mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-12 16:00:45 +00:00
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:
co-authored by
Claude Opus 4.6
parent
004d93384d
commit
280e008ec9
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user