Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/estate-front/src/web/acme.rs

//! **Let's Encrypt, in this process** — ACME (RFC 8555) with `instant-acme`,
//! the HTTP-01 challenge answered on this front's own port 80.
//!
//! Asked only when [`super::certs::decide`] says so: nothing valid to serve, or
//! less than thirty days left on what is served. A failed attempt is not
//! repeated at once — Let's Encrypt allows five failed validations per name per
//! hour and five certificates per week for one set of names — so the next
//! attempt waits [`backoff`], and that wait is written to the data disk so a
//! reboot does not reset it.
//!
//! The ACME account is minted once per directory and kept (0600) beside the
//! certificates; the HTTP client trusts the compiled-in Mozilla roots
//! (`webpki-roots`), because an OS-less front has no trust store to read.

use std::collections::BTreeMap;
use std::path::Path;
#[cfg(feature = "acme")]
use std::pin::Pin;
use std::sync::RwLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[cfg(feature = "acme")]
use bytes::Bytes;
#[cfg(feature = "acme")]
use instant_acme::{Account, AccountCredentials, AuthorizationStatus, BodyWrapper, BytesResponse, ChallengeType, HttpClient, Identifier, NewAccount, NewOrder, OrderStatus, RetryPolicy};

/// Let's Encrypt's production directory — the default when the installer
/// feeds no `acme_directory`.
pub const LETS_ENCRYPT: &str = "https://acme-v02.api.letsencrypt.org/directory";

/// Where HTTP-01 challenges are asked for.
pub const CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";

/// **The challenges being answered** — token → key authorization. The :80
/// listener reads it; an order writes it for as long as it runs.
#[derive(Debug, Default)]
pub struct Challenges(RwLock<BTreeMap<String, String>>);

impl Challenges {
    pub fn answer(&self, token: &str) -> Option<String> {
        self.0.read().unwrap_or_else(|e| e.into_inner()).get(token).cloned()
    }
    #[cfg_attr(not(feature = "acme"), allow(dead_code))]
    fn put(&self, token: &str, key_auth: &str) {
        self.0.write().unwrap_or_else(|e| e.into_inner()).insert(token.to_string(), key_auth.to_string());
    }
    #[cfg_attr(not(feature = "acme"), allow(dead_code))]
    fn clear(&self) {
        self.0.write().unwrap_or_else(|e| e.into_inner()).clear();
    }
}

#[cfg(feature = "acme")]
/// **The ACME HTTP client**: hyper over rustls, the compiled-in roots; plain
/// `http://` only for a directory that says so (a local test server).
struct Client(hyper_util::client::legacy::Client<hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>, BodyWrapper<Bytes>>);

#[cfg(feature = "acme")]
impl Client {
    fn new() -> Result<Client, String> {
        let tls = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
            .with_safe_default_protocol_versions()
            .map_err(|e| format!("rustls: {e}"))?
            .with_root_certificates(rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() })
            .with_no_client_auth();
        let https = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config(tls).https_or_http().enable_http1().build();
        Ok(Client(hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()).build(https)))
    }
}

#[cfg(feature = "acme")]
impl HttpClient for Client {
    fn request(&self, req: http::Request<BodyWrapper<Bytes>>) -> Pin<Box<dyn std::future::Future<Output = Result<BytesResponse, instant_acme::Error>> + Send>> {
        let fut = self.0.request(req);
        Box::pin(async move { fut.await.map(BytesResponse::from).map_err(|e| instant_acme::Error::Other(Box::new(e))) })
    }
}

/// The account file of `directory`, under the data root.
#[cfg_attr(not(feature = "acme"), allow(dead_code))]
fn account_rel(directory: &str) -> String {
    format!("{}/acme/account-{}.json", super::certs::TLS_REL, &blake3::hash(directory.as_bytes()).to_hex()[..16])
}

