Skip to main content

didbot_tls/
manager.rs

1//! Ties storage, the DNS-01 seam, the renewal clock and the swappable
2//! certificate together into the one thing a server needs: a
3//! [`rustls::ServerConfig`] that renews itself.
4//!
5//! # What this crate's tests verify, and what they still cannot
6//!
7//! `../tests/acme_conformance.rs` drives the full `instant_acme` exchange
8//! this module orders through -- `new_order`, both authorizations, `dns-01`
9//! challenges, `finalize`, `certificate` -- against an in-process fake ACME
10//! directory (`../tests/support::FakeAcmeServer`), through
11//! [`CertificateManager::bootstrap_with_account`]. That covers: the
12//! apex+wildcard grouping actually reaching a CA-shaped server rather than
13//! only the pure grouping function; a challenge that never validates; a
14//! renewal that fails
15//! repeatedly (still serving the old certificate, warning after three
16//! failures, critical inside three days of expiry); a crash between
17//! publishing a challenge and withdrawing it; and two zones ordering
18//! concurrently without sharing a challenge name.
19//!
20//! What that still does not cover: a real CA (rate limits, real validation,
21//! real certificate chains -- the fake signs the order's CSR key with a
22//! throwaway issuer nothing trusts), real DNS-01 propagation over the
23//! network, and [`ReqwestAcmeClient`] actually reaching a directory --
24//! [`CertificateManager::bootstrap`] itself is real-network-only and has
25//! no fake-directory path of its own, which is exactly why
26//! [`CertificateManager::bootstrap_with_account`] exists as a seam. Also exercised, independent of any of the above, purely
27//! as functions of a clock the tests supply:
28//!
29//! * [`crate::renew::RenewalTracker`] -- when a renewal is due and how loud
30//!   a failing one gets.
31//! * `crate::acme::group_challenges_by_name` -- the grouping that avoids
32//!   publishing the apex's DNS-01 value and then overwriting it with the
33//!   wildcard's, or vice versa.
34//! * [`crate::resolver::SwappableCert`] -- that a certificate swap is
35//!   invisible to a connection already past its handshake, and visible to
36//!   the very next one. See `tests/rotation.rs`.
37//! * [`crate::storage`] -- permissions and round-tripping on disk.
38
39use std::io;
40use std::sync::Arc;
41use std::time::Duration;
42
43use instant_acme::{Account, NewAccount};
44use rustls::sign::CertifiedKey;
45use time::OffsetDateTime;
46use tokio::sync::Mutex;
47
48use crate::acme::{self, IssueError};
49use crate::cert::{certified_key_from_pem, leaf_expiry, CertError};
50use crate::directory::Environment;
51use crate::dns::{AcmeDnsChallenge, IssuancePolicy};
52use crate::http_client::ReqwestAcmeClient;
53use crate::renew::{AlertLevel, RenewalPolicy, RenewalTracker};
54use crate::resolver::SwappableCert;
55use crate::storage::{CertMeta, CertStore, StorageError};
56
57/// How often the background loop checks whether renewal is due. Independent
58/// of [`RenewalPolicy::retry_interval`]: this is the poll granularity, that
59/// is the minimum gap between actual attempts.
60pub const CHECK_INTERVAL: Duration = Duration::from_secs(3600);
61
62/// Why the manager could not be built, or could not obtain a first
63/// certificate.
64#[derive(Debug, thiserror::Error)]
65pub enum BootstrapError {
66    /// The data directory could not be prepared, or a saved file did not
67    /// parse.
68    #[error("data directory: {0}")]
69    Storage(#[from] StorageError),
70    /// Creating or restoring the ACME account failed.
71    #[error("acme: {0}")]
72    Acme(#[from] instant_acme::Error),
73    /// No certificate was on disk and the first issuance failed.
74    #[error("issuing the first certificate: {0}")]
75    Issue(#[from] IssueError),
76    /// The certificate this crate just obtained or loaded from disk could
77    /// not be turned into something `rustls` can serve.
78    #[error("the certificate this crate just obtained or loaded: {0}")]
79    Cert(#[from] CertError),
80}
81
82/// The PDS's own certificate: issuance, renewal and the live rustls state,
83/// in one place.
84pub struct CertificateManager<D: AcmeDnsChallenge> {
85    store: CertStore,
86    dns: Arc<D>,
87    account: Account,
88    environment: Environment,
89    domains: Vec<String>,
90    policy: RenewalPolicy,
91    tracker: Mutex<RenewalTracker>,
92    cert: Arc<SwappableCert>,
93    provider: rustls::crypto::CryptoProvider,
94}
95
96/// The ACME account `directory_url` should be reached through: the one
97/// saved in `store` if it was registered there, and a freshly registered one
98/// otherwise, saved over it.
99///
100/// An account belongs to the directory that registered it. It can place
101/// orders there and nowhere else, and the `CAA` record naming it authorises
102/// issuance there and nowhere else — so a deployment that switches
103/// directories (staging to production, the switch that makes its
104/// certificates publicly trusted) needs an account at the new one before it
105/// can order anything. Credentials that record no directory at all are
106/// replaced too: one round trip to register an account is the cheaper half
107/// of that guess.
108///
109/// Taking the [`instant_acme::HttpClient`] as an argument is the same seam
110/// [`CertificateManager::bootstrap_with_account`] is:
111/// [`CertificateManager::bootstrap`] passes a [`ReqwestAcmeClient`] against
112/// a real Let's Encrypt directory, and a test passes a client answering as
113/// an in-process fake one.
114pub async fn load_or_register_account(
115    store: &CertStore,
116    directory_url: &str,
117    contact: &[&str],
118    http: Box<dyn instant_acme::HttpClient>,
119) -> Result<Account, BootstrapError> {
120    match store.account_credentials()? {
121        Some(stored) if stored.issued_by(directory_url) => {
122            tracing::info!(
123                directory = directory_url,
124                "restoring the saved acme account"
125            );
126            Ok(Account::builder_with_http(http)
127                .from_credentials(stored.credentials)
128                .await?)
129        }
130        stale => {
131            if let Some(stale) = &stale {
132                tracing::warn!(
133                    saved = stale
134                        .directory_url
135                        .as_deref()
136                        .unwrap_or("an unrecorded directory"),
137                    configured = directory_url,
138                    "the saved acme account belongs to another directory; registering a new \
139                     account at the configured one"
140                );
141            }
142            let (account, credentials) = Account::builder_with_http(http)
143                .create(
144                    &NewAccount {
145                        contact,
146                        terms_of_service_agreed: true,
147                        only_return_existing: false,
148                    },
149                    directory_url.to_owned(),
150                    None,
151                )
152                .await?;
153            store.save_account_credentials(&credentials)?;
154            Ok(account)
155        }
156    }
157}
158
159/// The certificate stored for `zone` if this run can serve it, with its
160/// expiry and metadata. `None` means order a new one.
161///
162/// A certificate is only as good as the directory that signed it: a staging
163/// leaf is served by exactly the same code as a production one and trusted by
164/// nobody, so the environment a run is configured for decides whether what is
165/// on disk can be served, not merely whether anything is there.
166///
167/// A set a crash left unusable is also `None`, so the next boot replaces it
168/// rather than refusing to start: a file missing, a `meta.json` that does not
169/// parse, or a certificate and key that do not load as a matching pair.
170/// `CertStore::save_certificate` renames its three files one at a time, so a
171/// crash between two renames leaves exactly such a set. Any other read error
172/// is still returned, since ordering again would not fix it.
173fn stored_certificate(
174    store: &CertStore,
175    zone: &str,
176    environment: Environment,
177    provider: &rustls::crypto::CryptoProvider,
178) -> Result<Option<(CertifiedKey, OffsetDateTime, CertMeta)>, BootstrapError> {
179    let (cert_pem, key_pem, meta) = match store.certificate() {
180        Ok(Some(stored)) => stored,
181        Ok(None) => {
182            tracing::info!(zone, "no certificate on disk yet; requesting the first one");
183            return Ok(None);
184        }
185        Err(StorageError::Io { path, source }) if source.kind() == io::ErrorKind::NotFound => {
186            tracing::warn!(
187                zone,
188                missing = %path.display(),
189                "the stored certificate is incomplete; ordering a new one"
190            );
191            return Ok(None);
192        }
193        Err(err @ StorageError::Parse { .. }) => {
194            tracing::warn!(zone, %err, "the stored certificate metadata does not parse; ordering a new one");
195            return Ok(None);
196        }
197        Err(err) => return Err(err.into()),
198    };
199    if meta.directory_url != environment.directory_url() {
200        tracing::info!(
201            zone,
202            stored = %meta.directory_url,
203            configured = environment.directory_url(),
204            "the certificate on disk was issued by another directory; ordering one from the \
205             configured directory"
206        );
207        return Ok(None);
208    }
209    let loaded = certified_key_from_pem(&cert_pem, &key_pem, provider)
210        .and_then(|certified| Ok((leaf_expiry(&certified.cert)?, certified)));
211    match loaded {
212        Ok((expiry, certified)) => Ok(Some((certified, expiry, meta))),
213        Err(err) => {
214            tracing::warn!(zone, %err, "the stored certificate does not load; ordering a new one");
215            Ok(None)
216        }
217    }
218}
219
220impl<D: AcmeDnsChallenge + 'static> CertificateManager<D> {
221    /// Loads or obtains a certificate for `zone` and `*.zone`, from
222    /// `data_dir/tls/zones/<zone>`, and returns a manager ready to serve and
223    /// renew it.
224    ///
225    /// One manager covers exactly one zone. A deployment configured with
226    /// several needs one of these each — [`crate::fleet::CertificateFleet`]
227    /// is what builds and holds them.
228    ///
229    /// Installs `ring` as the process's default `rustls` crypto provider if
230    /// none is installed yet; if one is already installed (for instance by
231    /// another part of the process that also uses rustls) this is a no-op
232    /// rather than an error -- a second install attempt is expected, not
233    /// exceptional.
234    pub async fn bootstrap(
235        data_dir: &std::path::Path,
236        zone: &str,
237        environment: Environment,
238        contact: &[&str],
239        policy: RenewalPolicy,
240        dns: Arc<D>,
241    ) -> Result<Arc<Self>, BootstrapError> {
242        tracing::info!("{}", environment.banner());
243
244        // Per zone, not per deployment: `<data_dir>/tls/zones/<zone>` holds
245        // this zone's certificate, `<data_dir>/tls` the one ACME account
246        // every zone's orders are placed through. See `storage`'s own docs,
247        // and `crate::fleet` for why one certificate can never cover two
248        // zones.
249        let store = CertStore::open_zone(data_dir, zone)?;
250
251        let account = load_or_register_account(
252            &store,
253            environment.directory_url(),
254            contact,
255            Box::new(ReqwestAcmeClient::new()),
256        )
257        .await?;
258
259        Self::finish_bootstrap(store, zone, environment, contact, policy, dns, account).await
260    }
261
262    /// Assembles a manager from an [`Account`] built elsewhere, skipping
263    /// [`CertificateManager::bootstrap`]'s own credential-loading and its hardcoded
264    /// [`ReqwestAcmeClient`] against `environment.directory_url()`.
265    ///
266    /// This is the seam a test drives against an in-process fake ACME
267    /// directory: [`CertificateManager::bootstrap`] has no way to reach anything but a
268    /// real Let's Encrypt endpoint, so nothing downstream of it could run
269    /// without one. Production code that already holds an [`Account`] -- for
270    /// instance one restored through a different credential path than
271    /// [`crate::storage::CertStore`] -- can use this directly too.
272    pub async fn bootstrap_with_account(
273        data_dir: &std::path::Path,
274        zone: &str,
275        environment: Environment,
276        contact: &[&str],
277        policy: RenewalPolicy,
278        dns: Arc<D>,
279        account: Account,
280    ) -> Result<Arc<Self>, BootstrapError> {
281        let store = CertStore::open_zone(data_dir, zone)?;
282        Self::finish_bootstrap(store, zone, environment, contact, policy, dns, account).await
283    }
284
285    /// Everything both bootstrap paths share once an [`Account`] is in hand:
286    /// publish the zone's `CAA` record, load or issue the certificate, and
287    /// assemble the manager.
288    async fn finish_bootstrap(
289        store: CertStore,
290        zone: &str,
291        environment: Environment,
292        contact: &[&str],
293        policy: RenewalPolicy,
294        dns: Arc<D>,
295        account: Account,
296    ) -> Result<Arc<Self>, BootstrapError> {
297        let _ = rustls::crypto::ring::default_provider().install_default();
298        let provider = rustls::crypto::ring::default_provider();
299        let domains = acme::zone_and_wildcard(zone);
300
301        // Before any order, and on every start rather than only the first:
302        // the record has to name the account this process actually holds, and
303        // that is only knowable here, once the account is loaded or created.
304        // A backend that already says this skips the write. This is also
305        // what reconciles a directory switch: the run that registers a new
306        // account replaces the `accounturi` naming the old one here, before
307        // the order that record has to authorise is placed.
308        let issuance = IssuancePolicy {
309            issuer: environment.issuer_domain().to_owned(),
310            account_uri: account.id().to_owned(),
311            report_to: contact
312                .iter()
313                .filter(|c| c.starts_with("mailto:") || c.starts_with("https://"))
314                .map(|c| (*c).to_owned())
315                .collect(),
316        };
317        if let Err(error) = dns.authorize_issuer(zone, &issuance) {
318            // Not fatal. A zone with no CAA record is the state every zone
319            // starts in, and refusing to serve because the record could not be
320            // written would turn a narrowing into a dependency. It is loud
321            // because nothing else will notice, and because what the zone
322            // already says is now what applies: no record leaves issuance
323            // open to every authority, and a record naming another account
324            // refuses the orders this run is about to place.
325            tracing::warn!(
326                zone,
327                %error,
328                account = account.id(),
329                "could not publish this zone's CAA record; the record the zone already carries is \
330                 the one that applies to every order this run places"
331            );
332        }
333
334        let (certified, expiry, meta) =
335            match stored_certificate(&store, zone, environment, &provider)? {
336                Some(stored) => stored,
337                None => {
338                    let issued = acme::issue(&account, &domains, &dns).await?;
339                    // Loaded before it is saved, so a pair that does not
340                    // load is never written where the next boot reads it.
341                    let certified =
342                        certified_key_from_pem(&issued.cert_chain_pem, &issued.key_pem, &provider)?;
343                    let expiry = leaf_expiry(&certified.cert)?;
344                    let meta = CertMeta {
345                        directory_url: environment.directory_url().to_owned(),
346                        issued_at: OffsetDateTime::now_utc(),
347                    };
348                    store.save_certificate(&issued.cert_chain_pem, &issued.key_pem, &meta)?;
349                    (certified, expiry, meta)
350                }
351            };
352
353        let cert = SwappableCert::new(certified);
354        let tracker = Mutex::new(RenewalTracker::new(expiry));
355
356        tracing::info!(
357            zone,
358            directory = %meta.directory_url,
359            expiry = %expiry,
360            "tls certificate ready"
361        );
362
363        Ok(Arc::new(Self {
364            store,
365            dns,
366            account,
367            environment,
368            domains,
369            policy,
370            tracker,
371            cert,
372            provider,
373        }))
374    }
375
376    /// The zone this manager holds a certificate for.
377    pub fn zone(&self) -> &str {
378        // `domains[0]` is the apex; `zone_and_wildcard` builds it that way.
379        &self.domains[0]
380    }
381
382    /// The live certificate resolver. Build the process's one
383    /// `rustls::ServerConfig` from this once, at startup; renewal swaps the
384    /// certificate this resolver hands back, not the config or the listener.
385    pub fn resolver(&self) -> Arc<SwappableCert> {
386        self.cert.clone()
387    }
388
389    /// How urgently the current renewal state should be surfaced, right now.
390    pub async fn alert_level(&self) -> AlertLevel {
391        self.tracker
392            .lock()
393            .await
394            .alert_level(OffsetDateTime::now_utc())
395    }
396
397    /// Runs forever, checking on [`CHECK_INTERVAL`] whether renewal is due
398    /// and attempting it when it is.
399    ///
400    /// A failed attempt never touches [`Self::resolver`]: the certificate
401    /// already being served keeps being served, and the failure is only
402    /// recorded in the tracker that [`Self::alert_level`] reads. Intended to
403    /// be spawned once and left running for the life of the process.
404    pub async fn run_renewal_loop(self: Arc<Self>) {
405        let mut interval = tokio::time::interval(CHECK_INTERVAL);
406        loop {
407            interval.tick().await;
408            self.maybe_renew().await;
409        }
410    }
411
412    /// Checks whether renewal is due and, if so, attempts it once. Split out
413    /// from [`Self::run_renewal_loop`] so a test can call it directly on a
414    /// clock it controls, without waiting on [`CHECK_INTERVAL`].
415    pub async fn maybe_renew(&self) {
416        let now = OffsetDateTime::now_utc();
417        let due = {
418            let tracker = self.tracker.lock().await;
419            tracker.due(now, &self.policy)
420        };
421        if !due {
422            return;
423        }
424
425        tracing::info!(domains = ?self.domains, "renewal due; requesting a new certificate");
426        match acme::issue(&self.account, &self.domains, &self.dns).await {
427            Ok(issued) => self.apply_renewal(issued, now).await,
428            Err(err) => self.record_failure(err, now).await,
429        }
430    }
431
432    async fn apply_renewal(&self, issued: acme::IssuedCertificate, now: OffsetDateTime) {
433        let certified = match certified_key_from_pem(
434            &issued.cert_chain_pem,
435            &issued.key_pem,
436            &self.provider,
437        ) {
438            Ok(certified) => certified,
439            Err(err) => {
440                tracing::error!(%err, "renewed certificate could not be loaded; still serving the old one");
441                self.tracker.lock().await.record_failure(now);
442                return;
443            }
444        };
445        let expiry = match leaf_expiry(&certified.cert) {
446            Ok(expiry) => expiry,
447            Err(err) => {
448                tracing::error!(%err, "could not read the renewed certificate's expiry; still serving the old one");
449                self.tracker.lock().await.record_failure(now);
450                return;
451            }
452        };
453
454        let meta = CertMeta {
455            directory_url: self.environment.directory_url().to_owned(),
456            issued_at: now,
457        };
458        if let Err(err) =
459            self.store
460                .save_certificate(&issued.cert_chain_pem, &issued.key_pem, &meta)
461        {
462            // The new certificate is good but unsaved: still swap it in, so
463            // this process keeps serving a valid certificate, but say loudly
464            // that a restart would lose it and re-issue.
465            tracing::error!(%err, "renewed certificate could not be written to disk; serving it anyway");
466        }
467
468        // One atomic swap; see `resolver.rs` for why this and not two
469        // separate fields.
470        self.cert.swap(certified);
471        self.tracker.lock().await.record_success(now, expiry);
472        tracing::info!(%expiry, "renewed the tls certificate");
473    }
474
475    async fn record_failure(&self, err: IssueError, now: OffsetDateTime) {
476        let mut tracker = self.tracker.lock().await;
477        tracker.record_failure(now);
478        let level = tracker.alert_level(now);
479        let failures = tracker.consecutive_failures();
480        let expiry = tracker.current_expiry();
481        drop(tracker);
482        // `zone` on every arm: `docs/deployment.md` promises a failure "is
483        // logged with the zone, the failure count and the expiry", and a
484        // multi-zone deployment is exactly the case where an operator cannot
485        // tell which zone this line is about without it.
486        let zone = self.zone();
487        match level {
488            AlertLevel::Ok => {
489                tracing::warn!(zone, %err, failures, %expiry, "certificate renewal failed; still serving the current certificate");
490            }
491            AlertLevel::Warning => {
492                tracing::error!(zone, %err, failures, %expiry, "certificate renewal has failed repeatedly; needs attention before it becomes an outage");
493            }
494            AlertLevel::Critical => {
495                tracing::error!(zone, %err, failures, %expiry, "certificate renewal is failing close to expiry -- every agent stops resolving if this is not fixed before then");
496            }
497        }
498    }
499}