Skip to main content

didbot_dns/
lib.rs

1//! DNS providers for the records a deployment writes into its own zone.
2//!
3//! Every agent gets its own hostname, because atproto supports "only
4//! hostname-level `did:web` DIDs". Those hostnames resolve through one
5//! wildcard record the deployment's zone holds (`terraform-aws-didbot-pds/route53.tf`), so
6//! minting an account writes nothing here — see `docs/names.md`. What a
7//! deployment does write is the pair of records a wildcard cannot stand in
8//! for: the `TXT` values an ACME DNS-01 challenge places at
9//! `_acme-challenge.<zone>`, and the `CAA` record at the apex that says which
10//! authority and which account may issue for the zone.
11//!
12//! The trait is deliberately small and synchronous. A write here is a rare,
13//! coarse-grained operation on the certificate path, so nothing needs a
14//! runtime; a provider that talks to a hosted DNS API blocks on its own
15//! client without pushing an async runtime into every consumer.
16//!
17//! [`InMemoryDns`] is the test double and the model of what a real provider's
18//! bookkeeping looks like. `route53::Route53Dns` drives a real hosted zone
19//! and is the backend this server runs in production, behind the `route53`
20//! feature so a build that does not need it does not pay for it. A provider
21//! that drives some other authoritative zone — a different hosted DNS API, a
22//! zone file, `nsupdate` — implements the same trait and slots in without a
23//! caller change.
24//!
25//! [`DnsProvider::publish_txt`], [`DnsProvider::withdraw_txt`] and
26//! [`DnsProvider::txt_values`] are an additive API over a set: the ACME
27//! DNS-01 challenge for a certificate covering both a name and its wildcard
28//! requires two values at one name simultaneously, and that is a set to add
29//! to and remove from, not a single slot to overwrite.
30//! [`DnsProvider::publish_caa`] is the opposite, a replacement: a zone's
31//! issuance policy is one statement with one writer.
32
33#![forbid(unsafe_code)]
34
35use std::collections::BTreeMap;
36use std::fmt;
37use std::sync::Mutex;
38
39#[cfg(feature = "aws-fake")]
40pub mod aws_fake;
41#[cfg(feature = "route53")]
42pub mod route53;
43
44/// One `CAA` property: RFC 8659's flags, tag and value.
45///
46/// A `CAA` record set is a statement about which certificate authority may
47/// issue for a name and everything under it. It is not a defence against
48/// whoever holds this zone's credential — that holder can rewrite this record
49/// as easily as any other. What it does is narrow issuance to one authority
50/// and one account at that authority, so a certificate for a name in this
51/// zone cannot be obtained through any other path that does not first change
52/// this record; and changing it is a second, separately visible act.
53///
54/// Only `issue` and `iodef` are constructed here. `issuewild` is deliberately
55/// absent: RFC 8659 says a wildcard request falls back to `issue` when no
56/// `issuewild` is present, and this deployment's certificate covers a zone
57/// and its wildcard from one order through one account, so a second property
58/// could only ever say the same thing or disagree with it.
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60pub struct CaaValue {
61    /// RFC 8659 flags octet. `0` for every property this crate writes: the
62    /// critical bit tells a certificate authority to refuse issuance rather
63    /// than ignore a property it does not understand, and `issue` and `iodef`
64    /// are understood everywhere.
65    pub flags: u8,
66    /// The property tag: `issue` or `iodef`.
67    pub tag: String,
68    /// The property value, unquoted.
69    pub value: String,
70}
71
72impl CaaValue {
73    /// `issue`, authorising `issuer` — a domain name such as
74    /// `letsencrypt.org` — and no one else.
75    pub fn issue(issuer: impl Into<String>) -> Self {
76        Self {
77            flags: 0,
78            tag: "issue".to_string(),
79            value: issuer.into(),
80        }
81    }
82
83    /// `issue` with RFC 8657's `accounturi` parameter, authorising `issuer`
84    /// only when the request comes from the ACME account at `account_uri`.
85    ///
86    /// This is the half that matters. `issue "letsencrypt.org"` alone
87    /// authorises anyone who can create an account at that authority, which
88    /// is anyone; naming the account narrows it to the one this deployment
89    /// holds the key for. An authority that does not implement RFC 8657
90    /// ignores the parameter and enforces the issuer name only, which is
91    /// what makes publishing it safe against an authority that has not
92    /// caught up.
93    pub fn issue_for_account(issuer: impl AsRef<str>, account_uri: impl AsRef<str>) -> Self {
94        Self::issue(format!(
95            "{}; accounturi={}",
96            issuer.as_ref(),
97            account_uri.as_ref()
98        ))
99    }
100
101    /// `iodef`, naming where a certificate authority should report a request
102    /// this record made it refuse.
103    ///
104    /// A refused issuance is the signal that someone tried to obtain a
105    /// certificate for this zone through an account that is not ours, and it
106    /// arrives from the authority rather than from this host — which is the
107    /// point, since a host holding the zone credential cannot be trusted to
108    /// report on its own misuse.
109    pub fn iodef(target: impl Into<String>) -> Self {
110        Self {
111            flags: 0,
112            tag: "iodef".to_string(),
113            value: target.into(),
114        }
115    }
116
117    /// The record's wire form: `<flags> <tag> "<value>"`, with `"` and `\`
118    /// escaped inside the quoted value.
119    pub fn to_wire(&self) -> String {
120        let escaped = self.value.replace('\\', "\\\\").replace('"', "\\\"");
121        format!("{} {} \"{}\"", self.flags, self.tag, escaped)
122    }
123}
124
125impl fmt::Display for CaaValue {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str(&self.to_wire())
128    }
129}
130
131/// Why a record could not be published or withdrawn.
132#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
133pub enum DnsError {
134    /// The host is not one this provider is allowed to write.
135    ///
136    /// A provider is scoped to a zone, and a request outside it is a
137    /// configuration error, not a routing decision. Refusing loudly is the
138    /// point: the alternative is a provider that quietly does nothing while
139    /// the caller believes a challenge value is in place.
140    #[error("host {host:?} is not under this provider's zone")]
141    NotUnderZone {
142        /// The host that was refused.
143        host: String,
144    },
145    /// Nothing was published for this host, so there is nothing to withdraw.
146    #[error("no record is published for {host:?}")]
147    NotPublished {
148        /// The host that was absent.
149        host: String,
150    },
151    /// The provider's own request failed for a reason that is not zone
152    /// containment — a network error, a malformed response, or exhausting a
153    /// retry budget against a rate limit.
154    #[error("dns provider error for {host:?}: {message}")]
155    Backend {
156        /// The host the failing request was for.
157        host: String,
158        /// What the provider said went wrong.
159        message: String,
160    },
161}
162
163/// A zone the records a certificate depends on can be written into.
164///
165/// Implementations must be safe to share: the certificate manager holds one
166/// behind a shared reference for the life of the process and calls it from
167/// whatever task a renewal runs on.
168pub trait DnsProvider: Send + Sync {
169    /// Short stable identifier for this provider, used in log fields.
170    fn name(&self) -> &str;
171
172    /// Adds `value` to the set of `TXT` values published at `host`, leaving
173    /// any other values at that name untouched.
174    ///
175    /// A `TXT` name used for the ACME DNS-01 challenge holds a set: issuing
176    /// one certificate that covers both `pds.example` and `*.pds.example`
177    /// requires two simultaneous values at `_acme-challenge.pds.example`,
178    /// one per name being validated, and a provider that cannot hold both
179    /// cannot issue that certificate. Calling this twice with the same
180    /// `value` is a no-op, not a collision: a retried challenge publish must
181    /// not fail.
182    ///
183    /// Returns once the value is visible to a resolver, on a backend that can
184    /// tell: the caller's next step is to ask the authority to look.
185    fn publish_txt(&self, host: &str, value: &str) -> Result<(), DnsError>;
186
187    /// Removes one `TXT` value at `host`, leaving any other values at that
188    /// name untouched.
189    ///
190    /// Removing the last value at a name removes the name; removing a value
191    /// that was never published, or a value that has already been withdrawn,
192    /// fails with [`DnsError::NotPublished`].
193    fn withdraw_txt(&self, host: &str, value: &str) -> Result<(), DnsError>;
194
195    /// Every `TXT` value currently published at `host`, sorted.
196    ///
197    /// Empty, not an error, when nothing is published there — checking is a
198    /// read, and an empty set is a legitimate answer to it. Sorted so that a
199    /// test or an operator comparing two listings sees a difference only
200    /// when the set actually differs.
201    fn txt_values(&self, host: &str) -> Vec<String>;
202
203    /// Replaces the `CAA` record set at `host` with exactly `values`,
204    /// removing the record entirely when `values` is empty.
205    ///
206    /// An upsert, deliberately not [`DnsProvider::publish_txt`]'s additive
207    /// rule. Adding to a `CAA` set widens who may issue; the operation this
208    /// needs is "authorise these and nobody else", which only a replacement
209    /// gives. A zone's issuance policy is one statement with one writer,
210    /// restated on every start from the account and authority the process is
211    /// actually about to order through; a provider that refused to restate
212    /// it could never correct a stale one, and a stale `CAA` naming an
213    /// account this deployment no longer holds is an outage on the next
214    /// renewal rather than a safe default.
215    ///
216    /// `CAA` lives at the zone apex, beside the address record for the server
217    /// itself. The two do not collide: they are different record types at one
218    /// name rather than two answers to one question.
219    fn publish_caa(&self, host: &str, values: &[CaaValue]) -> Result<(), DnsError>;
220
221    /// Every `CAA` value currently published at `host`, sorted.
222    ///
223    /// Empty, not an error, when nothing is published there — the same
224    /// reasoning [`DnsProvider::txt_values`] documents.
225    fn caa_values(&self, host: &str) -> Vec<CaaValue>;
226}
227
228/// A boxed provider is a provider.
229///
230/// A caller that picks its backend at runtime needs one concrete type every
231/// arm produces. `Box<dyn DnsProvider>` is that type, and this impl is what
232/// lets it stand in for a generic parameter instead of the caller writing an
233/// enum with one variant per backend.
234impl DnsProvider for Box<dyn DnsProvider> {
235    fn name(&self) -> &str {
236        (**self).name()
237    }
238
239    fn publish_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
240        (**self).publish_txt(host, value)
241    }
242
243    fn withdraw_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
244        (**self).withdraw_txt(host, value)
245    }
246
247    fn txt_values(&self, host: &str) -> Vec<String> {
248        (**self).txt_values(host)
249    }
250
251    fn publish_caa(&self, host: &str, values: &[CaaValue]) -> Result<(), DnsError> {
252        (**self).publish_caa(host, values)
253    }
254
255    fn caa_values(&self, host: &str) -> Vec<CaaValue> {
256        (**self).caa_values(host)
257    }
258}
259
260/// A shared provider is a provider.
261///
262/// One provider instance, held in more than one place at once: the
263/// certificate manager for a zone and a test that inspects what it wrote
264/// look at the same maps. `Box<dyn DnsProvider>` cannot be shared, so this
265/// is the form that case takes. Generic over `T` rather than written for
266/// `Arc<dyn DnsProvider>` alone, so that a caller keeping a concrete
267/// provider can hand the same `Arc` to something that wants only the trait.
268impl<T: DnsProvider + ?Sized> DnsProvider for std::sync::Arc<T> {
269    fn name(&self) -> &str {
270        (**self).name()
271    }
272
273    fn publish_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
274        (**self).publish_txt(host, value)
275    }
276
277    fn withdraw_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
278        (**self).withdraw_txt(host, value)
279    }
280
281    fn txt_values(&self, host: &str) -> Vec<String> {
282        (**self).txt_values(host)
283    }
284
285    fn publish_caa(&self, host: &str, values: &[CaaValue]) -> Result<(), DnsError> {
286        (**self).publish_caa(host, values)
287    }
288
289    fn caa_values(&self, host: &str) -> Vec<CaaValue> {
290        (**self).caa_values(host)
291    }
292}
293
294/// Folds `host` to the form every bookkeeping map keys on.
295///
296/// RFC 1035 3.1 makes DNS name comparison case-insensitive: `A.example` and
297/// `a.example` are one name. Every map in this module is keyed on the
298/// lowercased spelling so that two spellings of one host read and write one
299/// entry, instead of each believing the other's values are not there.
300fn normalize_host(host: &str) -> String {
301    host.to_ascii_lowercase()
302}
303
304/// Shared multi-value `TXT` bookkeeping.
305///
306/// A second, third or fourth value at one name is the entire point (see
307/// [`DnsProvider::publish_txt`]), so nothing here refuses a write. A
308/// `BTreeSet` inside a `BTreeMap` because [`DnsProvider::txt_values`]
309/// promises sorted output and a sorted container gives it for free.
310#[derive(Debug, Default)]
311struct TxtRecords(Mutex<BTreeMap<String, std::collections::BTreeSet<String>>>);
312
313impl TxtRecords {
314    /// Takes the lock, recovering from a poisoned mutex.
315    ///
316    /// A panic while holding this lock cannot leave the map inconsistent —
317    /// every write under it is a single map operation — so the data is still
318    /// good and refusing to serve it would turn one unrelated panic into a
319    /// permanently broken provider.
320    fn map(
321        &self,
322    ) -> std::sync::MutexGuard<'_, BTreeMap<String, std::collections::BTreeSet<String>>> {
323        self.0
324            .lock()
325            .unwrap_or_else(|poisoned| poisoned.into_inner())
326    }
327
328    /// Adds `value` at `host`. Returns whether it was newly added, so a
329    /// caller can skip a redundant network write on a retried publish.
330    fn insert(&self, host: &str, value: &str) -> bool {
331        self.map()
332            .entry(normalize_host(host))
333            .or_default()
334            .insert(value.to_owned())
335    }
336
337    /// Removes `value` at `host`. Cleans up the name entirely once its last
338    /// value is gone, so an empty set never lingers as a false positive in
339    /// [`Self::values`].
340    fn remove(&self, host: &str, value: &str) -> Result<(), DnsError> {
341        let mut map = self.map();
342        let key = normalize_host(host);
343        let Some(values) = map.get_mut(&key) else {
344            return Err(DnsError::NotPublished {
345                host: host.to_owned(),
346            });
347        };
348        if !values.remove(value) {
349            return Err(DnsError::NotPublished {
350                host: host.to_owned(),
351            });
352        }
353        if values.is_empty() {
354            map.remove(&key);
355        }
356        Ok(())
357    }
358
359    fn values(&self, host: &str) -> Vec<String> {
360        self.map()
361            .get(&normalize_host(host))
362            .map(|values| values.iter().cloned().collect())
363            .unwrap_or_default()
364    }
365}
366
367/// Shared `CAA` bookkeeping.
368///
369/// Not [`TxtRecords`], whose insert is additive; this one replaces, for the
370/// reason [`DnsProvider::publish_caa`] gives.
371#[derive(Debug, Default)]
372struct CaaRecords(Mutex<BTreeMap<String, Vec<CaaValue>>>);
373
374impl CaaRecords {
375    /// Takes the lock, recovering from a poisoned mutex; see
376    /// [`TxtRecords::map`] for why that is safe here too.
377    fn map(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Vec<CaaValue>>> {
378        self.0
379            .lock()
380            .unwrap_or_else(|poisoned| poisoned.into_inner())
381    }
382
383    /// Sets the set at `host` to `values`, or removes the name when `values`
384    /// is empty. Returns whether anything changed, so a caller can skip a
385    /// redundant network write when a restart restates what is already there.
386    fn replace(&self, host: &str, values: &[CaaValue]) -> bool {
387        let mut sorted = values.to_vec();
388        sorted.sort();
389        sorted.dedup();
390        let key = normalize_host(host);
391        let mut map = self.map();
392        if sorted.is_empty() {
393            return map.remove(&key).is_some();
394        }
395        match map.get(&key) {
396            Some(existing) if *existing == sorted => false,
397            _ => {
398                map.insert(key, sorted);
399                true
400            }
401        }
402    }
403
404    fn values(&self, host: &str) -> Vec<CaaValue> {
405        self.map()
406            .get(&normalize_host(host))
407            .cloned()
408            .unwrap_or_default()
409    }
410}
411
412/// A provider that records everything and admits any hostname.
413///
414/// The test double for callers that want to assert on what a certificate
415/// order asked for without also asserting on zone rules. It has no zone, so
416/// it never returns [`DnsError::NotUnderZone`].
417#[derive(Debug, Default)]
418pub struct InMemoryDns {
419    txt: TxtRecords,
420    caa: CaaRecords,
421}
422
423impl InMemoryDns {
424    /// A provider with nothing published.
425    pub fn new() -> Self {
426        Self::default()
427    }
428}
429
430impl DnsProvider for InMemoryDns {
431    fn name(&self) -> &str {
432        "in-memory"
433    }
434
435    fn publish_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
436        if self.txt.insert(host, value) {
437            tracing::info!(
438                provider = self.name(),
439                host = host,
440                value = value,
441                "published txt value"
442            );
443        }
444        Ok(())
445    }
446
447    fn withdraw_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
448        self.txt.remove(host, value)?;
449        tracing::info!(
450            provider = self.name(),
451            host = host,
452            value = value,
453            "withdrew txt value"
454        );
455        Ok(())
456    }
457
458    fn txt_values(&self, host: &str) -> Vec<String> {
459        self.txt.values(host)
460    }
461
462    fn publish_caa(&self, host: &str, values: &[CaaValue]) -> Result<(), DnsError> {
463        if self.caa.replace(host, values) {
464            log_caa(self.name(), host, values, "published caa record set");
465        }
466        Ok(())
467    }
468
469    fn caa_values(&self, host: &str) -> Vec<CaaValue> {
470        self.caa.values(host)
471    }
472}
473
474/// Renders a `CAA` set for one log line, so an operator reading the log sees
475/// the policy that was written rather than a count of properties.
476fn log_caa(provider: &str, host: &str, values: &[CaaValue], message: &'static str) {
477    let rendered = values
478        .iter()
479        .map(CaaValue::to_wire)
480        .collect::<Vec<_>>()
481        .join(" | ");
482    tracing::info!(
483        provider = provider,
484        host = host,
485        caa = rendered.as_str(),
486        "{message}"
487    );
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn a_caa_set_names_the_issuer_and_the_account() {
496        let value = CaaValue::issue_for_account(
497            "letsencrypt.org",
498            "https://acme-v02.api.letsencrypt.org/acme/acct/1",
499        );
500        assert_eq!(
501            value.to_wire(),
502            "0 issue \"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1\""
503        );
504        assert_eq!(
505            CaaValue::iodef("mailto:ops@example.com").to_wire(),
506            "0 iodef \"mailto:ops@example.com\""
507        );
508    }
509
510    #[test]
511    fn caa_replaces_the_whole_set_rather_than_adding_to_it() {
512        let dns = InMemoryDns::new();
513        assert!(dns
514            .publish_caa("example", &[CaaValue::issue("one.example")])
515            .is_ok());
516        assert!(dns
517            .publish_caa("example", &[CaaValue::issue("two.example")])
518            .is_ok());
519        // Not both: a set that only ever grows widens who may issue, which
520        // is the opposite of what publishing this record is for.
521        assert_eq!(
522            dns.caa_values("example"),
523            vec![CaaValue::issue("two.example")]
524        );
525
526        assert!(dns.publish_caa("example", &[]).is_ok());
527        assert!(dns.caa_values("example").is_empty());
528    }
529
530    #[test]
531    fn txt_values_are_additive_not_exclusive() {
532        // The case this API exists for: a certificate over both `pds.example`
533        // and `*.pds.example` needs two simultaneous values at
534        // `_acme-challenge.pds.example`, and a second publish must not
535        // clobber the first.
536        let dns = InMemoryDns::new();
537        let name = "_acme-challenge.pds.example";
538        assert!(dns.publish_txt(name, "token-for-apex").is_ok());
539        assert!(dns.publish_txt(name, "token-for-wildcard").is_ok());
540        assert_eq!(
541            dns.txt_values(name),
542            vec!["token-for-apex", "token-for-wildcard"]
543        );
544    }
545
546    #[test]
547    fn publishing_the_same_txt_value_twice_is_a_no_op() {
548        let dns = InMemoryDns::new();
549        assert!(dns.publish_txt("a.example", "tok").is_ok());
550        assert!(dns.publish_txt("a.example", "tok").is_ok());
551        assert_eq!(dns.txt_values("a.example"), vec!["tok"]);
552    }
553
554    #[test]
555    fn withdrawing_one_txt_value_leaves_the_others() {
556        let dns = InMemoryDns::new();
557        assert!(dns.publish_txt("a.example", "one").is_ok());
558        assert!(dns.publish_txt("a.example", "two").is_ok());
559        assert!(dns.withdraw_txt("a.example", "one").is_ok());
560        assert_eq!(dns.txt_values("a.example"), vec!["two"]);
561    }
562
563    #[test]
564    fn withdrawing_the_last_txt_value_clears_the_name() {
565        let dns = InMemoryDns::new();
566        assert!(dns.publish_txt("a.example", "only").is_ok());
567        assert!(dns.withdraw_txt("a.example", "only").is_ok());
568        assert!(dns.txt_values("a.example").is_empty());
569    }
570
571    #[test]
572    fn withdrawing_an_absent_txt_value_is_not_published() {
573        let dns = InMemoryDns::new();
574        assert_eq!(
575            dns.withdraw_txt("a.example", "never-published"),
576            Err(DnsError::NotPublished {
577                host: "a.example".to_string()
578            })
579        );
580        assert!(dns.publish_txt("a.example", "one").is_ok());
581        assert_eq!(
582            dns.withdraw_txt("a.example", "not-this-one"),
583            Err(DnsError::NotPublished {
584                host: "a.example".to_string()
585            })
586        );
587    }
588
589    #[test]
590    fn missing_txt_values_are_an_empty_read_not_an_error() {
591        let dns = InMemoryDns::new();
592        assert!(dns.txt_values("never.example").is_empty());
593    }
594
595    #[test]
596    fn providers_are_usable_behind_a_trait_object() {
597        let dns: Box<dyn DnsProvider> = Box::new(InMemoryDns::new());
598        assert!(dns
599            .publish_txt("_acme-challenge.a.localhost", "tok")
600            .is_ok());
601        assert_eq!(dns.txt_values("_acme-challenge.a.localhost"), vec!["tok"]);
602        assert_eq!(dns.name(), "in-memory");
603    }
604}