diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6c06a..76beb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - 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. ### Fixes diff --git a/debian/changelog b/debian/changelog index 4f53eb4..66656d1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,7 @@ certforge (0.6.0-1) unstable; urgency=low * Named hooks with per-certificate reference and cross-cert dedup + * Multiple listen addresses for HTTP-01 and TLS-ALPN-01 solvers -- Roman Kivalin Mon, 14 Apr 2026 00:00:00 +0000 diff --git a/examples/config.toml b/examples/config.toml index 0cd726e..07e2392 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -51,12 +51,13 @@ dns = "main" # references [dns.main] # [solver.http] # type = "http-01" -# listen = "[::]:80" # standalone HTTP server +# listen = "[::]:80" # single address +# # listen = ["[::]:80", "[::]:8080"] # multiple: try all, succeed if any binds # # OR: webroot = "/var/www/acme" # [solver.tls] # type = "tls-alpn-01" -# listen = "[::]:443" +# listen = "[::]:443" # single or list like http-01 # Named hooks — run once after all renewals, deduplicated across certificates. # Hooks run in the order defined here. diff --git a/src/config.rs b/src/config.rs index 9ffcf0a..29430f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -139,18 +139,59 @@ pub enum SolverConfig { }, #[serde(rename = "http-01")] Http01 { - /// Address to listen on for standalone HTTP server. - listen: Option, + /// Address(es) to listen on for standalone HTTP server. + /// Accepts a single address or a list. If multiple are given, + /// certforge tries all and succeeds if at least one binds. + #[serde(default, deserialize_with = "deserialize_listen_addrs")] + listen: Option>, /// Directory to write challenge tokens for an existing web server. webroot: Option, }, #[serde(rename = "tls-alpn-01")] TlsAlpn01 { - /// Address to listen on for the TLS-ALPN server. - listen: SocketAddr, + /// Address(es) to listen on for the TLS-ALPN server. + #[serde(deserialize_with = "deserialize_listen_addrs_required")] + listen: Vec, }, } +/// Deserialize `listen` as either a single SocketAddr or a list. +fn deserialize_listen_addrs<'de, D>(deserializer: D) -> std::result::Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(SocketAddr), + Many(Vec), + } + + Option::::deserialize(deserializer).map(|opt| { + opt.map(|v| match v { + OneOrMany::One(addr) => vec![addr], + OneOrMany::Many(addrs) => addrs, + }) + }) +} + +fn deserialize_listen_addrs_required<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(SocketAddr), + Many(Vec), + } + + OneOrMany::deserialize(deserializer).map(|v| match v { + OneOrMany::One(addr) => vec![addr], + OneOrMany::Many(addrs) => addrs, + }) +} + #[derive(Debug, Deserialize)] pub struct CertificateConfig { pub name: String, diff --git a/src/renew.rs b/src/renew.rs index 7630bc3..660110f 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -36,8 +36,8 @@ fn build_solvers(config: &Config, solver_names: &[&str]) -> Result { if let Some(webroot) = webroot { Box::new(Http01WebrootSolver::new(webroot.clone())) - } else if let Some(listen) = listen { - Box::new(Http01StandaloneSolver::new(*listen)) + } else if let Some(addrs) = listen { + Box::new(Http01StandaloneSolver::new(addrs.clone())) } else { return Err(Error::Config(format!( "solver '{name}': HTTP-01 requires either listen or webroot" @@ -45,7 +45,7 @@ fn build_solvers(config: &Config, solver_names: &[&str]) -> Result { - Box::new(TlsAlpn01Solver::new(*listen)) + Box::new(TlsAlpn01Solver::new(listen.clone())) } }; solvers.insert(name.to_string(), solver); diff --git a/src/solver/http01.rs b/src/solver/http01.rs index 955957e..be64931 100644 --- a/src/solver/http01.rs +++ b/src/solver/http01.rs @@ -16,14 +16,14 @@ use crate::error::{Error, Result}; /// HTTP-01 solver: standalone HTTP server mode. pub struct Http01StandaloneSolver { - listen: SocketAddr, + listen: Vec, tokens: Arc>>, server_started: Arc, _server_handle: tokio::sync::OnceCell>, } impl Http01StandaloneSolver { - pub fn new(listen: SocketAddr) -> Self { + pub fn new(listen: Vec) -> Self { Self { listen, tokens: Arc::new(RwLock::new(HashMap::new())), @@ -34,36 +34,65 @@ impl Http01StandaloneSolver { async fn ensure_server_running(&self) -> Result<()> { let _ = self._server_handle.get_or_try_init(|| async { - let listener = TcpListener::bind(self.listen).await.map_err(|e| { - Error::Config(format!("HTTP-01 solver: failed to bind {}: {e}", self.listen)) - })?; - tracing::info!(listen = %self.listen, "HTTP-01 challenge server started"); + // Try binding all addresses, succeed if at least one works + let mut listeners = Vec::new(); + let mut errors = Vec::new(); + + for addr in &self.listen { + match TcpListener::bind(addr).await { + Ok(listener) => { + tracing::info!(listen = %addr, "HTTP-01 challenge server listening"); + listeners.push(listener); + } + Err(e) => { + tracing::warn!(listen = %addr, error = %e, "HTTP-01 solver: failed to bind address"); + errors.push(format!("{addr}: {e}")); + } + } + } + + if listeners.is_empty() { + return Err(Error::Config(format!( + "HTTP-01 solver: failed to bind any address: {}", + errors.join(", ") + ))); + } let tokens = self.tokens.clone(); let started = self.server_started.clone(); let handle = tokio::spawn(async move { started.notify_waiters(); - loop { - let Ok((stream, _addr)) = listener.accept().await else { - continue; - }; + // Spawn an accept loop for each bound listener + let mut handles = Vec::new(); + for listener in listeners { let tokens = tokens.clone(); - tokio::spawn(async move { - let service = service_fn(move |req: Request| { + handles.push(tokio::spawn(async move { + loop { + let Ok((stream, _addr)) = listener.accept().await else { + continue; + }; let tokens = tokens.clone(); - async move { - handle_request(req, &tokens).await - } - }); - let _ = http1::Builder::new() - .serve_connection(hyper_util::rt::TokioIo::new(stream), service) - .await; - }); + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let tokens = tokens.clone(); + async move { + handle_request(req, &tokens).await + } + }); + let _ = http1::Builder::new() + .serve_connection(hyper_util::rt::TokioIo::new(stream), service) + .await; + }); + } + })); + } + // Wait for all accept loops (they run forever) + for h in handles { + let _ = h.await; } }); - // Wait for the server to be ready self.server_started.notified().await; Ok::<_, Error>(handle) diff --git a/src/solver/tls_alpn01.rs b/src/solver/tls_alpn01.rs index 9f75e22..9aa74ca 100644 --- a/src/solver/tls_alpn01.rs +++ b/src/solver/tls_alpn01.rs @@ -21,14 +21,14 @@ const ACME_TLS_ALPN_PROTO: &[u8] = b"acme-tls/1"; /// TLS-ALPN-01 solver with dynamic certificate resolution. pub struct TlsAlpn01Solver { - listen: SocketAddr, + listen: Vec, certs: Arc>>>, _server_handle: tokio::sync::OnceCell>, server_started: Arc, } impl TlsAlpn01Solver { - pub fn new(listen: SocketAddr) -> Self { + pub fn new(listen: Vec) -> Self { Self { listen, certs: Arc::new(RwLock::new(HashMap::new())), @@ -39,10 +39,28 @@ impl TlsAlpn01Solver { async fn ensure_server_running(&self) -> Result<()> { let _ = self._server_handle.get_or_try_init(|| async { - let listener = TcpListener::bind(self.listen).await.map_err(|e| { - Error::Config(format!("TLS-ALPN-01 solver: failed to bind {}: {e}", self.listen)) - })?; - tracing::info!(listen = %self.listen, "TLS-ALPN-01 challenge server started"); + let mut listeners = Vec::new(); + let mut errors = Vec::new(); + + for addr in &self.listen { + match TcpListener::bind(addr).await { + Ok(listener) => { + tracing::info!(listen = %addr, "TLS-ALPN-01 challenge server listening"); + listeners.push(listener); + } + Err(e) => { + tracing::warn!(listen = %addr, error = %e, "TLS-ALPN-01 solver: failed to bind address"); + errors.push(format!("{addr}: {e}")); + } + } + } + + if listeners.is_empty() { + return Err(Error::Config(format!( + "TLS-ALPN-01 solver: failed to bind any address: {}", + errors.join(", ") + ))); + } let certs = self.certs.clone(); let started = self.server_started.clone(); @@ -51,31 +69,33 @@ impl TlsAlpn01Solver { certs: certs.clone(), }); - let tls_config = Arc::new( - rustls::ServerConfig::builder() - .with_no_client_auth() - .with_cert_resolver(resolver), - ); - - // Set ALPN protocols - // Note: we accept acme-tls/1 — the resolver handles cert selection - let mut tls_config_mut = (*tls_config).clone(); - tls_config_mut.alpn_protocols = vec![ACME_TLS_ALPN_PROTO.to_vec()]; - let tls_config = Arc::new(tls_config_mut); + let mut tls_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver); + tls_config.alpn_protocols = vec![ACME_TLS_ALPN_PROTO.to_vec()]; + let tls_config = Arc::new(tls_config); let acceptor = TlsAcceptor::from(tls_config); let handle = tokio::spawn(async move { started.notify_waiters(); - loop { - let Ok((stream, _addr)) = listener.accept().await else { - continue; - }; + let mut handles = Vec::new(); + for listener in listeners { let acceptor = acceptor.clone(); - tokio::spawn(async move { - // Just accept and immediately close — the TLS handshake IS the validation - let _ = acceptor.accept(stream).await; - }); + handles.push(tokio::spawn(async move { + loop { + let Ok((stream, _addr)) = listener.accept().await else { + continue; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let _ = acceptor.accept(stream).await; + }); + } + })); + } + for h in handles { + let _ = h.await; } });