From b6d299300d39ef8ca97973f78268a69affafb366 Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Tue, 14 Apr 2026 17:02:09 +0200 Subject: [PATCH] Add configurable DNS propagation delay per solver DNS-01 solvers now accept a propagation_delay option (default 5s). When multiple solvers are used for a certificate, the longest delay is used. The Solver trait method changes from needs_propagation_delay() returning bool to propagation_delay() returning Option. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + README.md | 3 ++- debian/changelog | 1 + examples/config.toml | 2 ++ src/config.rs | 13 ++++++++++--- src/renew.rs | 15 +++++++++------ src/solver/dns01.rs | 11 +++++++---- src/solver/mod.rs | 9 ++++++--- 8 files changed, 38 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b068560..bc251d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Named hooks (`[[hook]]`) referenced by certificates via `hooks = ["name"]`. Triggered hooks run after all renewals complete, in definition order. - Multiple listen addresses for HTTP-01 and TLS-ALPN-01 solvers (`listen = ["[::]:80", "[::]:8080"]`). Certforge tries all addresses and succeeds if at least one binds. Useful for initial provisioning when a proxy isn't running yet. - `dane-check` now queries published TLSA records from DNS and verifies them against expected values. Exits with code 1 if any records are missing or mismatched. +- Configurable DNS propagation delay per solver (`propagation_delay`, default 5s). ### Fixes diff --git a/README.md b/README.md index 0ea7404..a8ff57d 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ Solvers are defined in `[solver.*]` blocks. Each certificate references a solver ```toml [solver.dns] type = "dns-01" -dns = "main" # references a [dns.*] block +dns = "main" # references a [dns.*] block +propagation_delay = 5 # seconds to wait for DNS propagation (default: 5) ``` **HTTP-01** -- serves challenge tokens over HTTP: diff --git a/debian/changelog b/debian/changelog index 5cc5d5a..108e0b6 100644 --- a/debian/changelog +++ b/debian/changelog @@ -5,6 +5,7 @@ certforge (0.6.0-1) unstable; urgency=low * Fix inline hooks ignoring --dry-run flag * Improve config validation * dane-check verifies published TLSA records against expected values + * Configurable DNS propagation delay per solver -- Roman Kivalin Mon, 14 Apr 2026 00:00:00 +0000 diff --git a/examples/config.toml b/examples/config.toml index 07e2392..5766617 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -44,6 +44,7 @@ protocol = "tcp" [solver.dns] type = "dns-01" dns = "main" # references [dns.main] +# propagation_delay = 5 # seconds to wait for DNS propagation (default: 5) # [solver.dns-org] # type = "dns-01" @@ -77,6 +78,7 @@ dns = "main" # references [dns.main] # name = "mail" # domains = ["mail.example.com"] # solver = "dns" +# key_type = "ecdsa-p256" # ecdsa-p256, ecdsa-p384 # key_credential = "/etc/credstore.encrypted/mail-tls-key" # # key_path = "/etc/certforge/keys/mail.key" # cert_path = "/etc/certforge/certs/mail.pem" diff --git a/src/config.rs b/src/config.rs index abe1b9f..894bd03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,6 +127,10 @@ fn default_dns_protocol() -> DnsProtocol { DnsProtocol::Tcp } +fn default_propagation_delay() -> u64 { + 5 +} + /// Challenge solver configuration, tagged by type. #[derive(Debug, Deserialize)] #[serde(tag = "type")] @@ -136,6 +140,9 @@ pub enum SolverConfig { Dns01 { /// Reference to a [dns.*] client config by name. dns: String, + /// Seconds to wait for DNS propagation after publishing challenge records. + #[serde(default = "default_propagation_delay")] + propagation_delay: u64, }, #[serde(rename = "http-01")] Http01 { @@ -433,7 +440,7 @@ impl Config { // Validate solver configs for (name, solver) in &self.solver { match solver { - SolverConfig::Dns01 { dns } => { + SolverConfig::Dns01 { dns, .. } => { if !self.dns.contains_key(dns) { return Err(Error::Config(format!( "solver.{name}: dns client '{dns}' not found in [dns.*]" @@ -526,7 +533,7 @@ impl Config { ))); } // DNS-01: verify domain matches a zone in the DNS client - if let Some(SolverConfig::Dns01 { dns: dns_name }) = self.solver.get(name) + if let Some(SolverConfig::Dns01 { dns: dns_name, .. }) = self.solver.get(name) && let Some(dns_config) = self.dns.get(dns_name.as_str()) { let base = domain.strip_prefix("*.").unwrap_or(domain); let challenge_name = format!("_acme-challenge.{base}"); @@ -597,7 +604,7 @@ impl Config { // Solvers reference DNS clients for solver in self.solver.values() { - if let SolverConfig::Dns01 { dns } = solver { + if let SolverConfig::Dns01 { dns, .. } = solver { referenced_dns.insert(dns); } } diff --git a/src/renew.rs b/src/renew.rs index 0957aa9..f317335 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -29,9 +29,12 @@ fn build_solvers(config: &Config, solver_names: &[&str]) -> Result = match solver_config { - SolverConfig::Dns01 { dns } => { + SolverConfig::Dns01 { dns, propagation_delay } => { let dns_config = config.dns_client(dns)?; - Box::new(Dns01Solver::new(dns_config.clone())) + Box::new(Dns01Solver::new( + dns_config.clone(), + std::time::Duration::from_secs(*propagation_delay), + )) } SolverConfig::Http01 { listen, webroot } => { if let Some(webroot) = webroot { @@ -224,10 +227,10 @@ async fn renew_certificate( // Run ACME flow, ensuring cleanup happens even on failure let order_result = async { - // Wait for DNS propagation if any solver needs it - if solvers.values().any(|s| s.needs_propagation_delay()) { - tracing::debug!("waiting 5s for DNS propagation"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + // Wait for DNS propagation (use the longest delay across all solvers) + if let Some(delay) = solvers.values().filter_map(|s| s.propagation_delay()).max() { + tracing::debug!(seconds = delay.as_secs(), "waiting for DNS propagation"); + tokio::time::sleep(delay).await; } tracing::debug!(name = %cert_config.name, "setting challenges ready"); diff --git a/src/solver/dns01.rs b/src/solver/dns01.rs index cdbbffa..2919beb 100644 --- a/src/solver/dns01.rs +++ b/src/solver/dns01.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use hickory_proto::rr::Name; use crate::acme::ChallengeInfo; @@ -9,11 +11,12 @@ use crate::error::{Error, Result}; /// DNS-01 challenge solver using RFC 2136 dynamic updates. pub struct Dns01Solver { dns_config: DnsClientConfig, + propagation_delay: Duration, } impl Dns01Solver { - pub fn new(dns_config: DnsClientConfig) -> Self { - Self { dns_config } + pub fn new(dns_config: DnsClientConfig, propagation_delay: Duration) -> Self { + Self { dns_config, propagation_delay } } /// Strip wildcard prefix for DNS-01 challenge name. @@ -81,7 +84,7 @@ impl super::Solver for Dns01Solver { updater.delete_txt_record(&zone, &name, &challenge.dns_value).await } - fn needs_propagation_delay(&self) -> bool { - true + fn propagation_delay(&self) -> Option { + Some(self.propagation_delay) } } diff --git a/src/solver/mod.rs b/src/solver/mod.rs index 0a538d4..ca95e89 100644 --- a/src/solver/mod.rs +++ b/src/solver/mod.rs @@ -2,6 +2,8 @@ pub mod dns01; pub mod http01; pub mod tls_alpn01; +use std::time::Duration; + use crate::acme::ChallengeInfo; use crate::error::Result; @@ -14,8 +16,9 @@ pub trait Solver: Send + Sync { /// Clean up a challenge after validation (success or failure). async fn cleanup(&self, challenge: &ChallengeInfo) -> Result<()>; - /// Whether this solver needs a DNS propagation delay after presenting. - fn needs_propagation_delay(&self) -> bool { - false + /// Propagation delay to wait after presenting challenges. + /// Returns None for solvers that don't need a delay (HTTP-01, TLS-ALPN-01). + fn propagation_delay(&self) -> Option { + None } }