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<Duration>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-14 17:02:09 +02:00
co-authored by Claude Opus 4.6
parent 538ee182d9
commit b6d299300d
8 changed files with 38 additions and 17 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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:
+1
View File
@@ -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 <roman@shl.dev> Mon, 14 Apr 2026 00:00:00 +0000
+2
View File
@@ -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"
+10 -3
View File
@@ -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);
}
}
+9 -6
View File
@@ -29,9 +29,12 @@ fn build_solvers(config: &Config, solver_names: &[&str]) -> Result<HashMap<Strin
}
let solver_config = config.solver_config(name)?;
let solver: Box<dyn Solver> = 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");
+7 -4
View File
@@ -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<Duration> {
Some(self.propagation_delay)
}
}
+6 -3
View File
@@ -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<Duration> {
None
}
}