#[cfg(feature = "acme")]
async fn account(root: &Path, directory: &str, contact: Option<&str>) -> Result<Account, String> {
    let rel = account_rel(directory);
    if let Ok(text) = std::fs::read_to_string(root.join(&rel)) {
        if let Ok(creds) = serde_json::from_str::<AccountCredentials>(&text) {
            return Account::builder_with_http(Box::new(Client::new()?)).from_credentials(creds).await.map_err(|e| format!("ACME: the kept account for {directory} was refused: {e}"));
        }
    }
    let mailto = contact.map(|c| if c.contains(':') { c.to_string() } else { format!("mailto:{c}") });
    let contacts: Vec<&str> = mailto.as_deref().into_iter().collect();
    let (acct, creds) = Account::builder_with_http(Box::new(Client::new()?))
        .create(&NewAccount { contact: &contacts, terms_of_service_agreed: true, only_return_existing: false }, directory.to_string(), None)
        .await
        .map_err(|e| format!("ACME: no account could be created at {directory}: {e}"))?;
    let text = serde_json::to_string(&creds).map_err(|e| e.to_string())?;
    crate::write_secret(root, &rel, &text).map_err(|e| format!("{e:#}"))?;
    Ok(acct)
}

/// The refusal ACME answers in a front built without `acme` (AIRGAP B1, owner 2026-10-25; A32-11).
pub const NOT_BUILT: &str = "ACME is not built into this front: it dials an ACME directory (Let's Encrypt by default), so it needs \
                             estate-front's `acme` feature (and the product's switch); feed a certificate instead";

/// **Why an attempt failed**: its kind (what the backoff goes by) and its words.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
    pub kind: FailKind,
    pub text: String,
}

impl std::fmt::Display for Failure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.text)
    }
}

fn other(text: String) -> Failure {
    Failure { kind: FailKind::Acme, text }
}

/// **Order one certificate for `hosts`** (the first is its name; all are in
/// it), answering HTTP-01 through `challenges`. Answers the PEM chain and key.
pub async fn issue(root: &Path, hosts: &[String], directory: &str, contact: Option<&str>, challenges: &Challenges) -> Result<(String, String), String> {
    issue_as(root, hosts, directory, contact, challenges).await.map_err(|f| f.text)
}

/// Without `acme`: nothing is dialled, the refusal is named.
#[cfg(not(feature = "acme"))]
pub async fn issue_as(_root: &Path, _hosts: &[String], _directory: &str, _contact: Option<&str>, _challenges: &Challenges) -> Result<(String, String), Failure> {
    Err(other(NOT_BUILT.to_owned()))
}

/// [`issue`], the failure told by kind: a challenge Let's Encrypt tried and found wrong
/// (an authorization or the order `invalid`, or the order's own problem document) is a
/// [`FailKind::Validation`]; everything else is [`FailKind::Acme`].
#[cfg(feature = "acme")]
pub async fn issue_as(root: &Path, hosts: &[String], directory: &str, contact: Option<&str>, challenges: &Challenges) -> Result<(String, String), Failure> {
    let acct = account(root, directory, contact).await.map_err(other)?;
    let ids: Vec<Identifier> = hosts.iter().map(|h| Identifier::Dns(h.clone())).collect();
    let mut order = acct.new_order(&NewOrder::new(&ids)).await.map_err(|e| other(format!("ACME: the order for {hosts:?} was refused: {e}")))?;
    let validation = |text: String| Failure { kind: FailKind::Validation, text };
    let result = async {
        let mut authorizations = order.authorizations();
        while let Some(a) = authorizations.next().await {
            let mut authz = a.map_err(|e| other(format!("ACME: an authorization could not be read: {e}")))?;
            match authz.status {
                AuthorizationStatus::Pending => {}
                AuthorizationStatus::Valid => continue,
                AuthorizationStatus::Invalid => return Err(validation("ACME: an authorization is Invalid (a validation failed)".to_string())),
                other_status => return Err(other(format!("ACME: an authorization is {other_status:?}"))),
            }
            let mut ch = authz.challenge(ChallengeType::Http01).ok_or_else(|| other("ACME: the server offers no http-01 challenge".to_string()))?;
            challenges.put(&ch.token, ch.key_authorization().as_str());
            ch.set_ready().await.map_err(|e| other(format!("ACME: the challenge could not be declared ready: {e}")))?;
        }
        let retry = RetryPolicy::new().timeout(Duration::from_secs(120));
        match order.poll_ready(&retry).await {
            Ok(OrderStatus::Ready) => {}
            Ok(st) => return Err(validation(format!("ACME: the order is {st:?} — port 80 of {hosts:?} did not answer the challenge (is DNS pointing at this front, and is tcp/80 open?)"))),
            Err(e @ instant_acme::Error::Api(_)) => return Err(validation(format!("ACME: the order failed its validation: {e}"))),
            Err(e) => return Err(other(format!("ACME: the order did not become ready: {e}"))),
        }
        let key = order.finalize().await.map_err(|e| other(format!("ACME: finalize: {e}")))?;
        let chain = order.poll_certificate(&retry).await.map_err(|e| other(format!("ACME: the certificate did not arrive: {e}")))?;
        Ok((chain, key))
    }
    .await;
    challenges.clear();
    result
}

