Skip to main content

didbot_tls/
fleet.rs

1//! One certificate pair per zone, and the SNI dispatch that picks between
2//! them.
3//!
4//! # Why a deployment cannot have one certificate
5//!
6//! A wildcard matches exactly one label. `*.pds.did.bot` covers
7//! `claudes.pds.did.bot` and does not cover `agent.claudes.pds.did.bot`, so
8//! a deployment configured with `claudes.pds.did.bot` and
9//! `vllm.pds.did.bot` needs a `<zone>` + `*.<zone>` pair for each of them —
10//! two ACME orders, two certificates, two renewal clocks.
11//! [`crate::manager::CertificateManager`] is one zone's worth of that;
12//! [`CertificateFleet`] is the deployment's.
13//!
14//! # One `ServerConfig` regardless
15//!
16//! The listener still binds once and the process still builds one
17//! `rustls::ServerConfig`. [`ZoneResolver`] sits where a single zone's
18//! [`crate::resolver::SwappableCert`] would, holds one of those per zone,
19//! and picks between them on the SNI name each `ClientHello` carries. Each
20//! zone's renewal swaps its own certificate under the same
21//! [`arc_swap::ArcSwap`] a single-zone deployment uses, so hot reload is
22//! unchanged: no listener is rebound, and no connection past its handshake
23//! notices.
24//!
25//! # What a failed renewal does here
26//!
27//! Exactly what it does for one zone, per zone and no more widely.
28//! [`crate::renew`] holds the whole policy: renewal starts 30 days before
29//! expiry, retries hourly, keeps serving the existing certificate on
30//! failure, warns after three consecutive failures and escalates to
31//! [`AlertLevel::Critical`] inside three days of expiry whether or not
32//! anything has failed yet. A zone whose renewal is failing does not stop
33//! the other zones' renewals or their traffic; [`CertificateFleet::alert_level`]
34//! reports the worst level any zone is at, so a deployment-wide health check
35//! reads one value, and [`CertificateFleet::zone_alert_levels`] says which
36//! zone it came from.
37
38use std::sync::Arc;
39
40use rustls::server::{ClientHello, ResolvesServerCert};
41use rustls::sign::CertifiedKey;
42
43use crate::directory::Environment;
44use crate::dns::AcmeDnsChallenge;
45use crate::manager::{BootstrapError, CertificateManager};
46use crate::renew::{AlertLevel, RenewalPolicy};
47use crate::resolver::SwappableCert;
48
49/// Why a fleet could not be assembled.
50#[derive(Debug, thiserror::Error)]
51pub enum FleetError {
52    /// No zones were offered at all.
53    #[error("a certificate fleet needs at least one zone")]
54    NoZones,
55    /// The same zone hostname was configured twice. Two managers renewing
56    /// one zone would place two ACME orders for the same names and race each
57    /// other's storage, so this is refused rather than deduplicated.
58    #[error("zone {0:?} is configured more than once")]
59    DuplicateZone(String),
60    /// One zone's certificate could not be loaded or obtained. Named, because
61    /// "the certificate failed" is not actionable in a deployment holding
62    /// several.
63    #[error("zone {zone:?}: {source}")]
64    Zone {
65        /// The zone that failed.
66        zone: String,
67        /// What went wrong for it.
68        #[source]
69        source: Box<BootstrapError>,
70    },
71}
72
73/// Whether a certificate for `zone` — covering `zone` and `*.zone` — is the
74/// right certificate to present for `host`.
75///
76/// The one-label rule, stated once: the apex itself, or exactly one label
77/// under it. `agent.claudes.pds.did.bot` is *not* covered by a certificate
78/// for `pds.did.bot`, which is the whole reason a deployment needs a
79/// certificate per zone rather than one at the root.
80pub fn zone_covers(zone: &str, host: &str) -> bool {
81    let zone = zone.trim_end_matches('.');
82    let host = host.trim_end_matches('.');
83    if host.eq_ignore_ascii_case(zone) {
84        return true;
85    }
86    match host.len().checked_sub(zone.len() + 1) {
87        Some(prefix_len) if prefix_len > 0 => {
88            let (prefix, rest) = host.split_at(prefix_len);
89            rest.eq_ignore_ascii_case(&format!(".{zone}")) && !prefix.contains('.')
90        }
91        _ => false,
92    }
93}
94
95/// The process's one `rustls` certificate resolver when several zones are
96/// served: one [`SwappableCert`] per zone, chosen by SNI.
97///
98/// # No SNI, and unknown SNI
99///
100/// A `ClientHello` with no server name — an IP-address connection, or an
101/// ancient client — gets the *primary* zone's certificate: the first zone
102/// configured, which is also the zone whose hostname the server answers for
103/// itself. Handing back something is right here; the handshake then fails or
104/// succeeds on the client's own name check, which is where that decision
105/// belongs.
106///
107/// A `ClientHello` naming a host no configured zone covers gets `None`,
108/// which rustls turns into an `unrecognized_name` alert. That is the honest
109/// answer: presenting a certificate for a name we do not serve would fail
110/// the client's verification anyway, and more confusingly.
111#[derive(Debug)]
112pub struct ZoneResolver {
113    /// Zone hostname and its live certificate, sorted deepest zone first so
114    /// a nested pair (`pds.did.bot` and `claudes.pds.did.bot`) resolves to
115    /// the more specific one — matching `didbot_identity::ZoneRegistry`'s
116    /// own "most specific wins" rule.
117    zones: Vec<(String, Arc<SwappableCert>)>,
118    /// The first zone as *configured*, which sorting by depth moves. Held
119    /// separately rather than by index for that reason.
120    primary: Arc<SwappableCert>,
121}
122
123impl ZoneResolver {
124    /// Builds a resolver over `zones`, refusing an empty or duplicated set.
125    ///
126    /// The first entry is the primary: the certificate a `ClientHello` with
127    /// no SNI gets.
128    pub fn new(zones: Vec<(String, Arc<SwappableCert>)>) -> Result<Arc<Self>, FleetError> {
129        if zones.is_empty() {
130            return Err(FleetError::NoZones);
131        }
132        for i in 0..zones.len() {
133            for j in (i + 1)..zones.len() {
134                if zones[i].0.eq_ignore_ascii_case(&zones[j].0) {
135                    return Err(FleetError::DuplicateZone(zones[i].0.clone()));
136                }
137            }
138        }
139        let primary = zones[0].1.clone();
140        let mut zones = zones;
141        zones.sort_by_key(|(zone, _)| std::cmp::Reverse(zone.matches('.').count()));
142        Ok(Arc::new(Self { zones, primary }))
143    }
144
145    /// The zones this resolver can answer for, deepest first.
146    pub fn zones(&self) -> Vec<&str> {
147        self.zones.iter().map(|(zone, _)| zone.as_str()).collect()
148    }
149
150    /// The certificate this resolver would present for `host`, if any.
151    ///
152    /// Exposed so a test — or an operator-facing check — can ask the
153    /// question without building a `ClientHello`.
154    pub fn certificate_for(&self, host: &str) -> Option<Arc<CertifiedKey>> {
155        self.zones
156            .iter()
157            .find(|(zone, _)| zone_covers(zone, host))
158            .map(|(_, cert)| cert.current())
159    }
160}
161
162impl ResolvesServerCert for ZoneResolver {
163    fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
164        match client_hello.server_name() {
165            Some(host) => {
166                let found = self.certificate_for(host);
167                if found.is_none() {
168                    tracing::debug!(
169                        sni = host,
170                        zones = ?self.zones(),
171                        "no configured zone covers this sni name; refusing the handshake"
172                    );
173                }
174                found
175            }
176            None => Some(self.primary_cert()),
177        }
178    }
179}
180
181impl ZoneResolver {
182    /// The certificate a `ClientHello` with no SNI name gets: the first zone
183    /// configured.
184    pub fn primary_cert(&self) -> Arc<CertifiedKey> {
185        self.primary.current()
186    }
187}
188
189/// Every configured zone's certificate, renewed independently, served
190/// through one `rustls::ServerConfig`.
191pub struct CertificateFleet<D: AcmeDnsChallenge> {
192    managers: Vec<Arc<CertificateManager<D>>>,
193    resolver: Arc<ZoneResolver>,
194}
195
196impl<D: AcmeDnsChallenge + 'static> CertificateFleet<D> {
197    /// Loads or obtains a `<zone>` + `*.<zone>` certificate for every entry
198    /// in `zones`, each through its own DNS-01 backend.
199    ///
200    /// `zones` pairs a zone hostname with the backend that can write
201    /// `_acme-challenge.<zone>` for it — in production one `Route53Dns` per
202    /// hosted zone, since one credential is scoped to one zone. The first
203    /// entry is the primary (see [`ZoneResolver`]).
204    ///
205    /// Fails if *any* zone fails: a server that came up serving three of its
206    /// four configured zones would be a deployment silently missing a
207    /// quarter of its agents, discovered by whoever tried to use them.
208    pub async fn bootstrap(
209        data_dir: &std::path::Path,
210        zones: Vec<(String, Arc<D>)>,
211        environment: Environment,
212        contact: &[&str],
213        policy: RenewalPolicy,
214    ) -> Result<Arc<Self>, FleetError> {
215        if zones.is_empty() {
216            return Err(FleetError::NoZones);
217        }
218        for i in 0..zones.len() {
219            for j in (i + 1)..zones.len() {
220                if zones[i].0.eq_ignore_ascii_case(&zones[j].0) {
221                    return Err(FleetError::DuplicateZone(zones[i].0.clone()));
222                }
223            }
224        }
225
226        let mut managers = Vec::with_capacity(zones.len());
227        // Sequential, not concurrent: the first zone through here is the one
228        // that creates the shared ACME account, and two registrations racing
229        // for one account is a needless way to meet a CA's rate limit.
230        for (zone, dns) in zones {
231            let manager =
232                CertificateManager::bootstrap(data_dir, &zone, environment, contact, policy, dns)
233                    .await
234                    .map_err(|source| FleetError::Zone {
235                        zone: zone.clone(),
236                        source: Box::new(source),
237                    })?;
238            managers.push(manager);
239        }
240
241        Self::from_managers(managers)
242    }
243
244    /// Assembles a fleet from managers built elsewhere. The escape hatch a
245    /// test uses, and the seam that keeps [`Self::bootstrap`] from being the
246    /// only way in.
247    pub fn from_managers(
248        managers: Vec<Arc<CertificateManager<D>>>,
249    ) -> Result<Arc<Self>, FleetError> {
250        let resolver = ZoneResolver::new(
251            managers
252                .iter()
253                .map(|manager| (manager.zone().to_owned(), manager.resolver()))
254                .collect(),
255        )?;
256        tracing::info!(zones = ?resolver.zones(), "tls certificates ready, one pair per zone");
257        Ok(Arc::new(Self { managers, resolver }))
258    }
259
260    /// The SNI-dispatching resolver behind [`Self::server_config`].
261    pub fn resolver(&self) -> Arc<ZoneResolver> {
262        self.resolver.clone()
263    }
264
265    /// The one `rustls::ServerConfig` this process should build, whatever
266    /// number of zones it holds. Renewal swaps a certificate underneath it;
267    /// nothing rebuilds this or rebinds the listener.
268    pub fn server_config(&self) -> Arc<rustls::ServerConfig> {
269        let mut config = rustls::ServerConfig::builder()
270            .with_no_client_auth()
271            .with_cert_resolver(self.resolver.clone());
272        config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
273        Arc::new(config)
274    }
275
276    /// Every zone's manager, in configuration order.
277    pub fn managers(&self) -> &[Arc<CertificateManager<D>>] {
278        &self.managers
279    }
280
281    /// The worst alert level any zone is currently at.
282    pub async fn alert_level(&self) -> AlertLevel {
283        let mut worst = AlertLevel::Ok;
284        for manager in &self.managers {
285            worst = worst.max(manager.alert_level().await);
286        }
287        worst
288    }
289
290    /// Each zone's alert level, so a failing renewal is attributable to the
291    /// zone it is failing for rather than to the deployment as a whole.
292    pub async fn zone_alert_levels(&self) -> Vec<(String, AlertLevel)> {
293        let mut levels = Vec::with_capacity(self.managers.len());
294        for manager in &self.managers {
295            levels.push((manager.zone().to_owned(), manager.alert_level().await));
296        }
297        levels
298    }
299
300    /// Runs every zone's renewal loop, forever.
301    ///
302    /// One task per zone, not one loop over all of them: a zone whose ACME
303    /// exchange is hanging must not hold up another zone's renewal behind
304    /// it. Cancelling this future aborts all of them.
305    pub async fn run_renewal_loops(self: Arc<Self>) {
306        let mut tasks = Vec::with_capacity(self.managers.len());
307        for manager in &self.managers {
308            tasks.push(tokio::spawn(manager.clone().run_renewal_loop()));
309        }
310        let _guard = AbortOnDrop(tasks);
311        std::future::pending::<()>().await;
312    }
313}
314
315/// Aborts every renewal task when the loop future is dropped, so a
316/// cancelled `run_renewal_loops` does not leave tasks renewing behind it.
317struct AbortOnDrop(Vec<tokio::task::JoinHandle<()>>);
318
319impl Drop for AbortOnDrop {
320    fn drop(&mut self) {
321        for task in &self.0 {
322            task.abort();
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::cert::certified_key_from_pem;
331    use crate::test_support::self_signed_pem;
332
333    fn swappable(name: &str) -> Arc<SwappableCert> {
334        let (cert, key) = self_signed_pem(name);
335        let provider = rustls::crypto::ring::default_provider();
336        SwappableCert::new(certified_key_from_pem(&cert, &key, &provider).unwrap())
337    }
338
339    fn resolver(zones: &[&str]) -> Arc<ZoneResolver> {
340        ZoneResolver::new(
341            zones
342                .iter()
343                .map(|zone| ((*zone).to_owned(), swappable(zone)))
344                .collect(),
345        )
346        .unwrap()
347    }
348
349    #[test]
350    fn a_zone_covers_its_apex_and_exactly_one_label_under_it() {
351        assert!(zone_covers("pds.did.bot", "pds.did.bot"));
352        assert!(zone_covers("pds.did.bot", "claudes.pds.did.bot"));
353        assert!(!zone_covers("pds.did.bot", "agent.claudes.pds.did.bot"));
354        assert!(!zone_covers("pds.did.bot", "did.bot"));
355        assert!(!zone_covers("pds.did.bot", "notpds.did.bot"));
356        assert!(!zone_covers("pds.did.bot", ".pds.did.bot"));
357    }
358
359    #[test]
360    fn coverage_ignores_case_and_a_trailing_root_dot() {
361        assert!(zone_covers("pds.did.bot", "Claudes.PDS.did.bot"));
362        assert!(zone_covers("pds.did.bot", "claudes.pds.did.bot."));
363    }
364
365    #[test]
366    fn each_zone_gets_its_own_certificate() {
367        let resolver = resolver(&["claudes.pds.did.bot", "vllm.pds.did.bot"]);
368
369        // An agent one label under each zone gets that zone's own
370        // certificate, and the two are different certificates -- which is
371        // the property a single deployment-wide pair cannot have.
372        let a = resolver
373            .certificate_for("agent.claudes.pds.did.bot")
374            .unwrap();
375        let b = resolver.certificate_for("agent.vllm.pds.did.bot").unwrap();
376        assert_ne!(a.cert.first().unwrap(), b.cert.first().unwrap());
377        assert_eq!(
378            a.cert.first().unwrap(),
379            resolver
380                .certificate_for("claudes.pds.did.bot")
381                .unwrap()
382                .cert
383                .first()
384                .unwrap()
385        );
386
387        // Two labels under a zone is past what its wildcard covers, and
388        // neither zone's certificate is offered for it.
389        assert!(resolver
390            .certificate_for("agent.sub.claudes.pds.did.bot")
391            .is_none());
392    }
393
394    #[test]
395    fn a_name_no_zone_covers_gets_nothing() {
396        let resolver = resolver(&["claudes.pds.did.bot"]);
397        assert!(resolver.certificate_for("elsewhere.example").is_none());
398        assert!(resolver
399            .certificate_for("deep.one.claudes.pds.did.bot")
400            .is_none());
401    }
402
403    #[test]
404    fn a_nested_zone_wins_over_its_parent() {
405        // Configuration order deliberately puts the parent first: the
406        // resolver must sort by depth, not trust the order it was given.
407        let resolver = resolver(&["pds.did.bot", "claudes.pds.did.bot"]);
408        let nested = resolver
409            .certificate_for("agent.claudes.pds.did.bot")
410            .unwrap();
411        let own = resolver.certificate_for("claudes.pds.did.bot").unwrap();
412        // `claudes.pds.did.bot` is covered by both zones; the deeper one's
413        // certificate is the one that also covers what is under it, so it is
414        // the one that must answer for both names.
415        assert_eq!(nested.cert.first().unwrap(), own.cert.first().unwrap());
416    }
417
418    #[test]
419    fn no_sni_gets_the_primary_zone() {
420        let resolver = resolver(&["pds.did.bot", "claudes.pds.did.bot"]);
421        let primary = resolver.certificate_for("pds.did.bot").unwrap();
422        assert_eq!(
423            resolver.primary_cert().cert.first().unwrap(),
424            primary.cert.first().unwrap()
425        );
426    }
427
428    #[test]
429    fn a_renewal_swaps_only_its_own_zone() {
430        let claudes = swappable("claudes.pds.did.bot");
431        let vllm = swappable("vllm.pds.did.bot");
432        let resolver = ZoneResolver::new(vec![
433            ("claudes.pds.did.bot".to_owned(), claudes.clone()),
434            ("vllm.pds.did.bot".to_owned(), vllm.clone()),
435        ])
436        .unwrap();
437
438        let vllm_before = resolver.certificate_for("vllm.pds.did.bot").unwrap();
439        let claudes_before = resolver.certificate_for("claudes.pds.did.bot").unwrap();
440
441        let (cert, key) = self_signed_pem("claudes.pds.did.bot");
442        let provider = rustls::crypto::ring::default_provider();
443        claudes.swap(certified_key_from_pem(&cert, &key, &provider).unwrap());
444
445        assert_ne!(
446            claudes_before.cert.first().unwrap(),
447            resolver
448                .certificate_for("claudes.pds.did.bot")
449                .unwrap()
450                .cert
451                .first()
452                .unwrap()
453        );
454        assert_eq!(
455            vllm_before.cert.first().unwrap(),
456            resolver
457                .certificate_for("vllm.pds.did.bot")
458                .unwrap()
459                .cert
460                .first()
461                .unwrap()
462        );
463    }
464
465    #[test]
466    fn duplicate_and_empty_zone_sets_are_refused() {
467        assert!(matches!(
468            ZoneResolver::new(vec![]).unwrap_err(),
469            FleetError::NoZones
470        ));
471        let dup = ZoneResolver::new(vec![
472            ("pds.did.bot".to_owned(), swappable("pds.did.bot")),
473            ("PDS.did.bot".to_owned(), swappable("pds.did.bot")),
474        ]);
475        assert!(matches!(dup.unwrap_err(), FleetError::DuplicateZone(_)));
476    }
477}