use crate::config::AcmeConfig;
use anyhow::{Context, Result, anyhow, bail};
use bytes::Bytes;
use instant_acme::{
Account, BodyWrapper, BytesResponse, ChallengeType, ExternalAccountKey, HttpClient,
Identifier, NewAccount, NewOrder, RetryPolicy,
};
use rcgen::{CertificateParams, CustomExtension, KeyPair};
use rustls::pki_types::CertificateDer;
use rustls::pki_types::pem::PemObject;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::Duration;
use tracing::{info, warn};
#[derive(Clone, Default)]
pub struct ChallengeStore {
http01: Arc<StdRwLock<HashMap<String, String>>>,
tls_alpn: Arc<StdRwLock<HashMap<String, Arc<rustls::sign::CertifiedKey>>>>,
}
impl std::fmt::Debug for ChallengeStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let http = self.http01.read().map(|m| m.len()).unwrap_or(0);
let alpn = self.tls_alpn.read().map(|m| m.len()).unwrap_or(0);
f.debug_struct("ChallengeStore")
.field("http01_pending", &http)
.field("tls_alpn_pending", &alpn)
.finish()
}
}
impl ChallengeStore {
pub fn new() -> Self {
Self::default()
}
pub fn put_http01(&self, token: String, key_auth: String) {
if let Ok(mut m) = self.http01.write() {
m.insert(token, key_auth);
}
}
pub fn get_http01(&self, token: &str) -> Option<String> {
self.http01.read().ok().and_then(|m| m.get(token).cloned())
}
pub fn put_tls_alpn(&self, sni: String, cert: Arc<rustls::sign::CertifiedKey>) {
if let Ok(mut m) = self.tls_alpn.write() {
m.insert(sni, cert);
}
}
pub fn get_tls_alpn(&self, sni: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
self.tls_alpn.read().ok().and_then(|m| m.get(sni).cloned())
}
pub fn has_tls_alpn(&self) -> bool {
self.tls_alpn.read().map(|m| !m.is_empty()).unwrap_or(false)
}
}
#[derive(Debug)]
pub struct AcmeResolver {
default: Arc<rustls::sign::CertifiedKey>,
store: ChallengeStore,
}
impl AcmeResolver {
pub fn new(default: Arc<rustls::sign::CertifiedKey>, store: ChallengeStore) -> Self {
Self { default, store }
}
}
impl rustls::server::ResolvesServerCert for AcmeResolver {
fn resolve(
&self,
hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
let is_acme_alpn = hello
.alpn()
.map(|mut protos| protos.any(|p| p == b"acme-tls/1"))
.unwrap_or(false);
if is_acme_alpn {
let sni = hello.server_name()?;
return self.store.get_tls_alpn(sni);
}
Some(self.default.clone())
}
}
type HyperAcmeClient = hyper_util::client::legacy::Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
BodyWrapper<Bytes>,
>;
struct AcmeHttpClient {
inner: HyperAcmeClient,
retry_after: Arc<AtomicU64>,
}
impl AcmeHttpClient {
fn new(ca_root: Option<&str>, retry_after: Arc<AtomicU64>) -> Result<Self> {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(path) = ca_root {
let pem = std::fs::read(path)
.with_context(|| format!("reading acme.ca_root {}", path))?;
for cert in CertificateDer::pem_reader_iter(&mut &pem[..]) {
let cert = cert.with_context(|| format!("parsing acme.ca_root {}", path))?;
roots
.add(cert)
.with_context(|| format!("adding acme.ca_root {}", path))?;
}
}
let tls = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let inner = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(connector);
Ok(Self { inner, retry_after })
}
}
impl HttpClient for AcmeHttpClient {
fn request(
&self,
req: http::Request<BodyWrapper<Bytes>>,
) -> Pin<Box<dyn Future<Output = Result<BytesResponse, instant_acme::Error>> + Send>> {
let fut = HttpClient::request(&self.inner, req);
let retry_after = Arc::clone(&self.retry_after);
Box::pin(async move {
let rsp = fut.await?;
if rsp.parts.status == http::StatusCode::TOO_MANY_REQUESTS {
if let Some(secs) = rsp
.parts
.headers
.get(http::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok())
{
retry_after.store(secs, Ordering::SeqCst);
}
}
Ok(rsp)
})
}
}
pub struct AcmeManager {
cfg: AcmeConfig,
server_names: Vec<String>,
store: ChallengeStore,
}
impl AcmeManager {
pub fn new(cfg: AcmeConfig, server_names: Vec<String>) -> Self {
Self {
cfg,
server_names,
store: ChallengeStore::new(),
}
}
pub fn store(&self) -> ChallengeStore {
self.store.clone()
}
pub fn http_challenge_port(&self) -> u16 {
self.cfg.http_challenge_port.unwrap_or(80)
}
pub fn cert_path(&self) -> String {
format!("{}/cert.pem", self.cfg.cert_dir)
}
pub fn key_path(&self) -> String {
format!("{}/key.pem", self.cfg.cert_dir)
}
fn domains(&self) -> Vec<String> {
if self.cfg.domains.is_empty() {
self.server_names.clone()
} else {
self.cfg.domains.clone()
}
}
pub fn renewal_check_interval(&self) -> Duration {
Duration::from_secs(self.cfg.renewal_check_interval_secs.unwrap_or(3600))
}
fn has_usable_cert(&self) -> bool {
load_certified_key(&self.cert_path(), &self.key_path()).is_ok()
}
pub fn needs_renewal(&self) -> Result<bool> {
let pem = match std::fs::read(self.cert_path()) {
Ok(p) => p,
Err(_) => return Ok(true),
};
let (_, pem_obj) = x509_parser::pem::parse_x509_pem(&pem)
.map_err(|e| anyhow!("parsing issued cert PEM: {e}"))?;
let cert = pem_obj
.parse_x509()
.map_err(|e| anyhow!("parsing issued cert DER: {e}"))?;
let not_after = cert.validity().not_after.timestamp();
let now = chrono::Utc::now().timestamp();
let window = self.cfg.renewal_window as i64 * 86_400;
Ok(not_after - now < window)
}
pub async fn ensure_cert(&self) -> Result<()> {
if self.has_usable_cert() {
info!(cert = %self.cert_path(), "ACME: reusing existing certificate");
return Ok(());
}
self.issue_with_retry().await
}
pub async fn issue_with_retry(&self) -> Result<()> {
let retry_after = Arc::new(AtomicU64::new(0));
let max_attempts: u32 = 5;
let mut attempt: u32 = 0;
loop {
attempt += 1;
retry_after.store(0, Ordering::SeqCst);
match self.try_issue(Arc::clone(&retry_after)).await {
Ok(()) => {
info!(
domains = ?self.domains(),
cert = %self.cert_path(),
"ACME certificate issued"
);
return Ok(());
}
Err(e) => {
if attempt >= max_attempts {
return Err(e).with_context(|| {
format!("ACME issuance failed after {} attempts", max_attempts)
});
}
let server = Duration::from_secs(retry_after.load(Ordering::SeqCst));
let backoff = Duration::from_secs(1u64 << (attempt - 1).min(6));
let wait = server.max(backoff);
warn!(
acme_error = %e,
attempt,
wait_secs = wait.as_secs(),
retry_after_secs = server.as_secs(),
"ACME issuance attempt failed; backing off before retry"
);
tokio::time::sleep(wait).await;
}
}
}
}
async fn try_issue(&self, retry_after: Arc<AtomicU64>) -> Result<()> {
let http = AcmeHttpClient::new(self.cfg.ca_root.as_deref(), retry_after)?;
let builder = Account::builder_with_http(Box::new(http));
let eab = self.load_eab()?;
let contact = format!("mailto:{}", self.cfg.email);
let new_account = NewAccount {
contact: &[contact.as_str()],
terms_of_service_agreed: self.cfg.agree_tos,
only_return_existing: false,
};
let (account, _creds) = builder
.create(&new_account, self.cfg.ca_endpoint.clone(), eab.as_ref())
.await
.map_err(map_acme_err)
.context("ACME account creation")?;
let domains = self.domains();
if domains.is_empty() {
bail!("ACME enabled but no domains/server_name to request");
}
let identifiers: Vec<Identifier> =
domains.iter().map(|d| Identifier::Dns(d.clone())).collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.map_err(map_acme_err)
.context("ACME new-order")?;
let mut authorizations = order.authorizations();
while let Some(authz) = authorizations.next().await {
let mut authz = authz.map_err(map_acme_err).context("fetch authorization")?;
if authz.status == instant_acme::AuthorizationStatus::Valid {
continue;
}
let domain = match &authz.identifier().identifier {
Identifier::Dns(d) => d.clone(),
other => bail!("unsupported ACME identifier: {:?}", other),
};
let pick = self.cfg.challenge_types.iter().find_map(|ct| {
let (kind, want) = match ct.as_str() {
"http-01" => (Pick::Http, ChallengeType::Http01),
"tls-alpn-01" => (Pick::Alpn, ChallengeType::TlsAlpn01),
"dns-01" => (Pick::Dns, ChallengeType::Dns01),
_ => return None,
};
if authz.challenges.iter().any(|c| c.r#type == want) {
Some((kind, want))
} else {
None
}
});
let (kind, want) = pick.ok_or_else(|| {
anyhow!("no configured challenge type offered for {}", domain)
})?;
let mut challenge = authz
.challenge(want.clone())
.ok_or_else(|| anyhow!("challenge {:?} unavailable for {}", want, domain))?;
let key_auth = challenge.key_authorization();
let token = challenge.token.clone();
match kind {
Pick::Http => {
self.store.put_http01(token.clone(), key_auth.as_str().to_string());
}
Pick::Alpn => {
let digest = key_auth.digest();
let cert = build_tls_alpn_cert(&domain, digest.as_ref())
.context("building tls-alpn-01 challenge cert")?;
self.store.put_tls_alpn(domain.clone(), Arc::new(cert));
}
Pick::Dns => {
self.post_dns_webhook(&domain, &token, &key_auth.dns_value())
.await
.context("posting dns-01 webhook")?;
}
}
challenge
.set_ready()
.await
.map_err(map_acme_err)
.context("set challenge ready")?;
}
let _ = authorizations;
let status = order
.poll_ready(&RetryPolicy::default())
.await
.map_err(map_acme_err)
.context("poll order ready")?;
if status != instant_acme::OrderStatus::Ready {
bail!("ACME order not ready after validation: {:?}", status);
}
let key_pair = KeyPair::generate().context("generating certificate key")?;
let mut params =
CertificateParams::new(domains.clone()).context("building CSR params")?;
params.distinguished_name = rcgen::DistinguishedName::new();
let csr = params
.serialize_request(&key_pair)
.context("serializing CSR")?;
order
.finalize_csr(csr.der())
.await
.map_err(map_acme_err)
.context("finalize order")?;
let cert_chain_pem = order
.poll_certificate(&RetryPolicy::default())
.await
.map_err(map_acme_err)
.context("poll certificate")?;
let key_pem = key_pair.serialize_pem();
self.write_cert(&cert_chain_pem, &key_pem)
.context("persisting issued certificate")?;
Ok(())
}
fn write_cert(&self, chain_pem: &str, key_pem: &str) -> Result<()> {
std::fs::create_dir_all(&self.cfg.cert_dir)
.with_context(|| format!("creating cert dir {}", self.cfg.cert_dir))?;
let cert_path = self.cert_path();
let key_path = self.key_path();
write_secret(&cert_path, chain_pem.as_bytes())?;
write_secret(&key_path, key_pem.as_bytes())?;
Ok(())
}
fn load_eab(&self) -> Result<Option<ExternalAccountKey>> {
match &self.cfg.eab {
None => Ok(None),
Some(eab) => {
let b64 = std::fs::read_to_string(&eab.hmac_key)
.with_context(|| format!("reading eab.hmac_key {}", eab.hmac_key))?;
let key_bytes = decode_b64(b64.trim())
.ok_or_else(|| anyhow!("eab.hmac_key is not valid base64"))?;
Ok(Some(ExternalAccountKey::new(eab.kid.clone(), &key_bytes)))
}
}
}
async fn post_dns_webhook(&self, domain: &str, token: &str, dns_value: &str) -> Result<()> {
let webhook = self
.cfg
.dns_webhook
.as_ref()
.ok_or_else(|| anyhow!("dns-01 challenge selected but no dns_webhook configured"))?;
let body = serde_json::json!({
"domain": domain,
"token": token,
"value": dns_value,
"action": "create",
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(webhook.timeout))
.build()
.context("building dns webhook client")?;
let mut req = client.post(&webhook.url).json(&body);
if let Some(auth_path) = &webhook.auth_header {
let header_value = std::fs::read_to_string(auth_path)
.with_context(|| format!("reading dns_webhook.auth_header {}", auth_path))?;
req = req.header(http::header::AUTHORIZATION, header_value.trim());
}
let resp = req.send().await.context("sending dns webhook")?;
if !resp.status().is_success() {
bail!("dns webhook returned HTTP {}", resp.status());
}
Ok(())
}
}
#[derive(Clone, Copy)]
enum Pick {
Http,
Alpn,
Dns,
}
fn build_tls_alpn_cert(
domain: &str,
key_auth_digest: &[u8],
) -> Result<rustls::sign::CertifiedKey> {
if key_auth_digest.len() != 32 {
bail!(
"tls-alpn-01 digest must be 32 bytes, got {}",
key_auth_digest.len()
);
}
let key_pair = KeyPair::generate().context("challenge cert key")?;
let mut params =
CertificateParams::new(vec![domain.to_string()]).context("challenge cert params")?;
params
.custom_extensions
.push(CustomExtension::new_acme_identifier(key_auth_digest));
let cert = params
.self_signed(&key_pair)
.context("self-signing challenge cert")?;
let cert_der = cert.der().clone();
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
.map_err(|e| anyhow!("loading challenge signing key: {e}"))?;
Ok(rustls::sign::CertifiedKey::new(vec![cert_der], signing_key))
}
pub fn load_certified_key(
cert_path: &str,
key_path: &str,
) -> Result<Arc<rustls::sign::CertifiedKey>> {
let certs = crate::tls::load_certificate(cert_path)?;
let key = crate::tls::load_private_key(key_path)?;
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)
.map_err(|e| anyhow!("loading signing key {}: {e}", key_path))?;
Ok(Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key)))
}
fn map_acme_err(e: instant_acme::Error) -> anyhow::Error {
anyhow!("ACME protocol error: {e}")
}
fn write_secret(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).with_context(|| format!("writing {}", path))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting 0600 on {}", path))?;
}
Ok(())
}
fn decode_b64(s: &str) -> Option<Vec<u8>> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(s))
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_roundtrip_http01() {
let store = ChallengeStore::new();
assert_eq!(store.get_http01("tok"), None);
store.put_http01("tok".into(), "tok.thumb".into());
assert_eq!(store.get_http01("tok").as_deref(), Some("tok.thumb"));
assert_eq!(store.get_http01("missing"), None);
}
#[test]
fn store_debug_redacts_secrets() {
let store = ChallengeStore::new();
store.put_http01("tok".into(), "super-secret-keyauth".into());
let dbg = format!("{:?}", store);
assert!(!dbg.contains("super-secret-keyauth"), "debug leaked key auth");
assert!(dbg.contains("http01_pending"));
}
#[test]
#[cfg_attr(miri, ignore)] fn tls_alpn_cert_has_acme_identifier_and_loads() {
let digest = [7u8; 32];
let ck = build_tls_alpn_cert("example.com", &digest).expect("build cert");
assert_eq!(ck.cert.len(), 1, "exactly the leaf challenge cert");
}
#[test]
fn tls_alpn_cert_rejects_bad_digest_len() {
assert!(build_tls_alpn_cert("example.com", &[0u8; 16]).is_err());
}
#[test]
fn b64_decode_tolerates_both_alphabets() {
assert_eq!(decode_b64("aGVsbG8").as_deref(), Some(&b"hello"[..]));
assert_eq!(decode_b64("aGVsbG8=").as_deref(), Some(&b"hello"[..]));
assert_eq!(decode_b64("!!not-b64!!"), None);
}
#[test]
#[cfg_attr(miri, ignore)] fn resolver_serves_default_without_acme_alpn() {
let ck = build_tls_alpn_cert("default.example", &[1u8; 32]).unwrap();
let resolver = AcmeResolver::new(Arc::new(ck), ChallengeStore::new());
assert!(!resolver.store.has_tls_alpn());
}
}
#[cfg(all(test, not(miri)))]
mod prop_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn prop_http01_store_roundtrip(token in "[A-Za-z0-9_-]{1,40}", auth in "[A-Za-z0-9._-]{1,80}") {
let store = ChallengeStore::new();
store.put_http01(token.clone(), auth.clone());
prop_assert_eq!(store.get_http01(&token), Some(auth));
let other = format!("{}x", token);
prop_assert_eq!(store.get_http01(&other), None);
}
}
}