Skip to main content

rws_acme/acme/
mod.rs

1/// Automatic TLS certificate management via ACME (RFC 8555) / Let's Encrypt.
2///
3/// # Quick start
4///
5/// Set environment variables and call [`AcmeManager::provision_if_needed`] at
6/// startup before starting the TLS server:
7///
8/// ```bash
9/// RWS_CONFIG_ACME_DOMAINS=example.com,www.example.com
10/// RWS_CONFIG_ACME_EMAIL=admin@example.com
11/// ```
12///
13/// ```rust,no_run
14/// # extern crate rws_acme as rust_web_server;
15/// use rust_web_server::acme::{AcmeConfig, AcmeManager};
16///
17/// # async fn run() {
18/// if let Some(cfg) = AcmeConfig::from_env() {
19///     let mgr = AcmeManager::new(cfg);
20///     mgr.provision_if_needed().await.unwrap();
21///     // TLS cert is now at the paths in RWS_CONFIG_TLS_CERT_FILE / KEY_FILE.
22///     // Spawn renewal in background:
23///     tokio::spawn(mgr.run_renewal_loop());
24/// }
25/// # }
26/// ```
27///
28/// # Environment variables
29///
30/// | Variable | Default | Description |
31/// |---|---|---|
32/// | `RWS_CONFIG_ACME_DOMAINS` | — | Comma-separated domain list (required to activate ACME) |
33/// | `RWS_CONFIG_ACME_EMAIL` | — | Contact email sent to the CA |
34/// | `RWS_CONFIG_ACME_STAGING` | `false` | `true` = Let's Encrypt staging (for testing) |
35/// | `RWS_CONFIG_ACME_DIRECTORY` | LE production URL | Custom ACME directory URL |
36/// | `RWS_CONFIG_ACME_CERT_PATH` | `RWS_CONFIG_TLS_CERT_FILE` | Where to write the certificate chain |
37/// | `RWS_CONFIG_ACME_KEY_PATH` | `RWS_CONFIG_TLS_KEY_FILE` | Where to write the certificate private key |
38/// | `RWS_CONFIG_ACME_CHALLENGE_PORT` | `80` | Port for the HTTP-01 challenge server |
39/// | `RWS_CONFIG_ACME_RENEW_BEFORE_DAYS` | — | Renew this many days before expiry instead of at two-thirds of the lifetime |
40/// | `RWS_CONFIG_ACME_PROFILE` | — | Certificate profile to order (`classic`, `tlsserver`, `shortlived`, … as the CA offers) |
41/// | `RWS_CONFIG_ACME_ACCOUNT_KEY_PATH` | `acme_account.key` | Persist the ACME account key |
42/// | `RWS_CONFIG_ACME_REDIS` | — | Redis `host:port` shared by replicas (see [`shared`]) |
43/// | `RWS_CONFIG_ACME_REDIS_PASSWORD` | — | Its password |
44/// | `RWS_CONFIG_ACME_REDIS_PREFIX` | `rws:acme` | Key prefix in that Redis |
45/// | `RWS_CONFIG_ACME_ON_DEMAND` | `false` | Order certificates at the first handshake (see [`on_demand`]) |
46/// | `RWS_CONFIG_ACME_ON_DEMAND_ASK` | — | URL asked `?domain=<name>` before each such order; 2xx allows it |
47
48mod crypto;
49mod client;
50#[doc(hidden)]
51pub mod dns01;
52pub mod dns_api;
53pub mod on_demand;
54#[doc(hidden)]
55pub mod shared;
56pub mod tls_alpn;
57
58#[cfg(test)]
59mod tests;
60
61use crate::entry_point::Config;
62use client::AcmeHttpClient;
63use crypto::AccountKey;
64use tokio::io::{AsyncReadExt, AsyncWriteExt};
65
66/// Parsers of CA-supplied input, for `fuzz_api`.
67#[cfg(fuzzing)]
68#[doc(hidden)]
69pub mod fuzzing {
70    pub use super::crypto::cert_info_from_der;
71    pub use super::{json_object_keys, parse_rfc3339, parse_suggested_window};
72}
73
74// ── Config ────────────────────────────────────────────────────────────────────
75
76/// ACME provisioning configuration.
77#[derive(Clone, Debug)]
78pub struct AcmeConfig {
79    /// DNS names to include in the certificate (first entry is CN).
80    pub domains: Vec<String>,
81    /// Contact email address for the CA.
82    pub email: String,
83    /// ACME directory URL. Defaults to Let's Encrypt production.
84    pub directory_url: String,
85    /// Path to write the provisioned certificate chain (PEM).
86    pub cert_path: String,
87    /// Path to write the certificate's private key (PEM).
88    pub key_path: String,
89    /// Port for the temporary HTTP-01 challenge server (default 80).
90    pub challenge_port: u16,
91    /// Renew this many days before expiry. `None` (the default): at
92    /// two-thirds of the certificate's lifetime, or earlier when the CA's
93    /// renewal information (RFC 9773) says so.
94    pub renew_before_days: Option<i64>,
95    /// Certificate profile to order (draft-ietf-acme-profiles), e.g.
96    /// `shortlived`; `None` lets the CA choose.
97    pub profile: Option<String>,
98    /// Path to persist the ACME account key between restarts.
99    pub account_key_path: String,
100    /// How control of the domains is proven (since 18.4.0; `Http01` before).
101    pub challenge: ChallengeType,
102    /// DNS settings; `Some` exactly when `challenge` is `Dns01` or
103    /// `DnsPersist01` (then only where to look the record up).
104    pub dns: Option<DnsConfig>,
105}
106
107/// The ACME challenge rws answers (RFC 8555 §8, RFC 8737).
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum ChallengeType {
110    /// A file at `http://<domain>/.well-known/acme-challenge/` (port 80).
111    Http01,
112    /// A special certificate on the HTTPS port, for the `acme-tls/1` ALPN
113    /// protocol (RFC 8737): no port 80 needed.
114    TlsAlpn01,
115    /// A TXT record at `_acme-challenge.<domain>`: the only way to get a
116    /// wildcard certificate; no inbound port needed.
117    Dns01,
118    /// A TXT record at `_validation-persist.<domain>` naming the CA and the
119    /// ACME account, published once by you (draft-ietf-acme-dns-persist):
120    /// wildcards too, and no DNS credentials on the server. rws checks
121    /// that it's there and prints it when it isn't.
122    DnsPersist01,
123}
124
125impl ChallengeType {
126    /// `http-01`, `tls-alpn-01`, `dns-01` or `dns-persist-01` (any case).
127    pub fn parse(value: &str) -> Option<ChallengeType> {
128        match value.trim().to_ascii_lowercase().as_str() {
129            "http-01" => Some(ChallengeType::Http01),
130            "tls-alpn-01" => Some(ChallengeType::TlsAlpn01),
131            "dns-01" => Some(ChallengeType::Dns01),
132            "dns-persist-01" => Some(ChallengeType::DnsPersist01),
133            _ => None,
134        }
135    }
136
137    /// The challenge's name in ACME (`"http-01"`, ...).
138    pub fn as_str(&self) -> &'static str {
139        match self {
140            ChallengeType::Http01 => "http-01",
141            ChallengeType::TlsAlpn01 => "tls-alpn-01",
142            ChallengeType::Dns01 => "dns-01",
143            ChallengeType::DnsPersist01 => "dns-persist-01",
144        }
145    }
146
147    /// The configured challenge (`RWS_CONFIG_ACME_CHALLENGE`), `Http01` when
148    /// unset or unknown (`rws --check` reports an unknown one).
149    pub fn configured() -> ChallengeType {
150        non_empty_env(Config::RWS_CONFIG_ACME_CHALLENGE).and_then(|v| ChallengeType::parse(&v)).unwrap_or(ChallengeType::Http01)
151    }
152}
153
154/// DNS-01 settings.
155#[derive(Clone, Debug)]
156pub struct DnsConfig {
157    /// Who adds and removes the TXT records.
158    pub provider: DnsProvider,
159    /// The zone holding them; `None`: found by an SOA lookup.
160    pub zone: Option<String>,
161    /// How long to wait for a record to be visible before telling the CA.
162    pub propagation_secs: u64,
163}
164
165/// How DNS-01 records are published.
166#[derive(Clone, Debug)]
167pub enum DnsProvider {
168    /// A DNS UPDATE (RFC 2136) to the zone's primary server, signed with a
169    /// TSIG key (RFC 8945) when one is given — BIND, Knot, PowerDNS, ...
170    Rfc2136 { server: String, tsig: Option<Tsig> },
171    /// A program, run as `<program> present <fqdn> <value>` and
172    /// `<program> cleanup <fqdn> <value>`; exit status 0 = done. `server`
173    /// (`dns_server`, optional) is where to look for the record; the system
174    /// resolver without it.
175    Exec { program: String, server: Option<String> },
176    /// Cloudflare's API with an API token; the zone by id or looked up.
177    /// `server` as for `Exec`.
178    Cloudflare { token: dns_api::Secret, api: String, zone_id: Option<String>, server: Option<String> },
179    /// Amazon Route 53 with the standard AWS credentials; the hosted zone by
180    /// id or looked up. `server` as for `Exec`.
181    Route53 { credentials: dns_api::AwsCredentials, api: String, zone_id: Option<String>, server: Option<String> },
182    /// Nobody: records are published by you (`dns-persist-01`). `server`
183    /// is where rws looks for them; the system resolver without it.
184    Manual { server: Option<String> },
185}
186
187/// A TSIG key: its name, algorithm and secret.
188#[derive(Clone)]
189pub struct Tsig {
190    pub key_name: String,
191    /// `hmac-sha256` or `hmac-sha512`.
192    pub algorithm: String,
193    pub secret: Vec<u8>,
194}
195
196impl std::fmt::Debug for Tsig {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.debug_struct("Tsig").field("key_name", &self.key_name).field("algorithm", &self.algorithm).field("secret", &"(hidden)").finish()
199    }
200}
201
202/// A base64 TSIG secret, decoded; `None` if it isn't base64 (or is empty).
203#[doc(hidden)]
204pub fn base64_secret(value: &str) -> Option<Vec<u8>> {
205    crypto::base64_decode_std(value.trim()).filter(|s| !s.is_empty())
206}
207
208impl DnsConfig {
209    /// From `RWS_CONFIG_ACME_DNS_*`; an error names what's missing or wrong.
210    pub fn from_env() -> Result<DnsConfig, String> {
211        let provider = match non_empty_env(Config::RWS_CONFIG_ACME_DNS_PROVIDER).map(|p| p.to_ascii_lowercase()).as_deref() {
212            Some("rfc2136") => {
213                let server = non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER)
214                    .ok_or("DNS-01 with rfc2136 needs RWS_CONFIG_ACME_DNS_SERVER (the primary server, host[:port])")?;
215                let tsig = match (non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_KEY), non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_SECRET)) {
216                    (None, None) => None,
217                    (Some(key_name), Some(secret)) => {
218                        let secret = base64_secret(&secret).ok_or("RWS_CONFIG_ACME_DNS_TSIG_SECRET is not base64")?;
219                        let algorithm = non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM)
220                            .unwrap_or_else(|| "hmac-sha256".to_string())
221                            .to_ascii_lowercase();
222                        if !matches!(algorithm.as_str(), "hmac-sha256" | "hmac-sha512") {
223                            return Err(format!("RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM {algorithm:?}: expected hmac-sha256 or hmac-sha512"));
224                        }
225                        Some(Tsig { key_name, algorithm, secret })
226                    }
227                    _ => return Err("RWS_CONFIG_ACME_DNS_TSIG_KEY and RWS_CONFIG_ACME_DNS_TSIG_SECRET go together".to_string()),
228                };
229                DnsProvider::Rfc2136 { server, tsig }
230            }
231            Some("exec") => DnsProvider::Exec {
232                program: non_empty_env(Config::RWS_CONFIG_ACME_DNS_EXEC).ok_or("DNS-01 with exec needs RWS_CONFIG_ACME_DNS_EXEC (the program)")?,
233                server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
234            },
235            Some("cloudflare") => DnsProvider::Cloudflare {
236                token: dns_api::Secret(
237                    non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).ok_or("DNS-01 with cloudflare needs RWS_CONFIG_ACME_DNS_API_TOKEN (an API token with DNS edit permission)")?,
238                ),
239                api: non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_URL).unwrap_or_else(|| dns_api::CLOUDFLARE_API.to_string()).trim_end_matches('/').to_string(),
240                zone_id: non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE_ID),
241                server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
242            },
243            Some("route53") => DnsProvider::Route53 {
244                credentials: dns_api::AwsCredentials::from_env().ok_or("DNS-01 with route53 needs AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY")?,
245                api: non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_URL).unwrap_or_else(|| dns_api::ROUTE53_API.to_string()).trim_end_matches('/').to_string(),
246                zone_id: non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE_ID),
247                server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
248            },
249            Some(other) => return Err(format!("RWS_CONFIG_ACME_DNS_PROVIDER {other:?}: expected rfc2136, cloudflare, route53 or exec")),
250            None => return Err("DNS-01 needs RWS_CONFIG_ACME_DNS_PROVIDER (rfc2136, cloudflare, route53 or exec)".to_string()),
251        };
252        let zone = non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE);
253        let propagation_secs = non_empty_env(Config::RWS_CONFIG_ACME_DNS_PROPAGATION_SECS).and_then(|v| v.parse().ok()).unwrap_or(120);
254        Ok(DnsConfig { provider, zone, propagation_secs })
255    }
256}
257
258impl AcmeConfig {
259    /// Let's Encrypt production ACME directory.
260    pub const LETSENCRYPT: &'static str =
261        "https://acme-v02.api.letsencrypt.org/directory";
262    /// Let's Encrypt staging ACME directory (no real certificates; for testing).
263    pub const LETSENCRYPT_STAGING: &'static str =
264        "https://acme-staging-v02.api.letsencrypt.org/directory";
265
266    /// Build config from environment variables. Returns `None` when
267    /// `RWS_CONFIG_ACME_DOMAINS` is not set or empty.
268    pub fn from_env() -> Option<Self> {
269        let domains_str = std::env::var(Config::RWS_CONFIG_ACME_DOMAINS).ok()?;
270        if domains_str.trim().is_empty() { return None; }
271        install_hooks();
272
273        let domains: Vec<String> = domains_str
274            .split(',')
275            .map(|s| s.trim().to_string())
276            .filter(|s| !s.is_empty())
277            .collect();
278        if domains.is_empty() { return None; }
279
280        // Startup sets unset RWS_CONFIG_* variables to their (often empty)
281        // defaults, so an empty value must mean "not configured" here.
282        let email = non_empty_env(Config::RWS_CONFIG_ACME_EMAIL).unwrap_or_default();
283        let staging = non_empty_env(Config::RWS_CONFIG_ACME_STAGING)
284            .map(|v| v.eq_ignore_ascii_case("true"))
285            .unwrap_or(false);
286        let directory_url = if staging {
287            Self::LETSENCRYPT_STAGING.to_string()
288        } else {
289            non_empty_env(Config::RWS_CONFIG_ACME_DIRECTORY)
290                .unwrap_or_else(|| Self::LETSENCRYPT.to_string())
291        };
292
293        // Unset paths default into STATE_DIR, a dot-directory the static
294        // server never serves — not the working directory (the web root),
295        // where they were downloadable before 17.113.1 (#45).
296        let cert_path = non_empty_env(Config::RWS_CONFIG_ACME_CERT_PATH)
297            .or_else(|| non_empty_env(Config::RWS_CONFIG_TLS_CERT_FILE))
298            .unwrap_or_else(|| DEFAULT_CERT_PATH.to_string());
299        let key_path = non_empty_env(Config::RWS_CONFIG_ACME_KEY_PATH)
300            .or_else(|| non_empty_env(Config::RWS_CONFIG_TLS_KEY_FILE))
301            .unwrap_or_else(|| DEFAULT_KEY_PATH.to_string());
302
303        let challenge_port = non_empty_env(Config::RWS_CONFIG_ACME_CHALLENGE_PORT)
304            .and_then(|v| v.parse().ok())
305            .unwrap_or(80u16);
306
307        let renew_before_days = non_empty_env(Config::RWS_CONFIG_ACME_RENEW_BEFORE_DAYS)
308            .and_then(|v| v.parse().ok());
309        let profile = non_empty_env(Config::RWS_CONFIG_ACME_PROFILE);
310
311        let account_key_path = non_empty_env(Config::RWS_CONFIG_ACME_ACCOUNT_KEY_PATH)
312            .unwrap_or_else(|| DEFAULT_ACCOUNT_KEY_PATH.to_string());
313
314        let challenge = ChallengeType::configured();
315        let dns = match challenge {
316            ChallengeType::Dns01 => match DnsConfig::from_env() {
317                Ok(dns) => Some(dns),
318                Err(e) => {
319                    eprintln!("[ACME] {e}; certificates can't be issued until it's fixed.");
320                    return None;
321                }
322            },
323            // You publish the record; rws only looks for it.
324            ChallengeType::DnsPersist01 => Some(DnsConfig {
325                provider: DnsProvider::Manual { server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER) },
326                zone: None,
327                propagation_secs: 0,
328            }),
329            _ => None,
330        };
331
332        Some(AcmeConfig {
333            domains,
334            email,
335            directory_url,
336            cert_path,
337            key_path,
338            challenge_port,
339            renew_before_days,
340            profile,
341            account_key_path,
342            challenge,
343            dns,
344        })
345    }
346
347    /// Point the TLS listener at this config's certificate: sets
348    /// `RWS_CONFIG_TLS_CERT_FILE` / `RWS_CONFIG_TLS_KEY_FILE` to `cert_path` /
349    /// `key_path` when they're empty, so setting only `RWS_CONFIG_ACME_DOMAINS`
350    /// is enough for `Server::run_tls` to serve the issued certificate. An
351    /// explicitly configured TLS path is left alone.
352    pub fn install_as_tls_certificate(&self) {
353        if non_empty_env(Config::RWS_CONFIG_TLS_CERT_FILE).is_none() {
354            std::env::set_var(Config::RWS_CONFIG_TLS_CERT_FILE, &self.cert_path);
355        }
356        if non_empty_env(Config::RWS_CONFIG_TLS_KEY_FILE).is_none() {
357            std::env::set_var(Config::RWS_CONFIG_TLS_KEY_FILE, &self.key_path);
358        }
359    }
360}
361
362#[doc(hidden)]
363pub fn renew_check_interval_secs() -> u64 {
364    non_empty_env(Config::RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS)
365        .and_then(|v| v.parse().ok())
366        .filter(|secs| *secs > 0)
367        .unwrap_or(12 * 3600)
368}
369
370/// Where ACME keeps its files when no path is configured (relative to the
371/// working directory, but a dot-directory: never served).
372pub const STATE_DIR: &str = ".rws/acme";
373const DEFAULT_CERT_PATH: &str = ".rws/acme/cert.pem";
374const DEFAULT_KEY_PATH: &str = ".rws/acme/key.pem";
375const DEFAULT_ACCOUNT_KEY_PATH: &str = ".rws/acme/account.key";
376/// The defaults before 17.113.1 — in the web root.
377const LEGACY_DEFAULTS: [(&str, &str); 3] = [
378    ("cert.pem", DEFAULT_CERT_PATH),
379    ("key.pem", DEFAULT_KEY_PATH),
380    ("acme_account.key", DEFAULT_ACCOUNT_KEY_PATH),
381];
382
383/// Move files left at the old default paths (the web root) into
384/// [`STATE_DIR`] when the new default is in use and has no file yet, so an
385/// upgrade keeps its certificate and account — and stops serving its keys.
386fn adopt_legacy_files(config: &AcmeConfig) {
387    for (legacy, current) in LEGACY_DEFAULTS {
388        let in_use = [&config.cert_path, &config.key_path, &config.account_key_path].iter().any(|p| p.as_str() == current);
389        if !in_use || std::path::Path::new(current).exists() || !std::path::Path::new(legacy).is_file() {
390            continue;
391        }
392        match move_private(legacy, current) {
393            Ok(()) => println!("[ACME] Moved '{legacy}' (in the web root) to '{current}'."),
394            Err(e) => eprintln!("[ACME] Could not move '{legacy}' to '{current}': {e}"),
395        }
396    }
397}
398
399/// Move `from` to `to` (creating `to`'s directory), leaving it `0600`.
400fn move_private(from: &str, to: &str) -> std::io::Result<()> {
401    ensure_state_dir(to)?;
402    std::fs::rename(from, to).or_else(|_| std::fs::copy(from, to).and_then(|_| std::fs::remove_file(from)))?;
403    restrict_permissions(to);
404    Ok(())
405}
406
407/// Create the directory holding `path`, `0700` on Unix.
408fn ensure_state_dir(path: &str) -> std::io::Result<()> {
409    let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) else {
410        return Ok(());
411    };
412    if dir.is_dir() {
413        return Ok(());
414    }
415    #[cfg(unix)]
416    {
417        use std::os::unix::fs::DirBuilderExt;
418        std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)
419    }
420    #[cfg(not(unix))]
421    {
422        std::fs::create_dir_all(dir)
423    }
424}
425
426/// Write a private key: `0600` on Unix (the file is chmod-ed too if it
427/// already existed with broader permissions).
428fn write_private(path: &str, contents: &[u8]) -> std::io::Result<()> {
429    ensure_state_dir(path)?;
430    #[cfg(unix)]
431    {
432        use std::io::Write;
433        use std::os::unix::fs::OpenOptionsExt;
434        let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
435        file.write_all(contents)?;
436        restrict_permissions(path);
437        Ok(())
438    }
439    #[cfg(not(unix))]
440    {
441        std::fs::write(path, contents)
442    }
443}
444
445fn restrict_permissions(path: &str) {
446    #[cfg(unix)]
447    {
448        use std::os::unix::fs::PermissionsExt;
449        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
450    }
451    #[cfg(not(unix))]
452    let _ = path;
453}
454
455fn non_empty_env(name: &str) -> Option<String> {
456    std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
457}
458
459// ── HTTP-01 challenge store ───────────────────────────────────────────────────
460//
461// Pending challenges live in a process-wide map rather than inside one
462// temporary server, so whichever listener owns the challenge port can answer
463// them: the standalone server below at startup, or `Server::run_redirect`'s
464// HTTP→HTTPS listener once it holds port 80 — renewal would otherwise fail to
465// bind the port the redirect listener already has.
466
467#[doc(hidden)]
468pub fn challenges() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
469    static CHALLENGES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
470        std::sync::OnceLock::new();
471    CHALLENGES.get_or_init(|| {
472        install_hooks();
473        Default::default()
474    })
475}
476
477/// Plug ACME into TLS and the listeners (`tls::AcmeHooks`): on-demand and
478/// TLS-ALPN-01 certificates, the `acme-tls/1` protocol, HTTP-01 answers on
479/// the redirect listener. Done at every entry point (config, manager,
480/// challenge stores), once.
481#[doc(hidden)]
482pub fn install_hooks() {
483    static INSTALLED: std::sync::Once = std::sync::Once::new();
484    INSTALLED.call_once(|| {
485        fn ensure(name: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
486            Box::pin(on_demand::ensure(name))
487        }
488        fn http_challenge(uri: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + '_>> {
489            Box::pin(challenge_http_response_shared(uri))
490        }
491        crate::tls::set_acme_hooks(crate::tls::AcmeHooks {
492            on_demand_enabled: on_demand::enabled,
493            on_demand_ensure: ensure,
494            on_demand_certificate: on_demand::certificate,
495            tls_alpn_enabled: tls_alpn::enabled,
496            is_challenge: tls_alpn::is_challenge,
497            challenge_certificate: tls_alpn::certificate,
498            note_tls_listener: tls_alpn::note_tls_listener,
499            note_redirect_listener,
500            http_challenge,
501        });
502    });
503}
504
505/// Port the HTTP→HTTPS redirect listener is bound to, `0` when not running.
506static REDIRECT_LISTENER_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
507
508/// Called by `Server::run_redirect` once it has bound `port`, so challenges
509/// on that port are served by it instead of a standalone server.
510#[doc(hidden)]
511pub fn note_redirect_listener(port: u16) {
512    REDIRECT_LISTENER_PORT.store(port, std::sync::atomic::Ordering::SeqCst);
513}
514
515const CHALLENGE_PATH_PREFIX: &str = "/.well-known/acme-challenge/";
516
517/// The full HTTP/1.1 response for a request to `uri` if it's a pending
518/// HTTP-01 challenge (`200` with the key authorization), else `None`.
519#[doc(hidden)]
520pub fn challenge_http_response(uri: &str) -> Option<String> {
521    let token = uri.strip_prefix(CHALLENGE_PATH_PREFIX)?;
522    let key_auth = challenges().lock().unwrap().get(token)?.clone();
523    Some(challenge_response(&key_auth))
524}
525
526/// [`challenge_http_response`], also answering a challenge another replica
527/// presented (`[acme] redis`).
528#[doc(hidden)]
529pub async fn challenge_http_response_shared(uri: &str) -> Option<String> {
530    if let Some(response) = challenge_http_response(uri) {
531        return Some(response);
532    }
533    let store = shared::shared()?;
534    let token = uri.strip_prefix(CHALLENGE_PATH_PREFIX)?.to_string();
535    if token.is_empty() || token.len() > 256 {
536        return None;
537    }
538    let key_auth = tokio::task::spawn_blocking(move || store.challenge(&token)).await.ok()??;
539    Some(challenge_response(&key_auth))
540}
541
542fn challenge_response(key_auth: &str) -> String {
543    format!(
544        "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
545        key_auth.len(),
546        key_auth
547    )
548}
549
550// ── Manager ───────────────────────────────────────────────────────────────────
551
552/// Manages the ACME lifecycle: initial provisioning and automatic renewal.
553pub struct AcmeManager {
554    config: AcmeConfig,
555}
556
557impl AcmeManager {
558    pub fn new(config: AcmeConfig) -> Self {
559        install_hooks();
560        AcmeManager { config }
561    }
562
563    /// Provision a certificate if there is none, or renew it when it's due
564    /// (see [`check`](Self::check)).
565    pub async fn provision_if_needed(&self) -> Result<(), String> {
566        self.check().await.map(|_| ())
567    }
568
569    /// Renew when due, else say when to look again. Due is the two-thirds
570    /// point of the certificate's lifetime (`renew_before_days` set: that
571    /// many days before expiry) — or earlier, when the CA's ACME Renewal
572    /// Information (RFC 9773) suggests a window that starts sooner, e.g.
573    /// after a revocation. A fixed "30 days before expiry" would renew a
574    /// 6-day certificate at once and leave a 45-day one too little margin.
575    /// Renewal information that can't be fetched is not an error: the
576    /// two-thirds point still applies.
577    #[doc(hidden)]
578    pub async fn check(&self) -> Result<RenewalCheck, String> {
579        // Another replica renewed it: install theirs (the caller hot-swaps).
580        if self.adopt_shared().await {
581            return Ok(RenewalCheck::Renewed);
582        }
583        let Some(info) = crypto::read_cert_info(&self.config.cert_path) else {
584            println!("[ACME] No valid certificate found at '{}', provisioning...", self.config.cert_path);
585            self.provision().await?;
586            return Ok(RenewalCheck::Renewed);
587        };
588        // A name added to `domains` since the certificate was issued: order
589        // one that covers them all. Not as a renewal (`replaces`): the CA
590        // only accepts that for the same identifiers.
591        let missing = missing_names(&self.config.domains, crypto::read_cert_dns_names(&self.config.cert_path).as_deref());
592        if !missing.is_empty() {
593            println!("[ACME] Certificate at '{}' doesn't cover {:?}; ordering one for {:?}.",
594                self.config.cert_path, missing, self.config.domains);
595            self.provision_coordinated(None).await?;
596            return Ok(RenewalCheck::Renewed);
597        }
598        let (window, retry_after) = match &info.cert_id {
599            Some(cert_id) => match self.renewal_info(cert_id).await {
600                Ok(found) => found,
601                Err(e) => {
602                    eprintln!("[ACME] No renewal information ({e}); renewing at two-thirds of the lifetime.");
603                    (None, None)
604                }
605            },
606            None => (None, None),
607        };
608        let due = renewal_time(&info, window, self.config.renew_before_days);
609        let now = unix_now();
610        if now >= due {
611            println!("[ACME] Certificate at '{}' is due for renewal (expires {}), renewing...",
612                self.config.cert_path, crate::http::date::format(info.not_after.max(0) as u64));
613            self.provision_coordinated(info.cert_id.as_deref()).await?;
614            return Ok(RenewalCheck::Renewed);
615        }
616        println!("[ACME] Certificate at '{}' is current; renewal due {}.",
617            self.config.cert_path, crate::http::date::format(due.max(0) as u64));
618        let mut recheck = renew_check_interval_secs().min((due - now).max(60) as u64);
619        if info.cert_id.is_some() {
620            // The CA's answer can change (revocation): ask again when it
621            // says, else every 6 hours.
622            recheck = recheck.min(retry_after.unwrap_or(RENEWAL_INFO_RECHECK_SECS).max(60));
623        }
624        Ok(RenewalCheck::Current { recheck_in: recheck })
625    }
626
627    /// The CA's suggested renewal window for `cert_id` and its `Retry-After`,
628    /// from the directory's `renewalInfo` endpoint. `(None, None)` when the
629    /// CA has no such endpoint.
630    async fn renewal_info(&self, cert_id: &str) -> Result<(Option<(i64, i64)>, Option<u64>), String> {
631        let http = AcmeHttpClient::new()?;
632        let dir = fetch_directory(&http, &self.config.directory_url).await?;
633        let Some(base) = dir.renewal_info_url else { return Ok((None, None)) };
634        let resp = http.get(&format!("{}/{}", base.trim_end_matches('/'), cert_id)).await?;
635        if resp.status != 200 {
636            return Err(format!("renewalInfo: HTTP {}", resp.status));
637        }
638        let retry_after = resp.header("retry-after").and_then(|v| {
639            v.trim().parse::<u64>().ok().or_else(|| {
640                crate::http::date::parse(v).map(|at| at.saturating_sub(unix_now().max(0) as u64))
641            })
642        });
643        Ok((parse_suggested_window(&resp.body), retry_after))
644    }
645
646    /// Provision a new certificate unconditionally.
647    pub async fn provision(&self) -> Result<(), String> {
648        self.provision_coordinated(None).await
649    }
650
651    /// [`provision_replacing`](Self::provision_replacing), coordinated with
652    /// other replicas when `[acme] redis` is set: the one that takes the lock
653    /// orders and stores the result, the others wait for it.
654    async fn provision_coordinated(&self, replaces: Option<&str>) -> Result<(), String> {
655        let Some(store) = shared::shared() else {
656            return self.provision_replacing(replaces).await;
657        };
658        let domains = self.config.domains.clone();
659        let locked = tokio::task::spawn_blocking(move || store.lock(&domains)).await.unwrap_or(None);
660        if locked == Some(false) {
661            println!("[ACME] Another replica is ordering the certificate for {:?}; waiting for it.", self.config.domains);
662            let deadline = tokio::time::Instant::now() + shared::WAIT_FOR_OTHER;
663            while tokio::time::Instant::now() < deadline {
664                tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
665                if self.adopt_shared().await {
666                    return Ok(());
667                }
668            }
669            return Err("another replica held the ordering lock, but no certificate appeared".to_string());
670        }
671        let result = self.provision_replacing(replaces).await;
672        let (cert_path, key_path, domains) = (self.config.cert_path.clone(), self.config.key_path.clone(), self.config.domains.clone());
673        let stored = result.is_ok();
674        let _ = tokio::task::spawn_blocking(move || {
675            if stored {
676                if let (Ok(cert), Ok(key)) = (std::fs::read(&cert_path), std::fs::read(&key_path)) {
677                    store.store_certificate(&domains, &cert, &key);
678                }
679            }
680            if locked == Some(true) {
681                store.unlock(&domains);
682            }
683        })
684        .await;
685        result
686    }
687
688    /// Install the certificate another replica stored in Redis when it is
689    /// newer than the local one. `true`: the local files changed.
690    async fn adopt_shared(&self) -> bool {
691        let Some(store) = shared::shared() else { return false };
692        let domains = self.config.domains.clone();
693        let Ok(Some((cert, key))) = tokio::task::spawn_blocking(move || store.certificate(&domains)).await else {
694            return false;
695        };
696        let Some(theirs) = crypto::cert_info_from_pem(&cert) else { return false };
697        if crypto::read_cert_info(&self.config.cert_path).is_some_and(|ours| ours.not_after >= theirs.not_after) {
698            return false;
699        }
700        let written = ensure_state_dir(&self.config.cert_path)
701            .and_then(|_| std::fs::write(&self.config.cert_path, &cert))
702            .and_then(|_| write_private(&self.config.key_path, &key));
703        match written {
704            Ok(()) => {
705                println!("[ACME] Installed the certificate another replica stored (expires {}).", crate::http::date::format(theirs.not_after.max(0) as u64));
706                true
707            }
708            Err(e) => {
709                eprintln!("[ACME] Couldn't install the shared certificate: {e}");
710                false
711            }
712        }
713    }
714
715    /// [`provision`](Self::provision), telling the CA which certificate the
716    /// new one replaces (`replaces`, RFC 9773 §5) so it can exempt the order
717    /// from rate limits and track the renewal.
718    async fn provision_replacing(&self, replaces: Option<&str>) -> Result<(), String> {
719        println!("[ACME] Provisioning certificate for: {:?}", self.config.domains);
720
721        let http = AcmeHttpClient::new()?;
722
723        // 1. Load or generate account key.
724        let account_key = self.load_or_create_account_key()?;
725
726        // 2. Fetch ACME directory.
727        let dir = fetch_directory(&http, &self.config.directory_url).await?;
728        if let Some(profile) = &self.config.profile {
729            if !dir.profiles.is_empty() && !dir.profiles.contains(profile) {
730                return Err(format!(
731                    "the CA doesn't offer the certificate profile {profile:?} (RWS_CONFIG_ACME_PROFILE); it offers: {}",
732                    dir.profiles.join(", ")
733                ));
734            }
735        }
736
737        // 3. Get initial nonce.
738        let nonce = get_nonce(&http, &dir.new_nonce_url).await?;
739
740        // 4. Create/find account; get account URL and fresh nonce.
741        let (account_url, nonce) =
742            create_account(&http, &account_key, &dir.new_account_url, &self.config.email, &nonce).await?;
743        println!("[ACME] Account: {account_url}");
744
745        // 5. Submit order. A CA may refuse `replaces` (the certificate was
746        // already replaced by an order whose certificate never got
747        // installed): order again without it rather than not at all.
748        let profile = self.config.profile.as_deref();
749        let ordered = new_order(&http, &account_key, &account_url, &dir.new_order_url, &self.config.domains, replaces, profile, &nonce).await;
750        let (order, order_url, nonce) = match (ordered, replaces) {
751            (Ok(order), _) => order,
752            (Err(e), Some(_)) => {
753                eprintln!("[ACME] Order replacing the current certificate refused ({e}); ordering without 'replaces'.");
754                let nonce = get_nonce(&http, &dir.new_nonce_url).await?;
755                new_order(&http, &account_key, &account_url, &dir.new_order_url, &self.config.domains, None, profile, &nonce).await?
756            }
757            (Err(e), None) => return Err(e),
758        };
759        println!("[ACME] Order: {order_url}{}", replaces.map(|id| format!(" (replacing {id})")).unwrap_or_default());
760
761        // 6. Complete HTTP-01 challenge for each authorization.
762        let mut nonce = nonce;
763        for authz_url in &order.authorizations {
764            nonce = self.complete_authorization(&http, &account_key, &account_url, authz_url, &nonce).await?;
765        }
766
767        // 7. Generate certificate key + CSR.
768        let (cert_key, csr_der) = generate_csr(&self.config.domains)?;
769        let csr_b64 = crypto::base64url(&csr_der);
770
771        // 8. Finalize order with CSR.
772        let nonce = finalize_order(&http, &account_key, &account_url, &order.finalize_url, &csr_b64, &nonce).await?;
773
774        // 9. Poll order until certificate URL is available.
775        let (cert_url, nonce) = poll_order(&http, &account_key, &account_url, &order_url, &nonce).await?;
776        println!("[ACME] Certificate URL: {cert_url}");
777
778        // 10. Download certificate chain.
779        let cert_pem = download_cert(&http, &account_key, &account_url, &cert_url, &nonce).await?;
780
781        // 11. Write cert + key.
782        ensure_state_dir(&self.config.cert_path)
783            .and_then(|_| std::fs::write(&self.config.cert_path, &cert_pem))
784            .map_err(|e| format!("failed to write cert to '{}': {e}", self.config.cert_path))?;
785        write_private(&self.config.key_path, cert_key.serialize_pem().as_bytes())
786            .map_err(|e| format!("failed to write key to '{}': {e}", self.config.key_path))?;
787
788        println!(
789            "[ACME] Certificate written to '{}' and '{}'.",
790            self.config.cert_path, self.config.key_path
791        );
792        Ok(())
793    }
794
795    /// Startup provisioning that doesn't give up: retries
796    /// [`provision_if_needed`](Self::provision_if_needed) with exponential
797    /// backoff (30s doubling to 15 min) until a certificate is in place —
798    /// e.g. while DNS for a new domain is still propagating. Without a
799    /// certificate the TLS listener has nothing to serve.
800    pub async fn provision_until_ready(&self) {
801        adopt_legacy_files(&self.config);
802        let mut delay = 30u64;
803        loop {
804            match self.provision_if_needed().await {
805                Ok(()) => return,
806                Err(e) => {
807                    eprintln!("[ACME] Provisioning failed: {e}");
808                    eprintln!("[ACME] Retrying in {delay}s...");
809                    tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
810                    delay = (delay * 2).min(15 * 60);
811                }
812            }
813        }
814    }
815
816    /// Background renewal loop: [`check`](Self::check)s at most every
817    /// `RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS` (default 12 hours), sooner
818    /// when renewal is due earlier or the CA's renewal information asks to be
819    /// fetched again; after a failure it retries within an hour. After a
820    /// renewal it sends SIGHUP to itself (Unix) so the running server reloads
821    /// the TLS certificate — only then (it used to on every check).
822    pub async fn run_renewal_loop(self) {
823        renewal().running.store(true, std::sync::atomic::Ordering::SeqCst);
824        let interval = renew_check_interval_secs();
825        let mut next = crypto::read_cert_info(&self.config.cert_path)
826            .map(|info| ((renewal_time(&info, None, self.config.renew_before_days) - unix_now()).max(60) as u64).min(interval))
827            .unwrap_or(interval);
828        loop {
829            let forced = tokio::select! {
830                _ = tokio::time::sleep(tokio::time::Duration::from_secs(next)) => false,
831                _ = renewal().now.notified() => true,
832            };
833            let result = if forced {
834                println!("[ACME] Renewal of '{}' requested through the admin API, renewing...", self.config.cert_path);
835                let cert_id = crypto::read_cert_info(&self.config.cert_path).and_then(|info| info.cert_id);
836                self.provision_coordinated(cert_id.as_deref()).await.map(|_| RenewalCheck::Renewed)
837            } else {
838                self.check().await
839            };
840            renewal().record(&result);
841            match result {
842                Ok(RenewalCheck::Current { recheck_in }) => next = recheck_in,
843                Ok(RenewalCheck::Renewed) => {
844                    // Look at the new certificate's renewal information soon.
845                    next = 60;
846                    #[cfg(unix)]
847                    {
848                        // Signal the process to reload TLS cert (run_tls reloads on SIGHUP).
849                        unsafe { libc::kill(libc::getpid(), libc::SIGHUP); }
850                    }
851                }
852                Err(e) => {
853                    next = interval.min(3600);
854                    eprintln!("[ACME] Renewal failed: {e} — retrying in {next}s");
855                }
856            }
857        }
858    }
859
860    // ── private helpers ───────────────────────────────────────────────────────
861
862    fn load_or_create_account_key(&self) -> Result<AccountKey, String> {
863        let path = &self.config.account_key_path;
864        if let Ok(der) = std::fs::read(path) {
865            let key = AccountKey::from_pkcs8(&der)
866                .map_err(|e| format!("failed to load account key from '{path}': {e}"))?;
867            println!("[ACME] Loaded account key from '{path}'.");
868            return Ok(key);
869        }
870        let (key, der) = AccountKey::generate()
871            .map_err(|e| format!("account key generation failed: {e}"))?;
872        write_private(path, &der)
873            .map_err(|e| format!("failed to save account key to '{path}': {e}"))?;
874        println!("[ACME] Generated new account key at '{path}'.");
875        Ok(key)
876    }
877
878    async fn complete_authorization(
879        &self,
880        http: &AcmeHttpClient,
881        key: &AccountKey,
882        account_url: &str,
883        authz_url: &str,
884        nonce: &str,
885    ) -> Result<String, String> {
886        let (authz, nonce) = get_authorization(http, key, account_url, authz_url, nonce).await?;
887
888        if authz.status == "valid" {
889            return Ok(nonce);
890        }
891
892        let kind = self.config.challenge;
893        let challenge = authz.challenges.iter()
894            .find(|c| c.challenge_type == kind.as_str())
895            .ok_or_else(|| format!("no {} challenge in authorization {authz_url} (the CA offers: {})", kind.as_str(),
896                authz.challenges.iter().map(|c| c.challenge_type.as_str()).collect::<Vec<_>>().join(", ")))?;
897
898        let key_auth = format!(
899            "{}.{}",
900            challenge.token,
901            crypto::key_thumbprint(key)?
902        );
903        // The authorization's name: a wildcard's base name, without "*.".
904        let domain = authz.identifier_value.clone();
905
906        let presented = if kind == ChallengeType::DnsPersist01 {
907            self.check_persist_record(&domain, &challenge.issuer_domain_names, account_url).await?
908        } else {
909            self.present(kind, &challenge.token, &domain, &key_auth).await?
910        };
911
912        // Signal challenge ready, then poll for validation (up to 60 seconds).
913        let poll_result = match signal_challenge(http, key, account_url, &challenge.url, &nonce).await {
914            Ok(nonce) => poll_authorization_valid(http, key, account_url, authz_url, &nonce).await,
915            Err(e) => Err(e),
916        };
917
918        // Stop serving the challenge regardless of the outcome.
919        presented.withdraw().await;
920
921        let nonce = poll_result?;
922
923        println!("[ACME] Authorization validated for '{}'.", authz.identifier_value);
924        Ok(nonce)
925    }
926}
927
928impl AcmeManager {
929    /// Put the answer to a `kind` challenge where the CA will look.
930    async fn present(&self, kind: ChallengeType, token: &str, domain: &str, key_auth: &str) -> Result<Presented, String> {
931        match kind {
932            ChallengeType::Http01 => {
933                challenges().lock().unwrap().insert(token.to_string(), key_auth.to_string());
934                if let Some(store) = shared::shared() {
935                    let (token, key_auth) = (token.to_string(), key_auth.to_string());
936                    let _ = tokio::task::spawn_blocking(move || store.put_challenge(&token, &key_auth)).await;
937                }
938                // The redirect listener answers challenges itself once it owns the
939                // port (renewals); otherwise (startup) run a temporary server.
940                let port = self.config.challenge_port;
941                let standalone = if REDIRECT_LISTENER_PORT.load(std::sync::atomic::Ordering::SeqCst) == port {
942                    println!("[ACME] Answering HTTP-01 challenge via the redirect listener on port {port}.");
943                    None
944                } else {
945                    println!("[ACME] Starting HTTP-01 challenge server on port {port}...");
946                    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
947                    let handle = tokio::spawn(async move {
948                        if let Err(e) = run_challenge_server(port, shutdown_rx).await {
949                            eprintln!("[ACME] Challenge server error: {e}");
950                        }
951                    });
952                    // Small delay to let the server bind before signalling ACME.
953                    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
954                    Some((shutdown_tx, handle))
955                };
956                Ok(Presented::Http { token: token.to_string(), standalone })
957            }
958            ChallengeType::TlsAlpn01 => {
959                tls_alpn::add(domain, key_auth)?;
960                if let Some(store) = shared::shared() {
961                    let (domain, key_auth) = (domain.to_string(), key_auth.to_string());
962                    let _ = tokio::task::spawn_blocking(move || store.put_tls_alpn(&domain, &key_auth)).await;
963                }
964                let standalone = if tls_alpn::tls_listener_up() {
965                    println!("[ACME] Answering TLS-ALPN-01 challenge for {domain} via the TLS listener.");
966                    None
967                } else {
968                    let (ip, port, _) = crate::entry_point::get_ip_port_thread_count();
969                    let addr = if ip.contains(':') { format!("[{ip}]:{port}") } else { format!("{ip}:{port}") };
970                    println!("[ACME] Answering TLS-ALPN-01 challenge for {domain} on {addr}...");
971                    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
972                    let handle = tokio::spawn(async move {
973                        if let Err(e) = tls_alpn::serve(addr, shutdown_rx).await {
974                            eprintln!("[ACME] TLS-ALPN-01 listener error: {e}");
975                        }
976                    });
977                    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
978                    Some((shutdown_tx, handle))
979                };
980                Ok(Presented::TlsAlpn { domain: domain.to_string(), standalone })
981            }
982            ChallengeType::Dns01 => {
983                let dns = self.config.dns.clone().ok_or("DNS-01 isn't configured (RWS_CONFIG_ACME_DNS_PROVIDER)")?;
984                let name = dns01::record_name(domain);
985                let value = dns01::record_value(key_auth);
986                println!("[ACME] Publishing the DNS-01 record {name} TXT {value}...");
987                dns01::present(&dns, &name, &value).await?;
988                if dns01::wait_visible(&dns, &name, &value).await {
989                    println!("[ACME] {name} is visible.");
990                } else {
991                    eprintln!("[ACME] {name} isn't visible after {}s; asking the CA to check anyway.", dns.propagation_secs);
992                }
993                Ok(Presented::Dns { config: dns, name, value })
994            }
995            ChallengeType::DnsPersist01 => Err("dns-persist-01 records are published by you, not presented".to_string()),
996        }
997    }
998
999    /// DNS-PERSIST-01: make sure the record you published for `domain` is
1000    /// visible before asking the CA (a failed validation counts against its
1001    /// limits); when it isn't, say exactly what to publish.
1002    async fn check_persist_record(&self, domain: &str, issuers: &[String], account_url: &str) -> Result<Presented, String> {
1003        let dns = self.config.dns.clone().ok_or("dns-persist-01 isn't configured")?;
1004        let issuer = issuers.first().ok_or("the CA's dns-persist-01 challenge names no issuer-domain-names")?;
1005        // One record covers the name and, with policy=wildcard, `*.name`.
1006        let wildcard = self.config.domains.iter().any(|d| d.strip_prefix("*.").is_some_and(|base| base.eq_ignore_ascii_case(domain)));
1007        let name = dns01::persist_record_name(domain);
1008        if dns01::persist_record_published(&dns, &name, issuers, account_url, wildcard).await? {
1009            println!("[ACME] Found the DNS-PERSIST-01 record at {name}.");
1010            return Ok(Presented::Persistent);
1011        }
1012        let value = dns01::persist_record_value(issuer, account_url, wildcard);
1013        Err(format!("dns-persist-01: publish this TXT record once (it stays; rws retries until it's visible): {name}. TXT \"{value}\""))
1014    }
1015}
1016
1017/// A challenge answer in place, withdrawn after validation.
1018enum Presented {
1019    Http { token: String, standalone: Option<(tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>)> },
1020    TlsAlpn { domain: String, standalone: Option<(tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>)> },
1021    Dns { config: DnsConfig, name: String, value: String },
1022    /// A DNS-PERSIST-01 record: yours, left in place.
1023    Persistent,
1024}
1025
1026impl Presented {
1027    async fn withdraw(self) {
1028        let standalone = match self {
1029            Presented::Http { token, standalone } => {
1030                challenges().lock().unwrap().remove(&token);
1031                if let Some(store) = shared::shared() {
1032                    let _ = tokio::task::spawn_blocking(move || store.remove_challenge(&token)).await;
1033                }
1034                standalone
1035            }
1036            Presented::TlsAlpn { domain, standalone } => {
1037                tls_alpn::remove(&domain);
1038                if let Some(store) = shared::shared() {
1039                    let _ = tokio::task::spawn_blocking(move || store.remove_tls_alpn(&domain)).await;
1040                }
1041                standalone
1042            }
1043            Presented::Dns { config, name, value } => {
1044                if let Err(e) = dns01::cleanup(&config, &name, &value).await {
1045                    eprintln!("[ACME] Couldn't remove the DNS-01 record {name}: {e}");
1046                }
1047                None
1048            }
1049            Presented::Persistent => None,
1050        };
1051        if let Some((shutdown_tx, handle)) = standalone {
1052            let _ = shutdown_tx.send(());
1053            let _ = handle.await;
1054        }
1055    }
1056}
1057
1058// ── ACME protocol functions ───────────────────────────────────────────────────
1059
1060struct Directory {
1061    new_nonce_url: String,
1062    new_account_url: String,
1063    new_order_url: String,
1064    /// ACME Renewal Information base URL (RFC 9773), if the CA has one.
1065    renewal_info_url: Option<String>,
1066    /// Certificate profiles the CA offers (`meta.profiles`); empty when it
1067    /// doesn't list any.
1068    profiles: Vec<String>,
1069}
1070
1071struct Order {
1072    authorizations: Vec<String>,
1073    finalize_url: String,
1074}
1075
1076struct Authorization {
1077    status: String,
1078    identifier_value: String,
1079    challenges: Vec<Challenge>,
1080}
1081
1082struct Challenge {
1083    challenge_type: String,
1084    url: String,
1085    /// Empty for `dns-persist-01`, which has none.
1086    token: String,
1087    /// `dns-persist-01`: the CA names its record may carry.
1088    issuer_domain_names: Vec<String>,
1089}
1090
1091async fn fetch_directory(http: &AcmeHttpClient, url: &str) -> Result<Directory, String> {
1092    let resp = http.get(url).await?;
1093    if resp.status != 200 {
1094        return Err(format!("directory fetch failed: HTTP {}: {}", resp.status, resp.body));
1095    }
1096    let new_nonce_url = json_str(&resp.body, "newNonce")
1097        .ok_or("directory missing newNonce")?;
1098    let new_account_url = json_str(&resp.body, "newAccount")
1099        .ok_or("directory missing newAccount")?;
1100    let new_order_url = json_str(&resp.body, "newOrder")
1101        .ok_or("directory missing newOrder")?;
1102    let renewal_info_url = json_str(&resp.body, "renewalInfo");
1103    let profiles = json_object_keys(&resp.body, "profiles");
1104    Ok(Directory { new_nonce_url, new_account_url, new_order_url, renewal_info_url, profiles })
1105}
1106
1107async fn get_nonce(http: &AcmeHttpClient, url: &str) -> Result<String, String> {
1108    let resp = http.head(url).await?;
1109    resp.nonce().ok_or_else(|| "Replay-Nonce missing from newNonce response".to_string())
1110}
1111
1112async fn create_account(
1113    http: &AcmeHttpClient,
1114    key: &AccountKey,
1115    url: &str,
1116    email: &str,
1117    nonce: &str,
1118) -> Result<(String, String), String> {
1119    let payload = if email.is_empty() {
1120        r#"{"termsOfServiceAgreed":true}"#.to_string()
1121    } else {
1122        format!(r#"{{"termsOfServiceAgreed":true,"contact":["mailto:{email}"]}}"#)
1123    };
1124    let body = crypto::build_jws(key, nonce, url, None, Some(&payload))?;
1125    let resp = http.post_jws(url, &body).await?;
1126    if resp.status != 200 && resp.status != 201 {
1127        return Err(format!("newAccount failed: HTTP {}: {}", resp.status, resp.body));
1128    }
1129    let account_url = resp.location()
1130        .ok_or("newAccount response missing Location header")?;
1131    let nonce = resp.nonce()
1132        .ok_or("newAccount response missing Replay-Nonce")?;
1133    Ok((account_url, nonce))
1134}
1135
1136#[allow(clippy::too_many_arguments)]
1137async fn new_order(
1138    http: &AcmeHttpClient,
1139    key: &AccountKey,
1140    account_url: &str,
1141    url: &str,
1142    domains: &[String],
1143    replaces: Option<&str>,
1144    profile: Option<&str>,
1145    nonce: &str,
1146) -> Result<(Order, String, String), String> {
1147    let payload = new_order_payload(domains, replaces, profile);
1148    let body = crypto::build_jws(key, nonce, url, Some(account_url), Some(&payload))?;
1149    let resp = http.post_jws(url, &body).await?;
1150    if resp.status != 201 {
1151        return Err(format!("newOrder failed: HTTP {}: {}", resp.status, resp.body));
1152    }
1153    let order_url = resp.location()
1154        .ok_or("newOrder response missing Location header")?;
1155    let nonce = resp.nonce()
1156        .ok_or("newOrder response missing Replay-Nonce")?;
1157    let authorizations = json_array_strings(&resp.body, "authorizations");
1158    let finalize_url = json_str(&resp.body, "finalize")
1159        .ok_or("newOrder response missing finalize URL")?;
1160    Ok((Order { authorizations, finalize_url }, order_url, nonce))
1161}
1162
1163async fn get_authorization(
1164    http: &AcmeHttpClient,
1165    key: &AccountKey,
1166    account_url: &str,
1167    authz_url: &str,
1168    nonce: &str,
1169) -> Result<(Authorization, String), String> {
1170    // POST-as-GET (empty payload)
1171    let body = crypto::build_jws(key, nonce, authz_url, Some(account_url), None)?;
1172    let resp = http.post_jws(authz_url, &body).await?;
1173    if resp.status != 200 {
1174        return Err(format!("authorization GET failed: HTTP {}: {}", resp.status, resp.body));
1175    }
1176    let nonce = resp.nonce().unwrap_or_default();
1177    let status = json_str(&resp.body, "status").unwrap_or_default();
1178    let identifier_value = json_str(&resp.body, "value").unwrap_or_default();
1179    let challenges = parse_challenges(&resp.body);
1180    Ok((Authorization { status, identifier_value, challenges }, nonce))
1181}
1182
1183async fn signal_challenge(
1184    http: &AcmeHttpClient,
1185    key: &AccountKey,
1186    account_url: &str,
1187    challenge_url: &str,
1188    nonce: &str,
1189) -> Result<String, String> {
1190    let body = crypto::build_jws(key, nonce, challenge_url, Some(account_url), Some("{}"))?;
1191    let resp = http.post_jws(challenge_url, &body).await?;
1192    if resp.status != 200 {
1193        return Err(format!("challenge signal failed: HTTP {}: {}", resp.status, resp.body));
1194    }
1195    Ok(resp.nonce().unwrap_or_default())
1196}
1197
1198async fn poll_authorization_valid(
1199    http: &AcmeHttpClient,
1200    key: &AccountKey,
1201    account_url: &str,
1202    authz_url: &str,
1203    start_nonce: &str,
1204) -> Result<String, String> {
1205    let mut nonce = start_nonce.to_string();
1206    for attempt in 0..20 {
1207        tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
1208        // If nonce is empty, fetch a fresh one from the authz URL
1209        let body = crypto::build_jws(key, &nonce, authz_url, Some(account_url), None)?;
1210        let resp = http.post_jws(authz_url, &body).await?;
1211        if let Some(n) = resp.nonce() { nonce = n; }
1212        let status = json_str(&resp.body, "status").unwrap_or_default();
1213        match status.as_str() {
1214            "valid" => return Ok(nonce),
1215            "invalid" => {
1216                let err = json_str(&resp.body, "detail").unwrap_or_else(|| resp.body.clone());
1217                return Err(format!("authorization became invalid: {err}"));
1218            }
1219            _ => {
1220                if attempt == 19 {
1221                    return Err(format!("authorization timed out with status '{status}'"));
1222                }
1223            }
1224        }
1225    }
1226    Err("authorization polling timed out".to_string())
1227}
1228
1229async fn finalize_order(
1230    http: &AcmeHttpClient,
1231    key: &AccountKey,
1232    account_url: &str,
1233    finalize_url: &str,
1234    csr_b64: &str,
1235    nonce: &str,
1236) -> Result<String, String> {
1237    let payload = format!(r#"{{"csr":"{csr_b64}"}}"#);
1238    let body = crypto::build_jws(key, nonce, finalize_url, Some(account_url), Some(&payload))?;
1239    let resp = http.post_jws(finalize_url, &body).await?;
1240    if resp.status != 200 {
1241        return Err(format!("finalize failed: HTTP {}: {}", resp.status, resp.body));
1242    }
1243    Ok(resp.nonce().unwrap_or_default())
1244}
1245
1246async fn poll_order(
1247    http: &AcmeHttpClient,
1248    key: &AccountKey,
1249    account_url: &str,
1250    order_url: &str,
1251    start_nonce: &str,
1252) -> Result<(String, String), String> {
1253    let mut nonce = start_nonce.to_string();
1254    for attempt in 0..20 {
1255        tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
1256        let body = crypto::build_jws(key, &nonce, order_url, Some(account_url), None)?;
1257        let resp = http.post_jws(order_url, &body).await?;
1258        if let Some(n) = resp.nonce() { nonce = n; }
1259        let status = json_str(&resp.body, "status").unwrap_or_default();
1260        match status.as_str() {
1261            "valid" => {
1262                let cert_url = json_str(&resp.body, "certificate")
1263                    .ok_or("order is valid but missing certificate URL")?;
1264                return Ok((cert_url, nonce));
1265            }
1266            "invalid" => {
1267                return Err(format!("order became invalid: {}", resp.body));
1268            }
1269            _ => {
1270                if attempt == 19 {
1271                    return Err(format!("order timed out with status '{status}'"));
1272                }
1273            }
1274        }
1275    }
1276    Err("order polling timed out".to_string())
1277}
1278
1279async fn download_cert(
1280    http: &AcmeHttpClient,
1281    key: &AccountKey,
1282    account_url: &str,
1283    cert_url: &str,
1284    nonce: &str,
1285) -> Result<String, String> {
1286    let body = crypto::build_jws(key, nonce, cert_url, Some(account_url), None)?;
1287    let resp = http.post_jws(cert_url, &body).await?;
1288    if resp.status != 200 {
1289        return Err(format!("cert download failed: HTTP {}: {}", resp.status, resp.body));
1290    }
1291    Ok(resp.body)
1292}
1293
1294// ── HTTP-01 challenge server ──────────────────────────────────────────────────
1295
1296async fn run_challenge_server(
1297    port: u16,
1298    shutdown: tokio::sync::oneshot::Receiver<()>,
1299) -> Result<(), String> {
1300    let listeners = crate::server::bind_all_families("0.0.0.0", port)
1301        .await
1302        .map_err(|e| format!("challenge server: bind port {port} failed: {e}"))?;
1303
1304    let tasks: Vec<_> = listeners.into_iter().map(|l| tokio::spawn(serve_challenges(l))).collect();
1305    let _ = shutdown.await;
1306    for task in tasks {
1307        task.abort();
1308    }
1309    Ok(())
1310}
1311
1312async fn serve_challenges(listener: tokio::net::TcpListener) {
1313    loop {
1314        let Ok((mut stream, _)) = listener.accept().await else { break };
1315        tokio::spawn(async move {
1316            let mut buf = [0u8; 1024];
1317            let n = stream.read(&mut buf).await.unwrap_or(0);
1318            let req = String::from_utf8_lossy(&buf[..n]);
1319            let uri = req.lines().next().and_then(|l| l.split_whitespace().nth(1)).unwrap_or("");
1320            let response = challenge_http_response_shared(uri).await.unwrap_or_else(|| {
1321                "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string()
1322            });
1323            let _ = stream.write_all(response.as_bytes()).await;
1324        });
1325    }
1326}
1327
1328// ── minimal JSON field extractor ──────────────────────────────────────────────
1329
1330/// Generate a fresh ECDSA P-256 certificate key and a DER-encoded CSR for
1331/// `domains` (all listed as subjectAltName dNSNames, empty subject — Let's
1332/// Encrypt ignores the subject and sets validity itself).
1333fn generate_csr(domains: &[String]) -> Result<(rcgen::KeyPair, Vec<u8>), String> {
1334    let cert_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
1335        .map_err(|e| format!("cert key generation failed: {e}"))?;
1336    let mut params = rcgen::CertificateParams::new(domains.to_vec())
1337        .map_err(|e| format!("CSR params error: {e}"))?;
1338    params.distinguished_name = rcgen::DistinguishedName::new();
1339    let csr = params.serialize_request(&cert_key)
1340        .map_err(|e| format!("CSR serialize error: {e}"))?;
1341    Ok((cert_key, csr.der().to_vec()))
1342}
1343
1344/// The `newOrder` payload: identifiers, plus `replaces` (RFC 9773 §5) and
1345/// `profile` (draft-ietf-acme-profiles) when given.
1346#[doc(hidden)]
1347pub fn new_order_payload(domains: &[String], replaces: Option<&str>, profile: Option<&str>) -> String {
1348    let ids: Vec<String> = domains.iter()
1349        .map(|d| format!(r#"{{"type":"dns","value":"{}"}}"#, json_escape(d)))
1350        .collect();
1351    let mut payload = format!(r#"{{"identifiers":[{}]"#, ids.join(","));
1352    if let Some(replaces) = replaces {
1353        payload.push_str(&format!(r#","replaces":"{}""#, json_escape(replaces)));
1354    }
1355    if let Some(profile) = profile {
1356        payload.push_str(&format!(r#","profile":"{}""#, json_escape(profile)));
1357    }
1358    payload.push('}');
1359    payload
1360}
1361
1362fn json_escape(s: &str) -> String {
1363    crate::core::json::escape(s)
1364}
1365
1366/// The renewal loop's state, for the admin API.
1367#[doc(hidden)]
1368pub struct Renewal {
1369    running: std::sync::atomic::AtomicBool,
1370    now: tokio::sync::Notify,
1371    /// The last check's outcome: (Unix time, `renewed` / `current` / the error).
1372    last: std::sync::Mutex<Option<(u64, String)>>,
1373}
1374
1375impl Renewal {
1376    fn record(&self, result: &Result<RenewalCheck, String>) {
1377        let outcome = match result {
1378            Ok(RenewalCheck::Renewed) => "renewed".to_string(),
1379            Ok(RenewalCheck::Current { .. }) => "current".to_string(),
1380            Err(e) => e.clone(),
1381        };
1382        *self.last.lock().unwrap_or_else(|e| e.into_inner()) = Some((unix_now().max(0) as u64, outcome));
1383    }
1384}
1385
1386#[doc(hidden)]
1387pub fn renewal() -> &'static Renewal {
1388    static RENEWAL: std::sync::OnceLock<Renewal> = std::sync::OnceLock::new();
1389    RENEWAL.get_or_init(|| Renewal {
1390        running: std::sync::atomic::AtomicBool::new(false),
1391        now: tokio::sync::Notify::new(),
1392        last: std::sync::Mutex::new(None),
1393    })
1394}
1395
1396/// Ask the running renewal loop to order a new certificate now; `false`
1397/// when no loop runs (ACME isn't configured).
1398pub fn request_renewal() -> bool {
1399    let renewal = renewal();
1400    if !renewal.running.load(std::sync::atomic::Ordering::SeqCst) {
1401        return false;
1402    }
1403    renewal.now.notify_one();
1404    true
1405}
1406
1407/// Whether the renewal loop runs, and its last outcome `(time, outcome)`.
1408pub fn renewal_status() -> (bool, Option<(u64, String)>) {
1409    let renewal = renewal();
1410    (renewal.running.load(std::sync::atomic::Ordering::SeqCst), renewal.last.lock().unwrap_or_else(|e| e.into_inner()).clone())
1411}
1412
1413/// Outcome of [`AcmeManager::check`].
1414#[derive(Debug, PartialEq, Eq)]
1415#[doc(hidden)]
1416pub enum RenewalCheck {
1417    /// A certificate was issued; the server should reload it.
1418    Renewed,
1419    /// Nothing to do yet; check again in this many seconds.
1420    Current { recheck_in: u64 },
1421}
1422
1423/// How often to ask again for renewal information without a `Retry-After`.
1424const RENEWAL_INFO_RECHECK_SECS: u64 = 6 * 3600;
1425
1426fn unix_now() -> i64 {
1427    std::time::SystemTime::now()
1428        .duration_since(std::time::UNIX_EPOCH)
1429        .map_or(0, |d| d.as_secs() as i64)
1430}
1431
1432/// When to renew `info` (Unix seconds). The fallback is the two-thirds point
1433/// of its lifetime — Let's Encrypt's advice, and right for 6-day as for
1434/// 90-day certificates — or `renew_before_days` before expiry when that is
1435/// set. The CA's suggested window (`(start, end)`) can only bring renewal
1436/// earlier: a point in it, spread per certificate so a fleet doesn't renew
1437/// at the same second, wins when it's sooner. A window that has passed
1438/// means now.
1439/// The configured domains a certificate with the DNS names `have` doesn't
1440/// cover (DNS names compare case-insensitively; a wildcard only covers the
1441/// same wildcard, as ordered). Unreadable names (`None`) cover nothing.
1442#[doc(hidden)]
1443pub fn missing_names(domains: &[String], have: Option<&[String]>) -> Vec<String> {
1444    let have = have.unwrap_or(&[]);
1445    domains
1446        .iter()
1447        .filter(|d| !have.iter().any(|h| h.eq_ignore_ascii_case(d.trim_end_matches('.'))))
1448        .cloned()
1449        .collect()
1450}
1451
1452#[doc(hidden)]
1453pub fn renewal_time(info: &crypto::CertInfo, window: Option<(i64, i64)>, renew_before_days: Option<i64>) -> i64 {
1454    let lifetime = (info.not_after - info.not_before).max(0);
1455    let fallback = match renew_before_days {
1456        Some(days) => info.not_after - days.saturating_mul(86_400),
1457        None => info.not_before + lifetime / 3 * 2,
1458    };
1459    match window {
1460        Some((start, end)) => {
1461            let span = (end - start).max(0);
1462            let point = start + (span as f64 * window_fraction(info)) as i64;
1463            point.min(fallback)
1464        }
1465        None => fallback,
1466    }
1467}
1468
1469/// A fraction in `[0, 1)`, fixed per certificate (FNV-1a of its identity):
1470/// the same point in the window at every check, a different one per
1471/// certificate.
1472fn window_fraction(info: &crypto::CertInfo) -> f64 {
1473    let identity = info.cert_id.clone().unwrap_or_else(|| info.not_after.to_string());
1474    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1475    for byte in identity.bytes() {
1476        hash = (hash ^ byte as u64).wrapping_mul(0x0100_0000_01b3);
1477    }
1478    (hash >> 11) as f64 / (1u64 << 53) as f64
1479}
1480
1481/// `suggestedWindow.start` / `.end` of a renewalInfo response, as Unix
1482/// seconds; `None` when missing or malformed.
1483#[doc(hidden)]
1484pub fn parse_suggested_window(body: &str) -> Option<(i64, i64)> {
1485    let window = &body[body.find("\"suggestedWindow\"")?..];
1486    let start = parse_rfc3339(&json_str(window, "start")?)?;
1487    let end = parse_rfc3339(&json_str(window, "end")?)?;
1488    (start <= end).then_some((start, end))
1489}
1490
1491/// An RFC 3339 timestamp (`2026-10-26T12:00:00Z`, fractional seconds and
1492/// `±hh:mm` offsets allowed) as Unix seconds.
1493#[doc(hidden)]
1494pub fn parse_rfc3339(value: &str) -> Option<i64> {
1495    let b = value.trim().as_bytes();
1496    if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't') || b[13] != b':' || b[16] != b':' {
1497        return None;
1498    }
1499    let num = |range: std::ops::Range<usize>| -> Option<i64> {
1500        let digits = b.get(range)?;
1501        digits.iter().all(u8::is_ascii_digit).then(|| digits.iter().fold(0i64, |n, d| n * 10 + (d - b'0') as i64))
1502    };
1503    let (year, month, day) = (num(0..4)?, num(5..7)?, num(8..10)?);
1504    let (hour, minute, second) = (num(11..13)?, num(14..16)?, num(17..19)?);
1505    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
1506        return None;
1507    }
1508    let mut rest = &b[19..];
1509    if rest.first() == Some(&b'.') {
1510        let digits = rest[1..].iter().take_while(|c| c.is_ascii_digit()).count();
1511        if digits == 0 {
1512            return None;
1513        }
1514        rest = &rest[1 + digits..];
1515    }
1516    let offset = match rest {
1517        [b'Z' | b'z'] => 0,
1518        [sign @ (b'+' | b'-'), h1, h2, b':', m1, m2] => {
1519            let digits = [*h1, *h2, *m1, *m2];
1520            if !digits.iter().all(u8::is_ascii_digit) {
1521                return None;
1522            }
1523            let minutes = ((h1 - b'0') as i64 * 10 + (h2 - b'0') as i64) * 60 + (m1 - b'0') as i64 * 10 + (m2 - b'0') as i64;
1524            if *sign == b'+' { minutes * 60 } else { -minutes * 60 }
1525        }
1526        _ => return None,
1527    };
1528    let days = crypto::days_since_epoch(year, month, day)?;
1529    Some(days * 86_400 + hour * 3600 + minute * 60 + second - offset)
1530}
1531
1532/// The keys of the JSON object at `field` (e.g. a directory's
1533/// `meta.profiles`), skipping nested values; empty when absent.
1534#[doc(hidden)]
1535pub fn json_object_keys(json: &str, field: &str) -> Vec<String> {
1536    let key = format!("\"{}\"", field);
1537    let Some(at) = json.find(&key) else { return vec![] };
1538    let rest = json[at + key.len()..].trim_start_matches([' ', ':', '\n', '\r', '\t']);
1539    if !rest.starts_with('{') {
1540        return vec![];
1541    }
1542    let mut keys = Vec::new();
1543    let (mut depth, mut in_string, mut escaped) = (0usize, false, false);
1544    let mut current = String::new();
1545    let mut expecting_key = true;
1546    for c in rest.chars() {
1547        if in_string {
1548            if escaped {
1549                escaped = false;
1550                current.push(c);
1551            } else if c == '\\' {
1552                escaped = true;
1553            } else if c == '"' {
1554                in_string = false;
1555            } else {
1556                current.push(c);
1557            }
1558            continue;
1559        }
1560        match c {
1561            '"' => { in_string = true; current.clear(); }
1562            ':' if depth == 1 && expecting_key => { keys.push(std::mem::take(&mut current)); expecting_key = false; }
1563            ',' if depth == 1 => expecting_key = true,
1564            '{' | '[' => depth += 1,
1565            '}' | ']' => {
1566                depth -= 1;
1567                if depth == 0 { break; }
1568            }
1569            _ => {}
1570        }
1571    }
1572    keys
1573}
1574
1575#[doc(hidden)]
1576pub fn json_str(json: &str, field: &str) -> Option<String> {
1577    let key = format!("\"{}\"", field);
1578    let start = json.find(&key)? + key.len();
1579    let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1580    let inner = rest.strip_prefix('"')?;
1581    // Read up to the closing quote, unescaping \" and \\ — CA error details
1582    // (e.g. Pebble's `Get \"http://...\": connection refused`) contain them.
1583    let mut out = String::new();
1584    let mut chars = inner.chars();
1585    while let Some(c) = chars.next() {
1586        match c {
1587            '"' => return Some(out),
1588            '\\' => match chars.next()? {
1589                'n' => out.push('\n'),
1590                't' => out.push('\t'),
1591                other => out.push(other),
1592            },
1593            c => out.push(c),
1594        }
1595    }
1596    None
1597}
1598
1599fn json_array_strings(json: &str, field: &str) -> Vec<String> {
1600    let key = format!("\"{}\"", field);
1601    let start = match json.find(&key) {
1602        Some(s) => s + key.len(),
1603        None => return vec![],
1604    };
1605    let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1606    if !rest.starts_with('[') { return vec![]; }
1607    let arr_end = rest.find(']').unwrap_or(rest.len());
1608    let arr = &rest[1..arr_end];
1609    let mut result = Vec::new();
1610    let mut pos = 0;
1611    while let Some(q) = arr[pos..].find('"') {
1612        let abs = pos + q + 1;
1613        if let Some(end) = arr[abs..].find('"') {
1614            result.push(arr[abs..abs + end].to_string());
1615            pos = abs + end + 1;
1616        } else {
1617            break;
1618        }
1619    }
1620    result
1621}
1622
1623fn parse_challenges(json: &str) -> Vec<Challenge> {
1624    json_objects(json, "challenges")
1625        .into_iter()
1626        .filter_map(|obj| {
1627            Some(Challenge {
1628                challenge_type: json_str(obj, "type")?,
1629                url: json_str(obj, "url")?,
1630                token: json_str(obj, "token").unwrap_or_default(),
1631                issuer_domain_names: json_array_strings(obj, "issuer-domain-names"),
1632            })
1633        })
1634        .collect()
1635}
1636
1637/// The objects in the array `field` (the first one in `json`), as slices.
1638#[doc(hidden)]
1639pub fn json_objects<'a>(json: &'a str, field: &str) -> Vec<&'a str> {
1640    let marker = format!("\"{field}\"");
1641    let start = match json.find(&marker) {
1642        Some(s) => s + marker.len(),
1643        None => return vec![],
1644    };
1645    let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1646    if !rest.starts_with('[') { return vec![]; }
1647
1648    let mut result = Vec::new();
1649    let mut pos = 1usize; // skip '['
1650    while pos < rest.len() {
1651        // The next object, unless the array ends first.
1652        let ahead = rest[pos..].trim_start_matches([' ', '\n', '\r', '\t', ',']);
1653        if !ahead.starts_with('{') { break; }
1654        let abs = rest.len() - ahead.len();
1655        // Find the matching closing brace.
1656        let mut depth = 0usize;
1657        let mut obj_end = abs;
1658        for (i, ch) in rest[abs..].char_indices() {
1659            match ch {
1660                '{' => depth += 1,
1661                '}' => {
1662                    depth -= 1;
1663                    if depth == 0 { obj_end = abs + i + 1; break; }
1664                }
1665                _ => {}
1666            }
1667        }
1668        if obj_end <= abs { break; }
1669        result.push(&rest[abs..obj_end]);
1670        pos = obj_end;
1671    }
1672    result
1673}