use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, is_tls_alpn_challenge};
use tokio_rustls::LazyConfigAcceptor;
use tokio_rustls::rustls::ServerConfig;
const ACME_POLL_TIMEOUT: Duration = Duration::from_secs(60);
pub struct AcmeOptions {
pub domains: Vec<String>,
pub contact: Option<String>,
pub cache_dir: PathBuf,
pub staging: bool,
}
pub struct AcmeAcceptors {
pub challenge: Arc<ServerConfig>,
pub default: Arc<ServerConfig>,
}
pub fn bootstrap(opts: AcmeOptions) -> AcmeAcceptors {
if let Err(e) = std::fs::create_dir_all(&opts.cache_dir) {
tracing::warn!(
"could not create ACME cache directory {}: {e}",
opts.cache_dir.display()
);
}
let mut state = AcmeConfig::new(opts.domains.clone())
.contact(
opts.contact
.iter()
.map(|e| format!("mailto:{e}"))
.collect::<Vec<_>>(),
)
.cache(DirCache::new(opts.cache_dir.clone()))
.directory_lets_encrypt(!opts.staging)
.state();
let challenge = state.challenge_rustls_config();
let default = state.default_rustls_config();
let domains = opts.domains.join(",");
tokio::spawn(async move {
use futures::StreamExt;
loop {
match tokio::time::timeout(ACME_POLL_TIMEOUT, state.next()).await {
Ok(Some(Ok(ok))) => {
tracing::info!(target: "s4_acme", domains = %domains, "ACME event: {ok:?}");
crate::metrics::record_acme_renewal("ok");
}
Ok(Some(Err(err))) => {
tracing::warn!(target: "s4_acme", domains = %domains, "ACME error: {err:?}");
crate::metrics::record_acme_renewal("err");
}
Ok(None) => {
tracing::warn!(target: "s4_acme", "ACME state stream ended unexpectedly");
break;
}
Err(_elapsed) => {
tracing::warn!(
target: "s4_acme",
domains = %domains,
timeout_secs = ACME_POLL_TIMEOUT.as_secs(),
"ACME renewal poll timeout; will retry on next iteration"
);
crate::metrics::record_acme_renewal_timeout();
}
}
}
});
AcmeAcceptors { challenge, default }
}
pub async fn accept_one<IO>(
sock: IO,
acceptors: &AcmeAcceptors,
) -> Result<Option<tokio_rustls::server::TlsStream<IO>>, Box<dyn std::error::Error + Send + Sync>>
where
IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let start = LazyConfigAcceptor::new(Default::default(), sock).await?;
if is_tls_alpn_challenge(&start.client_hello()) {
let mut tls = start.into_stream(acceptors.challenge.clone()).await?;
use tokio::io::AsyncWriteExt;
let _ = tls.shutdown().await;
Ok(None)
} else {
let tls = start.into_stream(acceptors.default.clone()).await?;
Ok(Some(tls))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bootstrap_returns_challenge_and_default_configs() {
crate::tls::install_default_crypto_provider();
let dir = tempfile::tempdir().unwrap();
let acceptors = bootstrap(AcmeOptions {
domains: vec!["example.test".into()],
contact: Some("ops@example.test".into()),
cache_dir: dir.path().to_path_buf(),
staging: true,
});
assert!(!Arc::ptr_eq(&acceptors.challenge, &acceptors.default));
}
#[tokio::test]
async fn renewal_poll_timeout_arm_fires_when_inner_future_hangs() {
assert_eq!(ACME_POLL_TIMEOUT, Duration::from_secs(60));
let pending = futures::future::pending::<()>();
let res = tokio::time::timeout(Duration::from_millis(20), pending).await;
assert!(
res.is_err(),
"tokio::time::timeout must surface Elapsed for a never-ready future; \
this is the same branch that bumps record_acme_renewal_timeout in \
the production loop"
);
crate::metrics::record_acme_renewal_timeout();
}
}