Skip to main content

featherbit/acme/
mod.rs

1//! Automatic certificates via ACME (RFC 8555) with the TLS-ALPN-01 challenge
2//! (RFC 8737).
3//!
4//! This module *produces* certificates; `server::tls` only consumes them. The
5//! contract between the two is [`ManagedCerts`] — an atomically swappable map
6//! from [`CertId`] to the current [`ManagedCert`] — plus the
7//! `challenge::ChallengeSolver` the TLS resolver asks when a ClientHello
8//! carries ALPN `acme-tls/1`. Renewals swap a map entry; they never rebuild
9//! the rustls `ServerConfig`.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use arc_swap::ArcSwap;
15use rustls::pki_types::pem::PemObject;
16use rustls::pki_types::{CertificateDer, PrivateKeyDer};
17use rustls::sign::CertifiedKey;
18use serde::{Deserialize, Serialize};
19use tracing::{info, warn};
20
21use crate::config::{AcmeConfig, AcmeStorageConfig, TlsConfig};
22use crate::metrics::GatewayMetrics;
23use crate::plugins::resources::PluginResources;
24use challenge::TlsAlpnSolver;
25use manager::{ManagedSlot, Manager, ManagerConfig};
26use storage::CertStorage;
27
28pub mod challenge;
29pub mod client;
30#[cfg(test)]
31mod live_tests;
32pub mod manager;
33pub mod metrics;
34pub mod order;
35pub mod storage;
36
37#[derive(Debug, thiserror::Error)]
38pub enum AcmeError {
39    #[error("acme config: {0}")]
40    Config(String),
41    #[error("acme storage: {0}")]
42    Storage(String),
43    #[error("acme protocol: {0}")]
44    Protocol(String),
45    #[error("acme certificate: {0}")]
46    Certificate(String),
47    #[error("acme crypto: {0}")]
48    Crypto(String),
49}
50
51/// Stable identity of one managed certificate: its normalized domain set.
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct CertId(String);
54
55impl CertId {
56    /// Normalizes (lowercase, sorted, deduplicated) and validates the domains;
57    /// returns the id and the normalized list.
58    pub fn from_domains(domains: &[String]) -> Result<(CertId, Vec<String>), AcmeError> {
59        let mut norm = domains
60            .iter()
61            .map(|d| crate::config::normalize_domain(d).map_err(AcmeError::Config))
62            .collect::<Result<Vec<_>, _>>()?;
63        norm.sort();
64        norm.dedup();
65        if norm.is_empty() {
66            return Err(AcmeError::Config(
67                "a certificate needs at least one domain".into(),
68            ));
69        }
70        Ok((CertId(norm.join(",")), norm))
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78impl std::fmt::Display for CertId {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(&self.0)
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum CertState {
87    /// Self-signed stand-in served until the first issuance succeeds.
88    Placeholder,
89    Issued,
90    Renewing,
91    /// Last attempt failed; the previous cert (or placeholder) keeps serving.
92    Failed,
93}
94
95impl CertState {
96    pub const ALL: [CertState; 4] = [
97        CertState::Placeholder,
98        CertState::Issued,
99        CertState::Renewing,
100        CertState::Failed,
101    ];
102
103    pub fn as_str(&self) -> &'static str {
104        match self {
105            CertState::Placeholder => "placeholder",
106            CertState::Issued => "issued",
107            CertState::Renewing => "renewing",
108            CertState::Failed => "failed",
109        }
110    }
111}
112
113/// Operator-facing facts about a managed certificate (never the key).
114#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
115pub struct CertMeta {
116    pub not_before: i64,
117    pub not_after: i64,
118    pub issuer: String,
119    pub serial: String,
120    #[serde(default)]
121    pub next_renewal_at: Option<i64>,
122    #[serde(default)]
123    pub last_attempt_at: Option<i64>,
124    #[serde(default)]
125    pub last_error: Option<String>,
126}
127
128/// The current certificate for one [`CertId`], as served by the TLS resolver.
129#[derive(Debug, Clone)]
130pub struct ManagedCert {
131    pub key: Arc<CertifiedKey>,
132    pub leaf_der: Vec<u8>,
133    pub state: CertState,
134    pub meta: CertMeta,
135    pub domains: Vec<String>,
136}
137
138/// Keyed by `CertId::as_str()`.
139pub type ManagedCerts = Arc<ArcSwap<HashMap<String, ManagedCert>>>;
140
141pub fn new_managed_certs() -> ManagedCerts {
142    Arc::new(ArcSwap::from_pointee(HashMap::new()))
143}
144
145/// Replaces (or inserts) the entry for `id`. New TLS connections see it on
146/// their next `resolve`; in-flight ones are unaffected.
147pub fn publish(certs: &ManagedCerts, id: &CertId, cert: ManagedCert) {
148    let mut map: HashMap<String, ManagedCert> = (**certs.load()).clone();
149    map.insert(id.as_str().to_string(), cert);
150    certs.store(Arc::new(map));
151}
152
153/// Mutates the entry for `id` in place (no-op when absent).
154pub fn update(certs: &ManagedCerts, id: &CertId, f: impl FnOnce(&mut ManagedCert)) {
155    let mut map: HashMap<String, ManagedCert> = (**certs.load()).clone();
156    if let Some(c) = map.get_mut(id.as_str()) {
157        f(c);
158        certs.store(Arc::new(map));
159    }
160}
161
162/// What storage persists per certificate.
163///
164/// Deliberately **not** `Serialize`: each backend writes its own shape (the
165/// filesystem one splits the parts across files, the redis one seals the key
166/// into a `CertRecord`), and a blanket derive would make it one
167/// `serde_json::to_string` away from a private key landing in a log line or an
168/// API response. The hand-written `Debug` redacts `key_pem` for the same
169/// reason — it exists only so tests can `assert_eq!` on round-trips.
170#[derive(Clone, PartialEq, Deserialize)]
171pub struct StoredCert {
172    pub chain_pem: String,
173    pub key_pem: String,
174    pub issued_at: i64,
175}
176
177impl std::fmt::Debug for StoredCert {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("StoredCert")
180            .field("chain_pem", &self.chain_pem)
181            .field("key_pem", &"<redacted>")
182            .field("issued_at", &self.issued_at)
183            .finish()
184    }
185}
186
187pub fn now_unix() -> i64 {
188    std::time::SystemTime::now()
189        .duration_since(std::time::UNIX_EPOCH)
190        .map(|d| d.as_secs() as i64)
191        .unwrap_or(0)
192}
193
194fn provider() -> rustls::crypto::CryptoProvider {
195    rustls::crypto::ring::default_provider()
196}
197
198/// A self-signed, 1-hour placeholder (SAN = all domains, CN = the first) served
199/// while no real certificate exists yet. Never persisted.
200pub fn placeholder_cert(domains: &[String]) -> Result<(Arc<CertifiedKey>, Vec<u8>), AcmeError> {
201    let first = domains
202        .first()
203        .ok_or_else(|| AcmeError::Config("placeholder needs a domain".into()))?;
204    let mut params = rcgen::CertificateParams::new(domains.to_vec())
205        .map_err(|e| AcmeError::Crypto(e.to_string()))?;
206    params
207        .distinguished_name
208        .push(rcgen::DnType::CommonName, first.as_str());
209    let now = time::OffsetDateTime::now_utc();
210    params.not_before = now - time::Duration::minutes(5);
211    params.not_after = now + time::Duration::hours(1);
212    let key = rcgen::KeyPair::generate().map_err(|e| AcmeError::Crypto(e.to_string()))?;
213    let cert = params
214        .self_signed(&key)
215        .map_err(|e| AcmeError::Crypto(e.to_string()))?;
216    load_certified_key(&cert.pem(), &key.serialize_pem())
217}
218
219/// Parses a PEM chain + PEM key into a rustls [`CertifiedKey`], verifying the
220/// key matches the leaf. Returns the leaf DER alongside for metadata parsing.
221pub fn load_certified_key(
222    chain_pem: &str,
223    key_pem: &str,
224) -> Result<(Arc<CertifiedKey>, Vec<u8>), AcmeError> {
225    let chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(chain_pem.as_bytes())
226        .collect::<Result<Vec<_>, _>>()
227        .map_err(|e| AcmeError::Certificate(format!("chain PEM: {e}")))?;
228    if chain.is_empty() {
229        return Err(AcmeError::Certificate(
230            "chain PEM contains no certificates".into(),
231        ));
232    }
233    let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
234        .map_err(|e| AcmeError::Certificate(format!("key PEM: {e}")))?;
235    let leaf = chain[0].as_ref().to_vec();
236    let ck = CertifiedKey::from_der(chain, key, &provider())
237        .map_err(|e| AcmeError::Certificate(format!("certificate/key: {e}")))?;
238    ck.keys_match().map_err(|e| {
239        AcmeError::Certificate(format!("private key does not match certificate: {e}"))
240    })?;
241    Ok((Arc::new(ck), leaf))
242}
243
244fn parse_leaf(leaf_der: &[u8]) -> Result<x509_parser::prelude::X509Certificate<'_>, AcmeError> {
245    use x509_parser::prelude::*;
246    X509Certificate::from_der(leaf_der)
247        .map(|(_, c)| c)
248        .map_err(|e| AcmeError::Certificate(format!("leaf DER: {e}")))
249}
250
251pub fn parse_cert_meta(leaf_der: &[u8]) -> Result<CertMeta, AcmeError> {
252    let cert = parse_leaf(leaf_der)?;
253    Ok(CertMeta {
254        not_before: cert.validity().not_before.timestamp(),
255        not_after: cert.validity().not_after.timestamp(),
256        issuer: cert.issuer().to_string(),
257        serial: cert.raw_serial_as_string(),
258        next_renewal_at: None,
259        last_attempt_at: None,
260        last_error: None,
261    })
262}
263
264/// DNS SANs of the leaf, lowercased.
265pub fn leaf_dns_sans(leaf_der: &[u8]) -> Result<Vec<String>, AcmeError> {
266    use x509_parser::prelude::*;
267    let cert = parse_leaf(leaf_der)?;
268    let mut out = Vec::new();
269    if let Ok(Some(san)) = cert.subject_alternative_name() {
270        for name in &san.value.general_names {
271            if let GeneralName::DNSName(d) = name {
272                out.push(d.to_ascii_lowercase());
273            }
274        }
275    }
276    Ok(out)
277}
278
279/// The leaf's SubjectPublicKeyInfo DER. Production code never needs it —
280/// `load_certified_key` gates key/leaf agreement through rustls'
281/// `CertifiedKey::keys_match()` — but the TLS resolver tests verify signatures
282/// over a challenge certificate whose critical `acmeIdentifier` extension
283/// rustls-webpki refuses to parse, and raw-SPKI verification is the way around
284/// that.
285#[cfg(test)]
286pub fn leaf_spki(leaf_der: &[u8]) -> Result<Vec<u8>, AcmeError> {
287    let cert = parse_leaf(leaf_der)?;
288    Ok(cert.tbs_certificate.subject_pki.raw.to_vec())
289}
290
291/// Everything the rest of the process needs from ACME once started.
292pub struct AcmeRuntime {
293    pub certs: ManagedCerts,
294    pub solver: Arc<TlsAlpnSolver>,
295    pub manager: Arc<Manager>,
296    pub storage_label: String,
297}
298
299impl AcmeRuntime {
300    pub fn hooks(&self) -> crate::server::tls::AcmeHooks {
301        crate::server::tls::AcmeHooks {
302            certs: self.certs.clone(),
303            solver: self.solver.clone(),
304        }
305    }
306
307    /// Ids of managed certs still serving a placeholder (drives `/readyz`).
308    pub fn placeholder_ids(&self) -> Vec<String> {
309        let mut v: Vec<String> = self
310            .certs
311            .load()
312            .iter()
313            .filter(|(_, c)| c.state == CertState::Placeholder)
314            .map(|(id, _)| id.clone())
315            .collect();
316        v.sort();
317        v
318    }
319}
320
321/// Builds the configured [`CertStorage`]. The redis backend keeps only the
322/// store's *name* and resolves its client from `resources` on every call, so an
323/// Admin API store edit is picked up by the next operation; the name is
324/// validated here (resolved once and discarded) so a typo fails startup rather
325/// than every later renewal.
326pub fn build_storage(
327    cfg: &AcmeConfig,
328    resources: &Arc<PluginResources>,
329) -> Result<Arc<dyn CertStorage>, AcmeError> {
330    match &cfg.storage {
331        AcmeStorageConfig::Filesystem { dir } => Ok(Arc::new(storage::fs::FsCertStorage::new(dir))),
332        #[cfg(feature = "redis-store")]
333        AcmeStorageConfig::Store {
334            store,
335            encryption_key,
336        } => {
337            let storage =
338                storage::redis::RedisCertStorage::new(resources.clone(), store, encryption_key);
339            storage
340                .client()
341                .map_err(|e| AcmeError::Config(e.to_string()))?;
342            Ok(Arc::new(storage))
343        }
344        #[cfg(not(feature = "redis-store"))]
345        AcmeStorageConfig::Store { .. } => {
346            let _ = resources;
347            Err(AcmeError::Config(
348                "acme.storage.type: store needs the redis-store feature".into(),
349            ))
350        }
351    }
352}
353
354/// Builds storage, seeds [`ManagedCerts`] from storage (or placeholders), and
355/// spawns the renewal manager. The CA is **not** contacted here — the manager
356/// connects lazily — so a CA outage never blocks the listener from starting.
357pub async fn start(
358    cfg: &AcmeConfig,
359    tls: &TlsConfig,
360    resources: &Arc<PluginResources>,
361    metrics: &GatewayMetrics,
362) -> Result<Arc<AcmeRuntime>, AcmeError> {
363    let storage = build_storage(cfg, resources)?;
364    let solver = TlsAlpnSolver::new(storage.clone());
365    let certs = new_managed_certs();
366    let acme_metrics = metrics::AcmeMetrics::register(&metrics.registry)
367        .map_err(|e| AcmeError::Config(format!("acme metrics: {e}")))?;
368    let now = now_unix();
369
370    let mut slots = Vec::new();
371    for domains in tls.managed_domains() {
372        let (id, domains) = CertId::from_domains(&domains)?;
373        if slots.iter().any(|s: &ManagedSlot| s.id == id) {
374            continue; // two slots with the same domain set share one certificate
375        }
376        let adopted = match storage.load_cert(&id).await {
377            Ok(Some(stored)) => {
378                match load_certified_key(&stored.chain_pem, &stored.key_pem)
379                    .and_then(|(key, leaf)| parse_cert_meta(&leaf).map(|meta| (key, leaf, meta)))
380                {
381                    Ok((key, leaf, meta)) if meta.not_after > now => {
382                        info!(
383                            "acme: loaded stored certificate for {} (expires {})",
384                            id, meta.not_after
385                        );
386                        Some(ManagedCert {
387                            key,
388                            leaf_der: leaf,
389                            state: CertState::Issued,
390                            meta,
391                            domains: domains.clone(),
392                        })
393                    }
394                    Ok(_) => {
395                        warn!(
396                            "acme: stored certificate for {} is expired; serving a placeholder",
397                            id
398                        );
399                        None
400                    }
401                    Err(e) => {
402                        warn!("acme: stored certificate for {} is unusable ({}); serving a placeholder", id, e);
403                        None
404                    }
405                }
406            }
407            Ok(None) => None,
408            Err(e) => {
409                warn!(
410                    "acme: reading stored certificate for {} failed ({}); serving a placeholder",
411                    id, e
412                );
413                None
414            }
415        };
416        let cert = match adopted {
417            Some(c) => c,
418            None => {
419                let (key, leaf) = placeholder_cert(&domains)?;
420                warn!("acme: {} has no certificate yet — serving a self-signed placeholder until issuance succeeds", id);
421                // Real metadata even for a placeholder: its (one-hour)
422                // `not_after` is what the manager uses to decide when to
423                // re-mint it. The metrics gauge still reports 0 for
424                // placeholders — see `seeded_not_after` below and
425                // `Manager::observe`.
426                let meta = parse_cert_meta(&leaf).unwrap_or_default();
427                ManagedCert {
428                    key,
429                    leaf_der: leaf,
430                    state: CertState::Placeholder,
431                    meta,
432                    domains: domains.clone(),
433                }
434            }
435        };
436        // Observed synchronously here (rather than left to the manager's
437        // spawned per-slot task) so the seeded state is visible to metrics
438        // scrapes and `/readyz` the instant `start()` returns, with no
439        // dependency on when that task first gets scheduled.
440        let seeded_not_after = if cert.state == CertState::Placeholder {
441            0
442        } else {
443            cert.meta.not_after
444        };
445        acme_metrics.observe(id.as_str(), cert.state, seeded_not_after);
446        publish(&certs, &id, cert);
447        slots.push(ManagedSlot { id, domains });
448    }
449
450    let manager_cfg = ManagerConfig {
451        key_type: order::KeyType::parse(&cfg.key_type)?,
452        renew_before: cfg.renew_before_duration().map_err(AcmeError::Config)?,
453        ..ManagerConfig::default()
454    };
455    let factory = Arc::new(client::InstantAcmeFactory::new(
456        cfg.clone(),
457        storage.clone(),
458    ));
459    let manager = Manager::new(
460        manager_cfg,
461        factory,
462        storage.clone(),
463        solver.clone(),
464        certs.clone(),
465        slots,
466        Some(acme_metrics),
467    );
468    tokio::spawn(manager.clone().run());
469
470    Ok(Arc::new(AcmeRuntime {
471        certs,
472        solver,
473        manager,
474        storage_label: storage.label(),
475    }))
476}
477
478/// Runtimes for admin/readiness unit tests: a manager that never runs, over a
479/// mock CA and a temp filesystem store.
480#[cfg(test)]
481pub(crate) mod testing {
482    use super::*;
483    use client::mock::{MockAcmeClient, MockBehavior, MockFactory};
484
485    pub fn runtime_with(certs: ManagedCerts, slots: Vec<ManagedSlot>) -> Arc<AcmeRuntime> {
486        let dir = std::env::temp_dir().join(format!(
487            "fb_acme_rt_{}_{}",
488            std::process::id(),
489            uuid::Uuid::new_v4().simple()
490        ));
491        let storage: Arc<dyn CertStorage> = Arc::new(storage::fs::FsCertStorage::new(dir));
492        let solver = TlsAlpnSolver::new(storage.clone());
493        let factory = MockFactory::new(MockAcmeClient::new(MockBehavior::default()));
494        let manager = Manager::new(
495            ManagerConfig::default(),
496            factory,
497            storage,
498            solver.clone(),
499            certs.clone(),
500            slots,
501            None,
502        );
503        Arc::new(AcmeRuntime {
504            certs,
505            solver,
506            manager,
507            storage_label: "filesystem".into(),
508        })
509    }
510
511    fn seeded(domains: &[&str], state: CertState) -> Arc<AcmeRuntime> {
512        let domains: Vec<String> = domains.iter().map(|d| d.to_string()).collect();
513        let (id, domains) = CertId::from_domains(&domains).unwrap();
514        let certs = new_managed_certs();
515        let (key, leaf) = if state == CertState::Placeholder {
516            placeholder_cert(&domains).unwrap()
517        } else {
518            let c = rcgen::generate_simple_self_signed(domains.clone()).unwrap();
519            load_certified_key(&c.cert.pem(), &c.signing_key.serialize_pem()).unwrap()
520        };
521        let meta = parse_cert_meta(&leaf).unwrap();
522        publish(
523            &certs,
524            &id,
525            ManagedCert {
526                key,
527                leaf_der: leaf,
528                state,
529                meta,
530                domains: domains.clone(),
531            },
532        );
533        runtime_with(certs, vec![ManagedSlot { id, domains }])
534    }
535
536    pub fn placeholder_runtime(domains: &[&str]) -> Arc<AcmeRuntime> {
537        seeded(domains, CertState::Placeholder)
538    }
539
540    pub fn issued_runtime(domains: &[&str]) -> Arc<AcmeRuntime> {
541        seeded(domains, CertState::Issued)
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    fn s(v: &[&str]) -> Vec<String> {
550        v.iter().map(|x| x.to_string()).collect()
551    }
552
553    #[test]
554    fn cert_id_normalizes_sorts_and_dedups() {
555        let (id, domains) =
556            CertId::from_domains(&s(&["B.example.com", "a.example.com", "a.EXAMPLE.com"])).unwrap();
557        assert_eq!(id.as_str(), "a.example.com,b.example.com");
558        assert_eq!(domains, s(&["a.example.com", "b.example.com"]));
559        assert!(CertId::from_domains(&[]).is_err());
560        assert!(CertId::from_domains(&s(&["*.example.com"])).is_err());
561    }
562
563    #[test]
564    fn placeholder_is_self_signed_for_domains_and_short_lived() {
565        let (key, leaf) = placeholder_cert(&s(&["api.example.com", "www.example.com"])).unwrap();
566        assert!(key.end_entity_cert().is_ok());
567        let sans = leaf_dns_sans(&leaf).unwrap();
568        assert!(sans.contains(&"api.example.com".to_string()));
569        let meta = parse_cert_meta(&leaf).unwrap();
570        let now = now_unix();
571        assert!(
572            meta.not_after > now && meta.not_after <= now + 3_700,
573            "{:?}",
574            meta
575        );
576        assert!(meta.not_before <= now);
577    }
578
579    #[test]
580    fn load_certified_key_round_trips_and_rejects_foreign_key() {
581        let a = rcgen::generate_simple_self_signed(s(&["a.example.com"])).unwrap();
582        let b = rcgen::generate_simple_self_signed(s(&["b.example.com"])).unwrap();
583        let (ck, leaf) = load_certified_key(&a.cert.pem(), &a.signing_key.serialize_pem()).unwrap();
584        assert_eq!(ck.end_entity_cert().unwrap().as_ref(), leaf.as_slice());
585        assert!(!leaf_spki(&leaf).unwrap().is_empty());
586        let err = load_certified_key(&a.cert.pem(), &b.signing_key.serialize_pem()).unwrap_err();
587        assert!(matches!(err, AcmeError::Certificate(_)), "{err}");
588        assert!(load_certified_key("not pem", &a.signing_key.serialize_pem()).is_err());
589    }
590
591    #[test]
592    fn parse_cert_meta_reads_validity_issuer_serial() {
593        let a = rcgen::generate_simple_self_signed(s(&["a.example.com"])).unwrap();
594        let meta = parse_cert_meta(a.cert.der()).unwrap();
595        assert!(meta.not_after > meta.not_before);
596        assert!(!meta.serial.is_empty());
597        assert!(meta.issuer.contains("rcgen"), "{}", meta.issuer);
598    }
599
600    #[tokio::test]
601    async fn start_seeds_placeholders_then_reuses_a_stored_cert() {
602        let dir = std::env::temp_dir().join(format!("fb_acme_start_{}", std::process::id()));
603        let _ = std::fs::remove_dir_all(&dir);
604        // directory_url pins to a closed local port: start() spawns the manager,
605        // whose placeholder slot is immediately due, so without this the test
606        // would have the InstantAcmeFactory dial the real Let's Encrypt
607        // production directory in the background. Both start() calls below
608        // share this cfg, so one pin covers both.
609        let system: crate::config::SystemConfig = serde_yaml::from_str(&format!(
610            "acme:\n  terms_of_service_agreed: true\n  directory_url: https://127.0.0.1:1/directory\n  storage:\n    type: filesystem\n    dir: {}\ntls:\n  acme:\n    domains: [s.example.com]\n",
611            dir.display().to_string().replace('\\', "/")
612        ))
613        .unwrap();
614        let cfg = system.acme.as_ref().unwrap();
615        let tls = system.tls.as_ref().unwrap();
616        let resources = crate::plugins::resources::PluginResources::new(None);
617        let metrics = crate::metrics::GatewayMetrics::new();
618
619        let rt = start(cfg, tls, &resources, &metrics).await.unwrap();
620        assert_eq!(rt.placeholder_ids(), vec!["s.example.com".to_string()]);
621        assert_eq!(rt.storage_label, "filesystem");
622        assert!(metrics.render().contains(
623            "featherbit_acme_cert_state{cert_id=\"s.example.com\",state=\"placeholder\"} 1"
624        ));
625        // The placeholder carries real validity (so the manager knows when to
626        // re-mint it) but the expiry gauge still reads 0 for placeholders.
627        let seeded = rt.certs.load().get("s.example.com").unwrap().clone();
628        assert!(seeded.meta.not_after > now_unix(), "{:?}", seeded.meta);
629        assert!(metrics.render().contains(
630            "featherbit_acme_cert_not_after_timestamp_seconds{cert_id=\"s.example.com\"} 0"
631        ));
632
633        // A valid stored cert is adopted at start (no placeholder, no order).
634        let issued = rcgen::generate_simple_self_signed(vec!["s.example.com".to_string()]).unwrap();
635        let storage = build_storage(cfg, &resources).unwrap();
636        let (id, _) = CertId::from_domains(&["s.example.com".into()]).unwrap();
637        storage
638            .save_cert(
639                &id,
640                &StoredCert {
641                    chain_pem: issued.cert.pem(),
642                    key_pem: issued.signing_key.serialize_pem(),
643                    issued_at: now_unix(),
644                },
645            )
646            .await
647            .unwrap();
648        let rt2 = start(cfg, tls, &resources, &crate::metrics::GatewayMetrics::new())
649            .await
650            .unwrap();
651        assert!(rt2.placeholder_ids().is_empty());
652        assert_eq!(
653            rt2.certs.load().get("s.example.com").unwrap().state,
654            CertState::Issued
655        );
656        let _ = std::fs::remove_dir_all(dir);
657    }
658
659    #[test]
660    fn stored_cert_debug_redacts_the_private_key() {
661        let cert = StoredCert {
662            chain_pem: "-----BEGIN CERTIFICATE-----".into(),
663            key_pem: "-----BEGIN PRIVATE KEY-----
664SECRET"
665                .into(),
666            issued_at: 7,
667        };
668        let rendered = format!("{cert:?}");
669        assert!(!rendered.contains("SECRET"), "{rendered}");
670        assert!(rendered.contains("<redacted>"), "{rendered}");
671        assert!(rendered.contains("BEGIN CERTIFICATE"), "{rendered}");
672    }
673
674    #[test]
675    fn publish_and_update_swap_map_entries() {
676        let certs = new_managed_certs();
677        let (id, domains) = CertId::from_domains(&s(&["a.example.com"])).unwrap();
678        let (key, leaf) = placeholder_cert(&domains).unwrap();
679        let leaf_copy = leaf.clone();
680        publish(
681            &certs,
682            &id,
683            ManagedCert {
684                key,
685                leaf_der: leaf,
686                state: CertState::Placeholder,
687                meta: parse_cert_meta(&leaf_copy).unwrap(),
688                domains,
689            },
690        );
691        assert_eq!(
692            certs.load().get(id.as_str()).unwrap().state,
693            CertState::Placeholder
694        );
695        update(&certs, &id, |c| c.state = CertState::Failed);
696        assert_eq!(
697            certs.load().get(id.as_str()).unwrap().state,
698            CertState::Failed
699        );
700        assert_eq!(CertState::Failed.as_str(), "failed");
701    }
702}