Support multiple listen addresses for HTTP-01 and TLS-ALPN-01 solvers

listen now accepts a single address or a list. Certforge tries binding
all addresses and succeeds if at least one works. This supports
initial provisioning scenarios where a proxy (e.g., haproxy) may not
be running yet — certforge can bind port 80 directly as a fallback
alongside a proxy-forwarded port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-14 10:48:16 +02:00
co-authored by Claude Opus 4.6
parent 004d93384d
commit 280e008ec9
7 changed files with 148 additions and 55 deletions
+1
View File
@@ -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
+1
View File
@@ -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 <roman@shl.dev> Mon, 14 Apr 2026 00:00:00 +0000
+3 -2
View File
@@ -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.
+45 -4
View File
@@ -139,18 +139,59 @@ pub enum SolverConfig {
},
#[serde(rename = "http-01")]
Http01 {
/// Address to listen on for standalone HTTP server.
listen: Option<SocketAddr>,
/// 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<Vec<SocketAddr>>,
/// Directory to write challenge tokens for an existing web server.
webroot: Option<PathBuf>,
},
#[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<SocketAddr>,
},
}
/// Deserialize `listen` as either a single SocketAddr or a list.
fn deserialize_listen_addrs<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<SocketAddr>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(SocketAddr),
Many(Vec<SocketAddr>),
}
Option::<OneOrMany>::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<Vec<SocketAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(SocketAddr),
Many(Vec<SocketAddr>),
}
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,
+3 -3
View File
@@ -36,8 +36,8 @@ fn build_solvers(config: &Config, solver_names: &[&str]) -> Result<HashMap<Strin
SolverConfig::Http01 { listen, webroot } => {
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<HashMap<Strin
}
}
SolverConfig::TlsAlpn01 { listen } => {
Box::new(TlsAlpn01Solver::new(*listen))
Box::new(TlsAlpn01Solver::new(listen.clone()))
}
};
solvers.insert(name.to_string(), solver);
+50 -21
View File
@@ -16,14 +16,14 @@ use crate::error::{Error, Result};
/// HTTP-01 solver: standalone HTTP server mode.
pub struct Http01StandaloneSolver {
listen: SocketAddr,
listen: Vec<SocketAddr>,
tokens: Arc<RwLock<HashMap<String, String>>>,
server_started: Arc<tokio::sync::Notify>,
_server_handle: tokio::sync::OnceCell<tokio::task::JoinHandle<()>>,
}
impl Http01StandaloneSolver {
pub fn new(listen: SocketAddr) -> Self {
pub fn new(listen: Vec<SocketAddr>) -> 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<hyper::body::Incoming>| {
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<hyper::body::Incoming>| {
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)
+45 -25
View File
@@ -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<SocketAddr>,
certs: Arc<RwLock<HashMap<String, Arc<CertifiedKey>>>>,
_server_handle: tokio::sync::OnceCell<tokio::task::JoinHandle<()>>,
server_started: Arc<tokio::sync::Notify>,
}
impl TlsAlpn01Solver {
pub fn new(listen: SocketAddr) -> Self {
pub fn new(listen: Vec<SocketAddr>) -> 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;
}
});