/// **How long to wait after `failures` failed attempts in a row**: fifteen
/// minutes, doubling, at most six hours — under Let's Encrypt's five failed
/// validations per name per hour however often this front reboots.
pub fn backoff(failures: u32) -> Duration {
    let base = 15 * 60u64;
    Duration::from_secs(base.saturating_mul(1u64 << failures.saturating_sub(1).min(5)).min(6 * 3600))
}

/// The persisted wait: `<data>/tls/<host>/acme-next` = `<unix secs> <failures> <kind>`
/// (`validation` or `acme`; an older front wrote no kind). Only a real ACME failure writes
/// it: a name that does not point here never does ([`super::dns`]).
fn next_rel(host: &str) -> String {
    format!("{}/{host}/acme-next", super::certs::TLS_REL)
}

/// The waits this process holds besides the disk's — keyed by data root and
/// host, `(unix secs, failures)` — so a wait the disk refused is still honoured
/// until this front restarts (stinky row 21, 2026-10-10).
type Waits = std::collections::HashMap<(std::path::PathBuf, String), (u64, u32)>;

fn remembered() -> std::sync::MutexGuard<'static, Waits> {
    static WAITS: std::sync::OnceLock<std::sync::Mutex<Waits>> = std::sync::OnceLock::new();
    WAITS.get_or_init(Default::default).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// When ACME may be asked next for `host`, and how many attempts failed in a
/// row — the later of what the disk and this process's memory hold.
pub fn next_attempt(root: &Path, host: &str) -> (SystemTime, u32) {
    let text = std::fs::read_to_string(root.join(next_rel(host))).unwrap_or_default();
    let mut it = text.split_whitespace();
    let mut at = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
    let mut n = it.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
    if let Some(&(m_at, m_n)) = remembered().get(&(root.to_path_buf(), host.to_string())) {
        if (m_n, m_at) > (n, at) {
            (at, n) = (m_at, m_n);
        }
    }
    (UNIX_EPOCH + Duration::from_secs(at), n)
}

/// **What failed**, as the backoff keeps it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailKind {
    /// Let's Encrypt tried the challenge and it failed: one of the five failed validations
    /// per name per hour it allows.
    Validation,
    /// Any other ACME failure: the account, the order, the network, finalize.
    Acme,
}

/// The persisted wait of a host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Wait {
    pub at: SystemTime,
    pub failures: u32,
    /// `None`: written by an older front, which did not say what failed.
    pub kind: Option<FailKind>,
}

