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}