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-"));
}
}