/// The persisted wait of `host`, if any.
pub fn wait(root: &Path, host: &str) -> Option<Wait> {
    let (at, failures) = next_attempt(root, host);
    let kind = std::fs::read_to_string(root.join(next_rel(host))).ok().and_then(|t| t.split_whitespace().nth(2).and_then(FailKind::parse));
    (failures > 0).then_some(Wait { at, failures, kind })
}

impl FailKind {
    pub fn as_str(self) -> &'static str {
        match self {
            FailKind::Validation => "validation",
            FailKind::Acme => "acme",
        }
    }
    pub fn parse(s: &str) -> Option<FailKind> {
        match s {
            "validation" => Some(FailKind::Validation),
            "acme" => Some(FailKind::Acme),
            _ => None,
        }
    }
}

/// Record a failed attempt at `now`: the next one waits [`backoff`]. The wait
/// is kept in memory as well as on disk; a disk that refuses it is said, loud.
pub fn failed(root: &Path, host: &str, now: SystemTime) -> Duration {
    failed_as(root, host, now, FailKind::Acme)
}

/// [`failed`], saying what failed.
pub fn failed_as(root: &Path, host: &str, now: SystemTime, kind: FailKind) -> Duration {
    let (_, n) = next_attempt(root, host);
    let wait = backoff(n + 1);
    let at = now.duration_since(UNIX_EPOCH).unwrap_or_default() + wait;
    remembered().insert((root.to_path_buf(), host.to_string()), (at.as_secs(), n + 1));
    // A wait the disk refuses is honoured from memory, without its kind (read as an older
    // front's wait: it gives way once the name is seen pointing here).
    if let Err(e) = crate::write_secret(root, &next_rel(host), &format!("{} {} {}\n", at.as_secs(), n + 1, kind.as_str())) {
        tracing::error!(host, file = %root.join(next_rel(host)).display(), error = %format!("{e:#}"), "ACME: the wait before the next attempt could not be kept on disk — it is honoured from memory until this front restarts");
    }
    wait
}

/// Rewrite `host`'s wait to run out at `at`, keeping its count and kind ("try now"); the
/// wait kept in memory follows.
pub fn wait_until(root: &Path, host: &str, at: SystemTime, failures: u32, kind: Option<FailKind>) {
    let secs = at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
    remembered().insert((root.to_path_buf(), host.to_string()), (secs, failures));
    let kind = kind.map(FailKind::as_str).unwrap_or("");
    if let Err(e) = crate::write_secret(root, &next_rel(host), &format!("{secs} {failures} {kind}\n")) {
        tracing::warn!(host, error = %format!("{e:#}"), "ACME: the shortened wait could not be kept on disk; it holds in memory");
    }
}

/// **Whether ACME may be asked now**, pure, for a name the DNS check let through:
/// no wait, or it ran out; or the wait was written by an older front (which said
/// nothing of what failed, and failed for a missing A record too) and the name is
/// now SEEN pointing here.
pub fn may_attempt(wait: Option<&Wait>, now: SystemTime, points_here: bool) -> bool {
    match wait {
        None => true,
        Some(w) => now >= w.at || (w.kind.is_none() && points_here),
    }
}

