use std::sync::Arc;
use futures_util::StreamExt;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme, UseChallenge};
use rustls_acme::acme::{LETS_ENCRYPT_PRODUCTION_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY};
use tracing::{info, warn};
use super::material::crypto_provider;
use super::TlsConfigError;
pub struct AcmeHandle {
pub resolver: Arc<ResolvesServerCertAcme>,
challenge_config: Arc<ServerConfig>,
_driver: tokio::task::JoinHandle<()>,
}
impl AcmeHandle {
#[must_use]
pub fn resolver(&self) -> Arc<ResolvesServerCertAcme> {
Arc::clone(&self.resolver)
}
#[must_use]
pub fn challenge_config(&self) -> Arc<ServerConfig> {
Arc::clone(&self.challenge_config)
}
}
#[must_use]
pub fn acme_enabled_from_env() -> bool {
crate::surfaces::env_enabled("NAUTALID_TLS_ACME")
}
pub fn start_from_env() -> Result<AcmeHandle, TlsConfigError> {
reject_http01_challenge_env()?;
let domains = domains_from_env()?;
let cache_path = std::env::var("NAUTALID_TLS_ACME_CACHE_PATH")
.unwrap_or_else(|_| "/var/cache/nautalid/acme".into());
let mut config = AcmeConfig::new(domains)
.cache(DirCache::new(cache_path))
.challenge_type(UseChallenge::TlsAlpn01);
if let Ok(email) = std::env::var("NAUTALID_TLS_ACME_EMAIL") {
let email = email.trim();
if !email.is_empty() {
let contact = if email.contains('@') {
format!("mailto:{email}")
} else {
email.to_owned()
};
config = config.contact([contact]);
}
}
let directory = std::env::var("NAUTALID_TLS_ACME_DIRECTORY")
.ok()
.map(|raw| raw.trim().to_ascii_lowercase());
config = match directory.as_deref() {
Some("staging") | Some("letsencrypt-staging") => {
config.directory(LETS_ENCRYPT_STAGING_DIRECTORY)
}
Some("production") | Some("letsencrypt") | None => {
config.directory(LETS_ENCRYPT_PRODUCTION_DIRECTORY)
}
Some(url) if url.starts_with("https://") => config.directory(url),
Some(other) => {
return Err(TlsConfigError::Rustls(format!(
"unknown NAUTALID_TLS_ACME_DIRECTORY: {other}"
)));
}
};
let mut state: AcmeState<std::io::Error> = config.state();
let resolver = state.resolver();
let challenge_config = state.challenge_rustls_config_with_provider(crypto_provider()?);
let driver = tokio::spawn(async move {
while let Some(event) = state.next().await {
match event {
Ok(ok) => info!(?ok, "acme event"),
Err(err) => warn!(%err, "acme error"),
}
}
warn!("acme driver exited");
});
Ok(AcmeHandle {
resolver,
challenge_config,
_driver: driver,
})
}
fn domains_from_env() -> Result<Vec<String>, TlsConfigError> {
let raw = std::env::var("NAUTALID_TLS_ACME_DOMAINS").map_err(|_| {
TlsConfigError::Rustls("NAUTALID_TLS_ACME=1 requires NAUTALID_TLS_ACME_DOMAINS".into())
})?;
let domains: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if domains.is_empty() {
return Err(TlsConfigError::Rustls(
"NAUTALID_TLS_ACME_DOMAINS is empty".into(),
));
}
Ok(domains)
}
fn reject_http01_challenge_env() -> Result<(), TlsConfigError> {
match std::env::var("NAUTALID_TLS_ACME_CHALLENGE")
.ok()
.map(|raw| raw.trim().to_ascii_lowercase())
.as_deref()
{
Some("http-01") | Some("http01") => Err(TlsConfigError::Rustls(
"HTTP/1.x is not supported: NAUTALID_TLS_ACME_CHALLENGE=http-01 is forbidden; use TLS-ALPN-01 (default) or omit NAUTALID_TLS_ACME_CHALLENGE".into(),
)),
_ => Ok(()),
}
}