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}