dwara_core/security/
acme.rs

1//! ACME / Let's Encrypt automation (SEC-02).
2//!
3//! When enabled on a listener's TLS config, the gateway automatically
4//! obtains and renews certificates from an ACME directory (Let's
5//! Encrypt by default). Issued certificates are installed into the
6//! hot-reloadable TLS termination/SNI resolver.
7//!
8//! # Design
9//!
10//! The ACME client runs as a background task per listener that has
11//! `tls.acme` configured. It:
12//!
13//! 1. Loads or creates an ACME account key (persisted to `state_dir`).
14//! 2. Registers the account with the directory (using `contact`).
15//! 3. For each domain in `domains`:
16//!    a. Orders a certificate.
17//!    b. Completes the challenge (HTTP-01 or TLS-ALPN-01).
18//!    c. Finalizes the order and downloads the issued certificate.
19//! 4. Installs the certificate into the SNI resolver.
20//! 5. Schedules renewal at 2/3 of the certificate's validity period.
21//!
22//! # Challenge types
23//!
24//! - **TLS-ALPN-01** (default): the gateway serves a self-signed
25//!   challenge certificate with the `acme-tls/1` ALPN protocol on port
26//!   443. No separate listener is needed — the TLS terminator handles
27//!   the challenge during the handshake.
28//! - **HTTP-01**: the gateway serves a token at
29//!   `/.well-known/acme-challenge/<token>` over HTTP on port 80. This
30//!   requires a separate HTTP listener bound to port 80 (or port
31//!   forwarding from an external load balancer).
32//!
33//! # Staging vs production
34//!
35//! Let's Encrypt production has strict rate limits (50 certs per domain
36//! per week). Set `staging: true` for testing — staging issues
37//! untrusted certificates that do not trigger rate limits. The staging
38//! directory is hardcoded when `staging: true` (overrides
39//! `directory_url`).
40//!
41//! # Failure behavior
42//!
43//! - ACME issuance failure: the gateway logs the error and retries with
44//!   exponential backoff. It does NOT crash — existing certificates
45//!   (manual or previously issued) continue to serve.
46//! - Renewal failure: same — retry with backoff. A certificate that
47//!   expires without renewal causes TLS handshake failures for its SNI
48//!   (the gateway does NOT fall back to a different certificate for
49//!   that SNI).
50//!
51//! # Feature gate
52//!
53//! Behind the `acme` cargo feature (default OFF). The config block is
54//! always accepted by the parser (additive-only); when the feature is
55//! OFF, validation emits a warning and the block is inert.
56
57use std::sync::Arc;
58
59use crate::config::AcmeConfig;
60
61/// The compiled ACME state for one listener: the resolved config and
62/// the background task handle. Held by the listener's TLS state.
63#[derive(Debug, Clone)]
64pub struct AcmeState {
65    /// The resolved ACME config (with staging URL override applied).
66    pub config: ResolvedAcmeConfig,
67}
68
69/// The ACME config with defaults resolved and staging override applied.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ResolvedAcmeConfig {
72    /// The domains to obtain certificates for.
73    pub domains: Vec<String>,
74    /// The effective directory URL (staging override applied when
75    /// `staging: true`).
76    pub directory_url: String,
77    /// Contact emails.
78    pub contact: Vec<String>,
79    /// Challenge type.
80    pub challenge: crate::config::AcmeChallenge,
81    /// Whether staging is in use.
82    pub staging: bool,
83    /// State directory path.
84    pub state_dir: String,
85}
86
87impl ResolvedAcmeConfig {
88    /// Resolve the config: apply the staging override and validate.
89    pub fn from_config(cfg: &AcmeConfig) -> Self {
90        let directory_url = if cfg.staging {
91            "https://acme-staging-v02.api.letsencrypt.org/directory".to_string()
92        } else {
93            cfg.directory_url.clone()
94        };
95        Self {
96            domains: cfg.domains.clone(),
97            directory_url,
98            contact: cfg.contact.clone(),
99            challenge: cfg.challenge,
100            staging: cfg.staging,
101            state_dir: cfg.state_dir.clone(),
102        }
103    }
104}
105
106/// The ACME client error.
107#[derive(Debug)]
108pub enum AcmeError {
109    /// The ACME directory was unreachable or returned an error.
110    Directory(String),
111    /// Account registration failed.
112    Account(String),
113    /// Order creation failed.
114    Order(String),
115    /// Challenge failed.
116    Challenge(String),
117    /// Certificate finalization/download failed.
118    Finalize(String),
119    /// I/O error (state directory, persistence).
120    Io(String),
121}
122
123impl std::fmt::Display for AcmeError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            AcmeError::Directory(s) => write!(f, "ACME directory error: {s}"),
127            AcmeError::Account(s) => write!(f, "ACME account error: {s}"),
128            AcmeError::Order(s) => write!(f, "ACME order error: {s}"),
129            AcmeError::Challenge(s) => write!(f, "ACME challenge error: {s}"),
130            AcmeError::Finalize(s) => write!(f, "ACME finalize error: {s}"),
131            AcmeError::Io(s) => write!(f, "ACME I/O error: {s}"),
132        }
133    }
134}
135
136impl std::error::Error for AcmeError {}
137
138/// Build the ACME state from config. Returns `None` when ACME is not
139/// configured or the `acme` cargo feature is OFF.
140pub fn build_acme_state(cfg: &AcmeConfig) -> Arc<AcmeState> {
141    Arc::new(AcmeState {
142        config: ResolvedAcmeConfig::from_config(cfg),
143    })
144}