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}