/// A success clears the wait, on disk and in memory.
pub fn succeeded(root: &Path, host: &str) {
    remembered().remove(&(root.to_path_buf(), host.to_string()));
    match std::fs::remove_file(root.join(next_rel(host))) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => tracing::warn!(host, %e, "ACME: the cleared wait could not be removed from disk"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ A32-11 (AIRGAP B1): **a front built without `acme` links no ACME client and dials no
    /// directory**: an attempt answers [`NOT_BUILT`] by name, whatever directory is fed.
    #[cfg(not(feature = "acme"))]
    #[tokio::test]
    async fn without_acme_no_directory_is_dialled_and_the_refusal_is_named() {
        let dir = tempfile::tempdir().unwrap();
        let f = issue_as(dir.path(), &["example.test".to_string()], "http://127.0.0.1:9/directory", None, &Challenges::default()).await.expect_err("not built");
        assert_eq!((f.kind, f.text.as_str()), (FailKind::Acme, NOT_BUILT));
        assert!(NOT_BUILT.contains("`acme` feature"), "{NOT_BUILT}");
    }

    #[test]
    fn a_failed_attempt_waits_longer_each_time_and_the_wait_survives_a_reboot() {
        assert_eq!(backoff(1), Duration::from_secs(15 * 60));
        assert_eq!(backoff(2), Duration::from_secs(30 * 60));
        assert_eq!(backoff(3), Duration::from_secs(60 * 60));
        assert_eq!(backoff(20), Duration::from_secs(6 * 3600), "capped");
        let dir = tempfile::tempdir().unwrap();
        let now = SystemTime::now();
        assert_eq!(next_attempt(dir.path(), "example.test").1, 0);
        failed(dir.path(), "example.test", now);
        failed(dir.path(), "example.test", now);
        let (at, n) = next_attempt(dir.path(), "example.test");
        assert_eq!(n, 2);
        assert!(at > now + Duration::from_secs(29 * 60) && at <= now + Duration::from_secs(31 * 60), "the second failure waits 30 min");
        succeeded(dir.path(), "example.test");
        assert_eq!(next_attempt(dir.path(), "example.test").1, 0);
    }

    /// Stinky row 21 (2026-10-10): `let _ = write_secret(backoff)` — a wait
    /// the disk did not take was forgotten, and Let's Encrypt was asked again
    /// at the next look.
    #[test]
    fn a_wait_the_disk_refuses_is_still_kept_in_memory() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("data");
        std::fs::write(&root, b"a file where the data root should be").unwrap();
        let now = SystemTime::now();
        let wait = failed(&root, "unwritable.example.test", now);
        assert_eq!(wait, backoff(1));
        let (at, n) = next_attempt(&root, "unwritable.example.test");
        assert_eq!(n, 1, "the failure is counted");
        assert!(at >= now + backoff(1) - Duration::from_secs(1), "and the wait is honoured");
        failed(&root, "unwritable.example.test", now);
        assert_eq!(next_attempt(&root, "unwritable.example.test").1, 2, "and it doubles");
        succeeded(&root, "unwritable.example.test");
        assert_eq!(next_attempt(&root, "unwritable.example.test").1, 0, "a success clears the memory too");
    }

    #[test]
    fn the_wait_says_what_failed_and_only_an_older_fronts_wait_gives_way_to_dns() {
        let dir = tempfile::tempdir().unwrap();
        let now = SystemTime::now();
        assert_eq!(wait(dir.path(), "a.test"), None);
        assert!(may_attempt(None, now, false), "no wait: ask");
        failed_as(dir.path(), "a.test", now, FailKind::Validation);
        let w = wait(dir.path(), "a.test").unwrap();
        assert_eq!((w.failures, w.kind), (1, Some(FailKind::Validation)));
        assert!(!may_attempt(Some(&w), now, true), "a real failure waits, even when the name points here");
        assert!(may_attempt(Some(&w), w.at, true), "until its wait runs out");
        failed_as(dir.path(), "a.test", now, FailKind::Acme);
        assert_eq!(wait(dir.path(), "a.test").unwrap().kind, Some(FailKind::Acme), "the latest failure names the kind");
        let old = Wait { at: now + Duration::from_secs(6 * 3600), failures: 6, kind: None };
        assert!(may_attempt(Some(&old), now, true), "an older front's wait gives way once the name points here");
        assert!(!may_attempt(Some(&old), now, false), "but holds when this front cannot tell");
    }

    #[test]
    fn the_account_file_is_per_directory() {
        assert_ne!(account_rel(LETS_ENCRYPT), account_rel("http://127.0.0.1:1/dir"));
        assert!(account_rel(LETS_ENCRYPT).starts_with("tls/acme/account-"));
    }
}