Skip to main content

didbot_dns/
route53.rs

1//! [`Route53Dns`]: the production backend that writes a real hosted zone.
2//!
3//! # Why hand-rolled SigV4 over `aws-sdk-route53`
4//!
5//! The official SDK is async-only (it is generated from Smithy models built
6//! on `hyper` and `tokio`), which conflicts with this crate's synchronous
7//! trait — see the crate-level docs for why that is deliberate. Using it
8//! would mean embedding a private Tokio runtime inside every provider just to
9//! `block_on` each call, and pulling in `aws-config`, `aws-runtime`,
10//! `aws-smithy-runtime`, `aws-smithy-xml`, `aws-sigv4` and their own copies of
11//! `hyper`/`rustls` — none of which are in `Cargo.lock` today.
12//!
13//! Route53's control-plane API is a small, stable REST/XML surface: five
14//! operations, one signing scheme. `Cargo.lock` already carries everything
15//! signing it needs — `reqwest` (with `rustls-tls`, already selected), `hmac`
16//! and `sha2` (both used elsewhere in this workspace for other purposes) —
17//! so the addition for this feature is `reqwest`'s `blocking` client feature,
18//! `hex`, `time` and `quick-xml`. The request bodies and the response shapes
19//! this provider reads are serde types on the private `xml` module this file
20//! defines, so the XML itself is `quick-xml`'s to escape and to parse.
21//!
22//! # Eventual consistency: a challenge publish waits, a withdrawal does not
23//!
24//! Every Route53 change is accepted as `PENDING` and becomes `INSYNC` some
25//! seconds later, once it has propagated to every edge name server. The
26//! caller of [`DnsProvider::publish_txt`] is about to ask a certificate
27//! authority to look for the value it just published, so this provider
28//! polls
29//! [`GetChange`](https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html)
30//! until the change reports `INSYNC`, up to a configurable timeout, and only
31//! returns `Ok` once it does — never optimistically. A caller that gets `Ok`
32//! back has a value a resolver can see.
33//!
34//! Withdrawal makes the opposite call. A challenge value still visible for a
35//! few more seconds after the order is done costs nothing, so
36//! [`DnsProvider::withdraw_txt`] and [`DnsProvider::publish_caa`] return as
37//! soon as Route53 accepts the change, not once it has propagated.
38//!
39//! # Rate limits and retries
40//!
41//! Route53 throttles the change APIs (`ChangeResourceRecordSets`) far below
42//! the read APIs, and returns `Throttling` or `PriorRequestNotComplete` when
43//! a caller exceeds it. Every call in this module retries those, and a
44//! `5xx`, with capped exponential backoff (see [`RetryPolicy`]); every other
45//! API error — including the one this provider maps to
46//! [`DnsError::NotPublished`] — is not retried, because retrying a real
47//! conflict cannot make it stop being one.
48//!
49//! # Hosted-zone record limits
50//!
51//! A Route53 hosted zone defaults to a 10,000-record-set quota. Under the
52//! wildcard shape `docs/names.md` draws, the zone holds a handful of record
53//! sets whatever the account count, and the one this provider creates and
54//! deletes on every renewal is the `_acme-challenge.<zone>` `TXT`. Nothing
55//! in this module enforces the quota locally — `ChangeResourceRecordSets`
56//! reports it as a normal API error, which surfaces as
57//! [`DnsError::Backend`].
58//!
59//! # The credential
60//!
61//! Scope the IAM credential this provider is given to the records it
62//! writes in the one hosted zone:
63//!
64//! ```text
65//! {
66//!   "Version": "2012-10-17",
67//!   "Statement": [
68//!     { "Effect": "Allow",
69//!       "Action": ["route53:ChangeResourceRecordSets"],
70//!       "Resource": "arn:aws:route53:::hostedzone/<HOSTED_ZONE_ID>",
71//!       "Condition": { "ForAllValues:StringEquals": {
72//!         "route53:ChangeResourceRecordSetsRecordTypes":
73//!           ["TXT", "CAA"],
74//!         "route53:ChangeResourceRecordSetsNormalizedRecordNames":
75//!           ["<zone>", "_acme-challenge.<zone>"] } } },
76//!     { "Effect": "Allow",
77//!       "Action": ["route53:GetChange"],
78//!       "Resource": "arn:aws:route53:::change/*" }
79//!   ]
80//! }
81//! ```
82//!
83//! Both condition keys match per change batch — every record name and type
84//! in the batch must be in the sets — and the names Route53 compares are
85//! normalized: lowercase, no trailing dot. [`Route53Dns`] sends one record
86//! set per batch, each one a pair from those sets.
87//!
88//! `docs/deployment.md` already says why: control of the zone is control of
89//! every DID under it, so this credential is as powerful as the signing
90//! keys, and it should be scoped, rotated and kept off the host that holds
91//! those keys the same way.
92
93use crate::{CaaRecords, CaaValue, DnsError, DnsProvider, TxtRecords};
94use didbot_identity::hostname_is_at_or_below;
95use hmac::{Hmac, Mac};
96use sha2::Sha256;
97use std::collections::BTreeSet;
98use std::thread;
99use std::time::{Duration, Instant};
100
101const DEFAULT_ENDPOINT: &str = "https://route53.amazonaws.com";
102const API_VERSION: &str = "2013-04-01";
103
104/// Long-lived AWS credentials, scoped to one hosted zone.
105///
106/// See the module docs for the IAM policy this credential should carry.
107/// Nothing here reads one from the environment or a file —
108/// [`InstanceCredentials`] is the one credential path this crate offers a
109/// deployment, and it is ambient rather than configured: whatever role is
110/// attached to the running instance, read from IMDS and re-read as it
111/// expires.
112#[derive(Clone)]
113pub struct Route53Credentials {
114    /// The `AKIA…`/`ASIA…` access key id.
115    pub access_key_id: String,
116    /// The paired secret key. Never logged, never included in a `Debug`.
117    pub secret_access_key: String,
118    /// The session token, when the credential is temporary (an assumed
119    /// role or an instance profile).
120    pub session_token: Option<String>,
121}
122
123impl std::fmt::Debug for Route53Credentials {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("Route53Credentials")
126            .field("access_key_id", &self.access_key_id)
127            .field("secret_access_key", &"...")
128            .field("session_token", &self.session_token.as_ref().map(|_| "..."))
129            .finish()
130    }
131}
132
133/// Why credentials could not be fetched from the instance metadata service.
134#[derive(Debug, thiserror::Error)]
135pub enum ImdsError {
136    /// A request to IMDS itself failed — no route to
137    /// `169.254.169.254` (not running on EC2, or the link-local route is
138    /// firewalled), a timeout, or a non-2xx status.
139    #[error("could not reach the instance metadata service: {0}")]
140    Request(String),
141    /// IMDS answered, but not with something this reads as credentials —
142    /// most often no IAM role attached to the instance at all.
143    #[error("instance metadata service response was not usable: {0}")]
144    Response(String),
145}
146
147/// A credential read from IMDS, with the moment IMDS says it stops being
148/// accepted.
149#[derive(Clone, Debug)]
150pub(crate) struct TimedCredentials {
151    pub(crate) credentials: Route53Credentials,
152    /// `None` when the response carried no `Expiration` — a credential that
153    /// states no end, and is signed with until the process ends.
154    pub(crate) expires_at: Option<time::OffsetDateTime>,
155}
156
157/// The IMDS read [`InstanceCredentials`] repeats, isolated the same way
158/// [`Transport`] is and for the same reason: there is no instance metadata
159/// service in this environment, so tests drive the holder against a fake
160/// that expires on command.
161pub(crate) trait CredentialFetch: Send + Sync {
162    fn fetch(&self) -> Result<TimedCredentials, ImdsError>;
163}
164
165/// The real one: three IMDSv2 round trips against `169.254.169.254`.
166struct Imds;
167
168impl CredentialFetch for Imds {
169    fn fetch(&self) -> Result<TimedCredentials, ImdsError> {
170        let client = imds_client().map_err(|err| ImdsError::Request(err.to_string()))?;
171
172        let token = imds_request(
173            client
174                .put(format!("{IMDS_ENDPOINT}/latest/api/token"))
175                .header("X-aws-ec2-metadata-token-ttl-seconds", "21600"),
176        )?;
177
178        let role = imds_request(
179            client
180                .get(format!(
181                    "{IMDS_ENDPOINT}/latest/meta-data/iam/security-credentials/"
182                ))
183                .header("X-aws-ec2-metadata-token", &token),
184        )?;
185        let role = role.trim();
186        if role.is_empty() {
187            return Err(ImdsError::Response(
188                "instance has no IAM role attached".to_string(),
189            ));
190        }
191
192        let body = imds_request(
193            client
194                .get(format!(
195                    "{IMDS_ENDPOINT}/latest/meta-data/iam/security-credentials/{role}"
196                ))
197                .header("X-aws-ec2-metadata-token", &token),
198        )?;
199        parse_imds_credentials(&body)
200    }
201}
202
203/// How far ahead of a credential's stated expiry a refresh is made.
204///
205/// AWS rotates an instance profile's credential well before the one it
206/// handed out expires, so a read this far ahead gets the next one. The
207/// margin also covers the clock skew between this host and the signing
208/// service, and leaves room for the three round trips the refresh itself
209/// costs.
210pub const REFRESH_MARGIN: Duration = Duration::from_secs(300);
211
212/// The credential every Route53 request in this process is signed with,
213/// kept current against the instance profile that issues it.
214///
215/// An instance profile's credential is temporary: IMDS states an
216/// `Expiration`, and a signature made with a credential past it is rejected
217/// by Route53 — which on this deployment stops agent hostnames being
218/// published and stops ACME DNS-01 renewing the certificate, since
219/// `terraform-aws-didbot-pds/security_groups.tf` opens no port 80 for an HTTP-01 order.
220/// This holder reads IMDS again once the credential it holds is within
221/// [`REFRESH_MARGIN`] of that expiry, and hands out the new one.
222///
223/// A refresh that fails is reported at `error` and tried again on the next
224/// request rather than ending the process: while the credential in hand is
225/// still valid it keeps signing with it, and once it is not,
226/// [`Self::current`] returns the [`ImdsError`], which reaches a caller as a
227/// [`DnsError::Backend`] through the same retry path as any other transport
228/// failure.
229///
230/// The refresh happens under the lock on the held credential, so callers
231/// that arrive during one wait for it and then sign with what it produced:
232/// a hosted zone being written by several threads makes one IMDS read
233/// between them, not one each.
234pub struct InstanceCredentials {
235    fetch: Box<dyn CredentialFetch>,
236    held: std::sync::Mutex<TimedCredentials>,
237}
238
239impl InstanceCredentials {
240    /// Reads the credential of whatever IAM role is attached to this EC2
241    /// instance, over IMDSv2, and keeps reading it as it expires.
242    ///
243    /// IMDSv2 specifically, not v1: v1's tokenless `GET` is what let the
244    /// 2019 Capital One breach turn an unrelated request-smuggling bug into
245    /// stolen instance-role credentials, and `infra/pds/`'s launch template
246    /// requires the token hop (`HttpTokens = required`) for exactly that
247    /// reason — see `docs/deployment.md`. This is the one credential path
248    /// this crate offers a production deployment: there is no environment
249    /// variable or file this reads instead, because inventing a second path
250    /// is a second thing to misconfigure and a second place a credential can
251    /// leak from.
252    ///
253    /// Three round trips, each against a 5-second timeout: the session
254    /// token, the attached role's name, and that role's current
255    /// credentials. Blocking, like the rest of this provider — see the
256    /// module docs for why. The first read happens here, so a deployment
257    /// whose instance profile is wrong fails at startup rather than at the
258    /// first publish.
259    pub fn from_instance_metadata() -> Result<Self, ImdsError> {
260        Self::with_fetch(Box::new(Imds))
261    }
262
263    pub(crate) fn with_fetch(fetch: Box<dyn CredentialFetch>) -> Result<Self, ImdsError> {
264        let held = fetch.fetch()?;
265        Ok(Self {
266            fetch,
267            held: std::sync::Mutex::new(held),
268        })
269    }
270
271    /// The credential to sign the next request with, refreshed first if the
272    /// one held is inside [`REFRESH_MARGIN`] of its expiry.
273    pub fn current(&self) -> Result<Route53Credentials, ImdsError> {
274        // Recovering from a poisoned mutex for the same reason
275        // [`Route53Dns::record_sets_slot`] does: what is under it is one
276        // value replaced in one operation, so a panic elsewhere cannot have
277        // left it half-written.
278        let mut held = self
279            .held
280            .lock()
281            .unwrap_or_else(|poisoned| poisoned.into_inner());
282
283        let now = time::OffsetDateTime::now_utc();
284        let Some(expires_at) = held.expires_at else {
285            return Ok(held.credentials.clone());
286        };
287        if now + REFRESH_MARGIN < expires_at {
288            return Ok(held.credentials.clone());
289        }
290
291        match self.fetch.fetch() {
292            Ok(fresh) => {
293                tracing::info!(
294                    expires_at = fresh.expires_at.map(|at| at.to_string()),
295                    "refreshed the route53 credential from instance metadata"
296                );
297                let credentials = fresh.credentials.clone();
298                *held = fresh;
299                Ok(credentials)
300            }
301            Err(err) => {
302                if now < expires_at {
303                    tracing::error!(
304                        %err,
305                        expires_at = expires_at.to_string(),
306                        "could not refresh the route53 credential; signing with the one held \
307                         until it expires"
308                    );
309                    Ok(held.credentials.clone())
310                } else {
311                    tracing::error!(
312                        %err,
313                        expires_at = expires_at.to_string(),
314                        "could not refresh the route53 credential and the one held has \
315                         expired; route53 calls will fail until instance metadata answers"
316                    );
317                    Err(err)
318                }
319            }
320        }
321    }
322}
323
324/// The one lever [`crate::aws_fake`] has on a credential holder that is
325/// otherwise driven entirely by the wall clock.
326///
327/// Nothing outside this crate's own tests can reach it: it is `pub(crate)`
328/// and behind the `aws-fake` feature, which no production build turns on.
329#[cfg(feature = "aws-fake")]
330impl InstanceCredentials {
331    /// Makes the credential in hand expired as of now, so the next
332    /// [`Self::current`] takes the refresh path rather than answering from
333    /// what it holds.
334    pub(crate) fn expire_held(&self) {
335        let mut held = self
336            .held
337            .lock()
338            .unwrap_or_else(|poisoned| poisoned.into_inner());
339        held.expires_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1));
340    }
341}
342
343const IMDS_ENDPOINT: &str = "http://169.254.169.254";
344
345/// How long one IMDS round trip may take. Shorter than didbot-http's shared
346/// default: 169.254.169.254 is a link-local address on the same host, so a
347/// slow answer means IMDS is not there at all rather than a congested route.
348const IMDS_TIMEOUT: Duration = Duration::from_secs(5);
349
350/// The client IMDS is reached through.
351fn imds_client() -> reqwest::Result<reqwest::blocking::Client> {
352    didbot_http::blocking_builder()
353        .timeout(IMDS_TIMEOUT)
354        .build()
355}
356
357/// The client the Route53 API is reached through. Without a timeout, a
358/// stalled call would hang whichever DNS operation asked for it, which is
359/// why the fallback to `reqwest`'s own defaults says so at `warn` rather
360/// than leaving that hang with nothing to explain it.
361fn api_client() -> reqwest::blocking::Client {
362    didbot_http::blocking_builder()
363        .build()
364        .unwrap_or_else(|error| {
365            tracing::warn!(
366                %error,
367                "building the Route53 API client failed; falling back to reqwest's own \
368                 defaults, which set no deadline on a Route53 call"
369            );
370            reqwest::blocking::Client::default()
371        })
372}
373
374fn imds_request(request: reqwest::blocking::RequestBuilder) -> Result<String, ImdsError> {
375    let response = request
376        .send()
377        .map_err(|err| ImdsError::Request(err.to_string()))?
378        .error_for_status()
379        .map_err(|err| ImdsError::Request(err.to_string()))?;
380    let body = didbot_http::read_bounded_blocking(response, didbot_http::MAX_JSON_BODY)
381        .map_err(|err| ImdsError::Request(err.to_string()))?;
382    Ok(didbot_http::body_text(body))
383}
384
385/// What
386/// `GET /latest/meta-data/iam/security-credentials/<role>` answers with.
387/// `Code`, `LastUpdated` and `Type` are in the response and are not read.
388#[derive(serde::Deserialize)]
389struct ImdsCredentials {
390    #[serde(rename = "AccessKeyId")]
391    access_key_id: String,
392    #[serde(rename = "SecretAccessKey")]
393    secret_access_key: String,
394    #[serde(rename = "Token")]
395    token: Option<String>,
396    #[serde(rename = "Expiration")]
397    expiration: Option<String>,
398}
399
400/// Reads the credentials out of a
401/// `GET /latest/meta-data/iam/security-credentials/<role>` response.
402fn parse_imds_credentials(body: &str) -> Result<TimedCredentials, ImdsError> {
403    let parsed: ImdsCredentials = serde_json::from_str(body).map_err(|err| {
404        ImdsError::Response(format!("the credentials response does not read: {err}"))
405    })?;
406    let expires_at = match &parsed.expiration {
407        Some(stated) => Some(
408            time::OffsetDateTime::parse(stated, &time::format_description::well_known::Rfc3339)
409                .map_err(|err| {
410                    ImdsError::Response(format!("Expiration {stated:?} is not a timestamp: {err}"))
411                })?,
412        ),
413        None => None,
414    };
415    Ok(TimedCredentials {
416        credentials: Route53Credentials {
417            access_key_id: parsed.access_key_id,
418            secret_access_key: parsed.secret_access_key,
419            session_token: parsed.token,
420        },
421        expires_at,
422    })
423}
424
425/// How this provider retries a throttled or transiently failed call.
426#[derive(Debug, Clone, Copy)]
427pub struct RetryPolicy {
428    /// Total attempts, including the first. `1` disables retrying.
429    pub max_attempts: u32,
430    /// Delay before the first retry; doubles on every subsequent one.
431    pub base_delay: Duration,
432}
433
434impl Default for RetryPolicy {
435    fn default() -> Self {
436        Self {
437            max_attempts: 5,
438            base_delay: Duration::from_millis(200),
439        }
440    }
441}
442
443/// The production [`DnsProvider`]: writes and removes the `TXT` and `CAA`
444/// records in one Route53 hosted zone. See the module docs for the operating
445/// rules — propagation, retries, quotas and the credential's scope.
446pub struct Route53Dns {
447    zone: String,
448    hosted_zone_id: String,
449    transport: Box<dyn Transport>,
450    retry: RetryPolicy,
451    wait_for_propagation: bool,
452    propagation_timeout: Duration,
453    poll_interval: Duration,
454    /// The TTL on the `CAA` record this provider writes, matching the
455    /// address records beside it at the apex.
456    ttl: u32,
457    /// The TTL on the `TXT` records this provider writes. Short, because an
458    /// ACME DNS-01 challenge value is only needed for the length of one
459    /// validation and a short TTL keeps a withdrawn or replaced value from
460    /// lingering in downstream caches.
461    txt_ttl: u32,
462    txt: TxtRecords,
463    caa: CaaRecords,
464}
465
466impl Route53Dns {
467    /// A provider for `zone`, writing to hosted zone `hosted_zone_id`, over
468    /// the real network.
469    ///
470    /// Starts with empty local bookkeeping — the same starting state every
471    /// provider in this crate has. Every write this provider makes is an
472    /// `UPSERT` of the whole set it holds for a name, so a value the zone
473    /// carries from a previous process is replaced by the first publish at
474    /// that name rather than added to.
475    pub fn new(
476        zone: impl Into<String>,
477        hosted_zone_id: impl Into<String>,
478        credentials: std::sync::Arc<InstanceCredentials>,
479    ) -> Self {
480        Self::with_transport(
481            zone,
482            hosted_zone_id,
483            Box::new(HttpTransport {
484                client: api_client(),
485                endpoint: DEFAULT_ENDPOINT.to_string(),
486                credentials,
487            }),
488        )
489    }
490
491    pub(crate) fn with_transport(
492        zone: impl Into<String>,
493        hosted_zone_id: impl Into<String>,
494        transport: Box<dyn Transport>,
495    ) -> Self {
496        Self {
497            zone: zone.into(),
498            hosted_zone_id: hosted_zone_id.into(),
499            transport,
500            retry: RetryPolicy::default(),
501            wait_for_propagation: true,
502            propagation_timeout: Duration::from_secs(30),
503            poll_interval: Duration::from_millis(500),
504            ttl: 300,
505            txt_ttl: 60,
506            txt: TxtRecords::default(),
507            caa: CaaRecords::default(),
508        }
509    }
510
511    /// Overrides the retry policy for throttled or transient failures.
512    #[must_use]
513    pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
514        self.retry = retry;
515        self
516    }
517
518    /// Whether [`DnsProvider::publish_txt`] waits for the change to reach
519    /// `INSYNC` before returning. Default `true`; see the module docs for
520    /// why that is the honest default. Turning it off trades that guarantee
521    /// for lower publish latency.
522    #[must_use]
523    pub fn with_propagation_wait(mut self, wait: bool) -> Self {
524        self.wait_for_propagation = wait;
525        self
526    }
527
528    /// How long [`DnsProvider::publish_txt`] will wait for `INSYNC` before
529    /// giving up and returning [`DnsError::Backend`]. Default 30 seconds.
530    #[must_use]
531    pub fn with_propagation_timeout(mut self, timeout: Duration) -> Self {
532        self.propagation_timeout = timeout;
533        self
534    }
535
536    /// How long [`DnsProvider::publish_txt`] waits between `GetChange` polls
537    /// while a change is `PENDING`. Default 500 milliseconds.
538    ///
539    /// A Route53 change reaches `INSYNC` in tens of seconds, so the default
540    /// is sized for that rather than for how fast a caller can learn the
541    /// answer. Lowering it costs one API call per poll.
542    #[must_use]
543    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
544        self.poll_interval = interval;
545        self
546    }
547
548    /// Whether `host` is under this provider's zone.
549    ///
550    /// A deployment answers this zone with a single-label wildcard DNS
551    /// record (`*.<zone>`), and RFC 1034 wildcards match exactly one label.
552    /// So `host` must be the zone itself (the apex, for `CAA`) or exactly
553    /// one label below it (`_acme-challenge.<zone>`) — never two or more.
554    pub fn accepts(&self, host: &str) -> bool {
555        if !hostname_is_at_or_below(host, &self.zone) {
556            return false;
557        }
558        let zone_labels = self.zone.matches('.').count() + 1;
559        let host_labels = host.matches('.').count() + 1;
560        host_labels <= zone_labels + 1
561    }
562
563    fn check(&self, host: &str) -> Result<(), DnsError> {
564        if self.accepts(host) {
565            Ok(())
566        } else {
567            Err(DnsError::NotUnderZone {
568                host: host.to_owned(),
569            })
570        }
571    }
572
573    fn backend_error(&self, host: &str, message: String) -> DnsError {
574        DnsError::Backend {
575            host: host.to_string(),
576            message,
577        }
578    }
579
580    /// Issues one API call, retrying on throttling or a server error per
581    /// [`Self::retry`].
582    fn call_with_retry(
583        &self,
584        method: &str,
585        path: &str,
586        query: &[(String, String)],
587        body: String,
588        host_for_errors: &str,
589    ) -> Result<String, DnsError> {
590        let mut attempt = 0;
591        let mut delay = self.retry.base_delay;
592        loop {
593            attempt += 1;
594            match self.transport.call(method, path, query, &body) {
595                Ok(response) if response.status < 300 => return Ok(response.body),
596                Ok(response) => {
597                    let error = xml::parse_error(&response.body);
598                    let retryable = response.status == 500
599                        || response.status == 503
600                        || error.as_ref().is_some_and(|e| {
601                            e.code == "Throttling" || e.code == "PriorRequestNotComplete"
602                        });
603                    if retryable && attempt < self.retry.max_attempts {
604                        thread::sleep(delay);
605                        delay *= 2;
606                        continue;
607                    }
608                    let message = error
609                        .map(|e| format!("{}: {}", e.code, e.message))
610                        .unwrap_or_else(|| format!("http {}", response.status));
611                    return Err(self.classify(host_for_errors, response.status, &message));
612                }
613                Err(message) => {
614                    if attempt < self.retry.max_attempts {
615                        thread::sleep(delay);
616                        delay *= 2;
617                        continue;
618                    }
619                    return Err(self.backend_error(host_for_errors, message));
620                }
621            }
622        }
623    }
624
625    /// Maps a non-retried API error to the right [`DnsError`] variant.
626    fn classify(&self, host: &str, status: u16, message: &str) -> DnsError {
627        let lower = message.to_ascii_lowercase();
628        if status == 400 && (lower.contains("not found") || lower.contains("not match")) {
629            DnsError::NotPublished {
630                host: host.to_string(),
631            }
632        } else {
633            self.backend_error(host, message.to_string())
634        }
635    }
636
637    /// Polls `GetChange` until `change_id` reaches `INSYNC`, or gives up
638    /// after [`Self::propagation_timeout`].
639    fn await_propagation(&self, change_id: &str) -> Result<(), DnsError> {
640        let deadline = Instant::now() + self.propagation_timeout;
641        loop {
642            let path = format!("/{API_VERSION}/change/{change_id}");
643            let body = self.call_with_retry("GET", &path, &[], String::new(), change_id)?;
644            match xml::parse_status(&body) {
645                Some(status) if status == "INSYNC" => return Ok(()),
646                _ if Instant::now() >= deadline => {
647                    return Err(self.backend_error(
648                        change_id,
649                        format!(
650                            "change did not reach INSYNC within {:?}",
651                            self.propagation_timeout
652                        ),
653                    ))
654                }
655                _ => thread::sleep(self.poll_interval),
656            }
657        }
658    }
659
660    fn submit_change(
661        &self,
662        host: &str,
663        action: &str,
664        rtype: &str,
665        ttl: u32,
666        values: &[String],
667    ) -> Result<String, DnsError> {
668        let body = xml::change_request("didbot", action, host, rtype, ttl, values)
669            .map_err(|err| self.backend_error(host, format!("cannot build the change: {err}")))?;
670        let path = format!("/{API_VERSION}/hostedzone/{}/rrset", self.hosted_zone_id);
671        let response = self.call_with_retry("POST", &path, &[], body, host)?;
672        xml::parse_change_id(&response).ok_or_else(|| {
673            self.backend_error(
674                host,
675                "change accepted but no change id in response".to_string(),
676            )
677        })
678    }
679}
680
681impl std::fmt::Debug for Route53Dns {
682    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
683        f.debug_struct("Route53Dns")
684            .field("zone", &self.zone)
685            .field("hosted_zone_id", &self.hosted_zone_id)
686            .finish_non_exhaustive()
687    }
688}
689
690impl DnsProvider for Route53Dns {
691    fn name(&self) -> &str {
692        "route53"
693    }
694
695    fn publish_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
696        self.check(host)?;
697        let before = self.txt.values(host);
698        if before.contains(&value.to_string()) {
699            return Ok(());
700        }
701        self.txt.insert(host, value);
702        let after: BTreeSet<String> = self.txt.values(host).into_iter().collect();
703        let values: Vec<String> = after.iter().map(|v| xml::quote_txt(v)).collect();
704
705        // The caller's next step is to tell a certificate authority to look
706        // for this value, so the change has to be visible before this
707        // returns; see the module docs.
708        let outcome = self
709            .submit_change(host, "UPSERT", "TXT", self.txt_ttl, &values)
710            .and_then(|change_id| {
711                if self.wait_for_propagation {
712                    self.await_propagation(&change_id)
713                } else {
714                    Ok(())
715                }
716            });
717        if let Err(error) = outcome {
718            self.txt.remove(host, value).ok();
719            return Err(error);
720        }
721        tracing::info!(
722            provider = self.name(),
723            host = host,
724            value = value,
725            "published txt value"
726        );
727        Ok(())
728    }
729
730    fn withdraw_txt(&self, host: &str, value: &str) -> Result<(), DnsError> {
731        self.check(host)?;
732        let before: Vec<String> = self.txt.values(host);
733        if !before.iter().any(|v| v == value) {
734            return Err(DnsError::NotPublished {
735                host: host.to_string(),
736            });
737        }
738        self.txt.remove(host, value)?;
739        let after = self.txt.values(host);
740
741        let outcome = if after.is_empty() {
742            // Nothing left: the whole record set must be deleted, and a
743            // DELETE must name the exact values Route53 currently has,
744            // which is `before`.
745            let quoted: Vec<String> = before.iter().map(|v| xml::quote_txt(v)).collect();
746            self.submit_change(host, "DELETE", "TXT", self.txt_ttl, &quoted)
747        } else {
748            let quoted: Vec<String> = after.iter().map(|v| xml::quote_txt(v)).collect();
749            self.submit_change(host, "UPSERT", "TXT", self.txt_ttl, &quoted)
750        };
751
752        if let Err(error) = outcome {
753            self.txt.insert(host, value);
754            return Err(error);
755        }
756        tracing::info!(
757            provider = self.name(),
758            host = host,
759            value = value,
760            "withdrew txt value"
761        );
762        Ok(())
763    }
764
765    fn txt_values(&self, host: &str) -> Vec<String> {
766        self.txt.values(host)
767    }
768
769    fn publish_caa(&self, host: &str, values: &[CaaValue]) -> Result<(), DnsError> {
770        self.check(host)?;
771        let before = self.caa.values(host);
772        if !self.caa.replace(host, values) {
773            // Byte-for-byte what this zone already says. A restart restating
774            // its own policy is the common case and must not spend a write
775            // against Route53's change quota.
776            return Ok(());
777        }
778        let after = self.caa.values(host);
779
780        let outcome = if after.is_empty() {
781            // A DELETE must name the exact values Route53 currently holds,
782            // which is `before` — the same rule `withdraw_txt` follows.
783            let wire: Vec<String> = before.iter().map(CaaValue::to_wire).collect();
784            self.submit_change(host, "DELETE", "CAA", self.ttl, &wire)
785        } else {
786            let wire: Vec<String> = after.iter().map(CaaValue::to_wire).collect();
787            self.submit_change(host, "UPSERT", "CAA", self.ttl, &wire)
788        };
789
790        if let Err(error) = outcome {
791            self.caa.replace(host, &before);
792            return Err(error);
793        }
794        crate::log_caa(self.name(), host, &after, "published caa record set");
795        Ok(())
796    }
797
798    fn caa_values(&self, host: &str) -> Vec<CaaValue> {
799        self.caa.values(host)
800    }
801}
802
803/// One HTTP response, isolated from the `reqwest` type so tests can build
804/// one without a client.
805pub(crate) struct Response {
806    pub(crate) status: u16,
807    pub(crate) body: String,
808}
809
810/// The network boundary this provider drives, isolated so tests can replace
811/// it with something that records calls and returns canned XML instead of
812/// reaching the real Route53 API — which there is no AWS account to test
813/// against in this environment. `HttpTransport` is the only real
814/// implementation. The tests in `sigv4` check each step of the signature it
815/// sends against AWS's published signing test suite.
816pub(crate) trait Transport: Send + Sync {
817    fn call(
818        &self,
819        method: &str,
820        path: &str,
821        query: &[(String, String)],
822        body: &str,
823    ) -> Result<Response, String>;
824}
825
826struct HttpTransport {
827    client: reqwest::blocking::Client,
828    endpoint: String,
829    credentials: std::sync::Arc<InstanceCredentials>,
830}
831
832impl Transport for HttpTransport {
833    fn call(
834        &self,
835        method: &str,
836        path: &str,
837        query: &[(String, String)],
838        body: &str,
839    ) -> Result<Response, String> {
840        // Per call, not per provider: an instance profile's credential
841        // expires, and this is where the holder notices.
842        let credentials = self.credentials.current().map_err(|err| err.to_string())?;
843        let now = time::OffsetDateTime::now_utc();
844        // One value for both the signed `host` header and the sent one: a
845        // signature over a host the request does not carry cannot verify.
846        let host = sigv4::host_header(&self.endpoint);
847        let request = sigv4::Request {
848            method,
849            host: &host,
850            path,
851            query,
852            body,
853        };
854        let signed = sigv4::sign(&credentials, sigv4::ROUTE53, &request, now);
855
856        let mut url = format!("{}{}", self.endpoint, path);
857        if !query.is_empty() {
858            url.push('?');
859            url.push_str(&sigv4::canonical_query(query));
860        }
861
862        let method = match method {
863            "GET" => reqwest::Method::GET,
864            "POST" => reqwest::Method::POST,
865            other => return Err(format!("unsupported method {other}")),
866        };
867
868        let mut request = self
869            .client
870            .request(method, &url)
871            .header("host", host)
872            .header("x-amz-date", signed.amz_date.clone())
873            .header("authorization", signed.authorization)
874            .header("content-type", "text/xml; charset=utf-8");
875        if let Some(token) = &credentials.session_token {
876            request = request.header("x-amz-security-token", token.clone());
877        }
878        if !body.is_empty() {
879            request = request.body(body.to_string());
880        }
881
882        let response = request.send().map_err(|error| error.to_string())?;
883        let status = response.status().as_u16();
884        let body = didbot_http::read_bounded_blocking(response, didbot_http::MAX_JSON_BODY)
885            .map_err(|error| error.to_string())?;
886        Ok(Response {
887            status,
888            body: didbot_http::body_text(body),
889        })
890    }
891}
892
893mod sigv4;
894
895/// The handful of request bodies and response shapes this provider needs.
896///
897/// Route53's XML surface is large; this reads and writes only the elements
898/// listed below; a response element this module does not recognise is
899/// ignored rather than rejected, which is deliberate — Route53 is free to
900/// add fields to a response without breaking a client that never looked for
901/// them. This is a purpose-built extractor for those specific shapes, not a
902/// conformant general XML parser: it does not handle attributes, namespaces,
903/// CDATA or non-well-formed input, none of which Route53's own responses use
904/// in the elements this module reads.
905mod xml {
906    use serde::{Deserialize, Serialize};
907
908    /// One `ChangeResourceRecordSets` request body.
909    #[derive(Serialize)]
910    #[serde(rename = "ChangeResourceRecordSetsRequest")]
911    struct ChangeRequest {
912        #[serde(rename = "@xmlns")]
913        xmlns: String,
914        #[serde(rename = "ChangeBatch")]
915        batch: ChangeBatch,
916    }
917
918    #[derive(Serialize)]
919    struct ChangeBatch {
920        #[serde(rename = "Comment")]
921        comment: String,
922        #[serde(rename = "Changes")]
923        changes: Changes,
924    }
925
926    #[derive(Serialize)]
927    struct Changes {
928        #[serde(rename = "Change")]
929        change: Vec<Change>,
930    }
931
932    #[derive(Serialize)]
933    struct Change {
934        #[serde(rename = "Action")]
935        action: String,
936        #[serde(rename = "ResourceRecordSet")]
937        record_set: ResourceRecordSet,
938    }
939
940    #[derive(Serialize)]
941    struct ResourceRecordSet {
942        #[serde(rename = "Name")]
943        name: String,
944        #[serde(rename = "Type")]
945        rtype: String,
946        #[serde(rename = "TTL")]
947        ttl: u32,
948        #[serde(rename = "ResourceRecords")]
949        records: ResourceRecords,
950    }
951
952    #[derive(Serialize)]
953    struct ResourceRecords {
954        #[serde(rename = "ResourceRecord")]
955        record: Vec<ResourceRecord>,
956    }
957
958    #[derive(Serialize)]
959    struct ResourceRecord {
960        #[serde(rename = "Value")]
961        value: String,
962    }
963
964    /// The body of a one-change `ChangeResourceRecordSets` call.
965    pub(super) fn change_request(
966        comment: &str,
967        action: &str,
968        host: &str,
969        rtype: &str,
970        ttl: u32,
971        values: &[String],
972    ) -> Result<String, quick_xml::SeError> {
973        let request = ChangeRequest {
974            xmlns: format!("https://route53.amazonaws.com/doc/{}/", super::API_VERSION),
975            batch: ChangeBatch {
976                comment: comment.to_string(),
977                changes: Changes {
978                    change: vec![Change {
979                        action: action.to_string(),
980                        record_set: ResourceRecordSet {
981                            name: host.to_string(),
982                            rtype: rtype.to_string(),
983                            ttl,
984                            records: ResourceRecords {
985                                record: values
986                                    .iter()
987                                    .map(|value| ResourceRecord {
988                                        value: value.clone(),
989                                    })
990                                    .collect(),
991                            },
992                        },
993                    }],
994                },
995            },
996        };
997        quick_xml::se::to_string(&request)
998    }
999
1000    /// Wraps a TXT value in the quoted, backslash-escaped form the DNS
1001    /// presentation format requires for a `TXT` `ResourceRecord` value.
1002    pub(super) fn quote_txt(value: &str) -> String {
1003        let mut out = String::with_capacity(value.len() + 2);
1004        out.push('"');
1005        for c in value.chars() {
1006            if c == '"' || c == '\\' {
1007                out.push('\\');
1008            }
1009            out.push(c);
1010        }
1011        out.push('"');
1012        out
1013    }
1014
1015    /// What `ChangeResourceRecordSets` and `GetChange` both answer with.
1016    #[derive(Deserialize)]
1017    struct ChangeInfoResponse {
1018        #[serde(rename = "ChangeInfo")]
1019        change_info: ChangeInfo,
1020    }
1021
1022    #[derive(Deserialize)]
1023    struct ChangeInfo {
1024        #[serde(rename = "Id")]
1025        id: String,
1026        #[serde(rename = "Status")]
1027        status: String,
1028    }
1029
1030    #[derive(Deserialize)]
1031    struct ErrorResponse {
1032        #[serde(rename = "Error")]
1033        error: ApiError,
1034    }
1035
1036    /// What an API error says about itself. `Message` is optional because a
1037    /// few of Route53's errors carry only a code.
1038    #[derive(Deserialize)]
1039    pub(super) struct ApiError {
1040        #[serde(rename = "Code")]
1041        pub(super) code: String,
1042        #[serde(rename = "Message", default)]
1043        pub(super) message: String,
1044    }
1045
1046    pub(super) fn parse_change_id(xml: &str) -> Option<String> {
1047        let response: ChangeInfoResponse = quick_xml::de::from_str(xml).ok()?;
1048        Some(
1049            response
1050                .change_info
1051                .id
1052                .trim_start_matches("/change/")
1053                .to_string(),
1054        )
1055    }
1056
1057    pub(super) fn parse_status(xml: &str) -> Option<String> {
1058        let response: ChangeInfoResponse = quick_xml::de::from_str(xml).ok()?;
1059        Some(response.change_info.status)
1060    }
1061
1062    pub(super) fn parse_error(xml: &str) -> Option<ApiError> {
1063        let response: ErrorResponse = quick_xml::de::from_str(xml).ok()?;
1064        Some(response.error)
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    /// The host [`DEFAULT_ENDPOINT`] sends, and so the host a signature
1073    /// against it covers.
1074    const DEFAULT_HOST: &str = "route53.amazonaws.com";
1075    use std::sync::Mutex;
1076
1077    /// Records every call it receives and answers from a scripted list of
1078    /// responses, so the provider's request-building, retry and parsing
1079    /// logic is tested without a network or an AWS account — which this
1080    /// environment has neither of.
1081    struct FakeTransport {
1082        responses: Mutex<Vec<Result<Response, String>>>,
1083        calls: Mutex<Vec<(String, String, String)>>, // method, path, body
1084    }
1085
1086    impl FakeTransport {
1087        fn new(responses: Vec<Result<Response, String>>) -> Self {
1088            Self {
1089                responses: Mutex::new(responses),
1090                calls: Mutex::new(Vec::new()),
1091            }
1092        }
1093
1094        fn calls(&self) -> Vec<(String, String, String)> {
1095            self.calls.lock().unwrap().clone()
1096        }
1097    }
1098
1099    impl Transport for FakeTransport {
1100        fn call(
1101            &self,
1102            method: &str,
1103            path: &str,
1104            _query: &[(String, String)],
1105            body: &str,
1106        ) -> Result<Response, String> {
1107            self.calls.lock().unwrap().push((
1108                method.to_string(),
1109                path.to_string(),
1110                body.to_string(),
1111            ));
1112            let mut responses = self.responses.lock().unwrap();
1113            if responses.is_empty() {
1114                // GetChange polling loop after the first canned response ran
1115                // out: keep reporting INSYNC so a test that does not script
1116                // every poll does not hang.
1117                return Ok(Response {
1118                    status: 200,
1119                    body: "<GetChangeResponse><ChangeInfo><Id>/change/C1</Id><Status>INSYNC</Status></ChangeInfo></GetChangeResponse>".to_string(),
1120                });
1121            }
1122            responses.remove(0)
1123        }
1124    }
1125
1126    // Lets a test keep a handle to the fake (to inspect `calls()`) while
1127    // still handing `Route53Dns` an owned `Box<dyn Transport>`.
1128    impl Transport for std::sync::Arc<FakeTransport> {
1129        fn call(
1130            &self,
1131            method: &str,
1132            path: &str,
1133            query: &[(String, String)],
1134            body: &str,
1135        ) -> Result<Response, String> {
1136            (**self).call(method, path, query, body)
1137        }
1138    }
1139
1140    fn ok(body: &str) -> Result<Response, String> {
1141        Ok(Response {
1142            status: 200,
1143            body: body.to_string(),
1144        })
1145    }
1146
1147    fn change_response() -> Result<Response, String> {
1148        ok("<ChangeResourceRecordSetsResponse><ChangeInfo><Id>/change/C1</Id><Status>PENDING</Status></ChangeInfo></ChangeResourceRecordSetsResponse>")
1149    }
1150
1151    fn insync_response() -> Result<Response, String> {
1152        ok("<GetChangeResponse><ChangeInfo><Id>/change/C1</Id><Status>INSYNC</Status></ChangeInfo></GetChangeResponse>")
1153    }
1154
1155    fn provider(responses: Vec<Result<Response, String>>) -> Route53Dns {
1156        let transport = Box::new(FakeTransport::new(responses));
1157        Route53Dns::with_transport("agents.example.com", "Z123", transport)
1158            .with_propagation_timeout(Duration::from_millis(200))
1159    }
1160
1161    #[test]
1162    fn a_txt_publish_waits_for_insync_before_returning() {
1163        let fake = std::sync::Arc::new(FakeTransport::new(vec![
1164            change_response(),
1165            insync_response(),
1166        ]));
1167        let dns = Route53Dns::with_transport("agents.example.com", "Z123", Box::new(fake.clone()))
1168            .with_propagation_timeout(Duration::from_millis(200));
1169        assert!(dns
1170            .publish_txt("_acme-challenge.agents.example.com", "token")
1171            .is_ok());
1172        // The UPSERT, then the GetChange that answered INSYNC.
1173        let calls = fake.calls();
1174        assert_eq!(calls.len(), 2, "{calls:?}");
1175        assert!(calls[1].1.contains("/change/"), "{calls:?}");
1176    }
1177
1178    #[test]
1179    fn a_txt_withdrawal_does_not_wait_for_propagation() {
1180        // Exactly two scripted responses, the UPSERT's and the DELETE's: if
1181        // the withdrawal also polled GetChange the way a publish does, the
1182        // fake would run out of responses and fall back to a canned INSYNC
1183        // body this test never asserts on -- so assert on the call count.
1184        let fake = std::sync::Arc::new(FakeTransport::new(vec![
1185            change_response(),
1186            change_response(),
1187        ]));
1188        let dns = Route53Dns::with_transport("agents.example.com", "Z123", Box::new(fake.clone()))
1189            .with_propagation_wait(false);
1190        let name = "_acme-challenge.agents.example.com";
1191        assert!(dns.publish_txt(name, "token").is_ok());
1192        assert!(dns.withdraw_txt(name, "token").is_ok());
1193        assert_eq!(fake.calls().len(), 2);
1194    }
1195
1196    #[test]
1197    fn a_txt_publish_refuses_a_host_outside_the_zone() {
1198        // No transport call is queued, so this would also panic on an
1199        // unexpected HTTP call if `accepts` failed to refuse before the
1200        // network was reached.
1201        let dns = provider(vec![]);
1202        assert_eq!(
1203            dns.publish_txt("_acme-challenge.other.com", "token"),
1204            Err(DnsError::NotUnderZone {
1205                host: "_acme-challenge.other.com".to_string()
1206            })
1207        );
1208        assert_eq!(
1209            dns.publish_txt("_acme-challenge.deep.agents.example.com", "token"),
1210            Err(DnsError::NotUnderZone {
1211                host: "_acme-challenge.deep.agents.example.com".to_string()
1212            })
1213        );
1214    }
1215
1216    #[test]
1217    fn caa_is_upserted_as_one_record_set_at_the_apex() {
1218        let fake = std::sync::Arc::new(FakeTransport::new(vec![change_response()]));
1219        let dns = Route53Dns::with_transport("agents.example.com", "Z123", Box::new(fake.clone()));
1220        assert!(dns
1221            .publish_caa(
1222                "agents.example.com",
1223                &[
1224                    CaaValue::issue_for_account("letsencrypt.org", "https://acme.example/acct/1"),
1225                    CaaValue::iodef("mailto:ops@example.com"),
1226                ],
1227            )
1228            .is_ok());
1229
1230        let (_, _, body) = fake.calls()[0].clone();
1231        assert!(body.contains("<Action>UPSERT</Action>"), "{body}");
1232        assert!(body.contains("<Type>CAA</Type>"), "{body}");
1233        assert!(
1234            body.contains("0 issue \"letsencrypt.org; accounturi=https://acme.example/acct/1\""),
1235            "{body}"
1236        );
1237        assert!(
1238            body.contains("0 iodef \"mailto:ops@example.com\""),
1239            "{body}"
1240        );
1241    }
1242
1243    #[test]
1244    fn restating_the_same_caa_set_spends_no_change() {
1245        let fake = std::sync::Arc::new(FakeTransport::new(vec![change_response()]));
1246        let dns = Route53Dns::with_transport("agents.example.com", "Z123", Box::new(fake.clone()));
1247        let values = [CaaValue::issue("letsencrypt.org")];
1248        assert!(dns.publish_caa("agents.example.com", &values).is_ok());
1249        // Every start restates the policy; only the first one is a write.
1250        assert!(dns.publish_caa("agents.example.com", &values).is_ok());
1251        assert_eq!(fake.calls().len(), 1);
1252    }
1253
1254    #[test]
1255    fn txt_publish_is_additive_and_uses_upsert() {
1256        let fake = std::sync::Arc::new(FakeTransport::new(vec![
1257            change_response(),
1258            insync_response(),
1259            change_response(),
1260            insync_response(),
1261        ]));
1262        let dns = Route53Dns::with_transport("agents.example.com", "Z123", Box::new(fake.clone()))
1263            .with_propagation_timeout(Duration::from_millis(200));
1264        let name = "_acme-challenge.agents.example.com";
1265        assert!(dns.publish_txt(name, "token-a").is_ok());
1266        assert!(dns.publish_txt(name, "token-b").is_ok());
1267        for (_, _, body) in fake
1268            .calls()
1269            .iter()
1270            .filter(|(_, path, _)| path.ends_with("/rrset"))
1271        {
1272            assert!(body.contains("<Action>UPSERT</Action>"), "{body}");
1273        }
1274        // The second write carries both values, not just the new one.
1275        let (_, _, body) = fake.calls()[2].clone();
1276        assert!(
1277            body.contains("token-a") && body.contains("token-b"),
1278            "{body}"
1279        );
1280        assert_eq!(dns.txt_values(name), vec!["token-a", "token-b"]);
1281    }
1282
1283    #[test]
1284    fn txt_publish_same_value_twice_makes_no_second_call() {
1285        let dns = provider(vec![change_response()]);
1286        let name = "_acme-challenge.agents.example.com";
1287        assert!(dns.publish_txt(name, "token-a").is_ok());
1288        assert!(dns.publish_txt(name, "token-a").is_ok());
1289        assert_eq!(dns.txt_values(name), vec!["token-a"]);
1290    }
1291
1292    #[test]
1293    fn withdrawing_last_txt_value_issues_a_delete() {
1294        let transport = Box::new(FakeTransport::new(vec![
1295            change_response(),
1296            change_response(),
1297        ]));
1298        let dns = Route53Dns::with_transport("agents.example.com", "Z123", transport);
1299        let name = "_acme-challenge.agents.example.com";
1300        assert!(dns.publish_txt(name, "only").is_ok());
1301        assert!(dns.withdraw_txt(name, "only").is_ok());
1302        assert!(dns.txt_values(name).is_empty());
1303    }
1304
1305    #[test]
1306    fn retries_on_throttling_then_succeeds() {
1307        let throttled = Ok(Response {
1308            status: 400,
1309            body: "<ErrorResponse><Error><Code>Throttling</Code><Message>Rate exceeded</Message></Error></ErrorResponse>".to_string(),
1310        });
1311        let transport = Box::new(FakeTransport::new(vec![
1312            throttled,
1313            change_response(),
1314            insync_response(),
1315        ]));
1316        let dns = Route53Dns::with_transport("agents.example.com", "Z123", transport)
1317            .with_retry_policy(RetryPolicy {
1318                max_attempts: 3,
1319                base_delay: Duration::from_millis(0),
1320            });
1321        assert!(dns
1322            .publish_txt("_acme-challenge.agents.example.com", "token")
1323            .is_ok());
1324    }
1325
1326    #[test]
1327    fn exhausting_retries_on_throttling_is_a_backend_error() {
1328        let throttled = || {
1329            Ok(Response {
1330                status: 400,
1331                body: "<ErrorResponse><Error><Code>Throttling</Code><Message>Rate exceeded</Message></Error></ErrorResponse>".to_string(),
1332            })
1333        };
1334        let transport = Box::new(FakeTransport::new(vec![
1335            throttled(),
1336            throttled(),
1337            throttled(),
1338        ]));
1339        let dns = Route53Dns::with_transport("agents.example.com", "Z123", transport)
1340            .with_retry_policy(RetryPolicy {
1341                max_attempts: 3,
1342                base_delay: Duration::from_millis(0),
1343            });
1344        assert!(matches!(
1345            dns.publish_txt("_acme-challenge.agents.example.com", "token"),
1346            Err(DnsError::Backend { .. })
1347        ));
1348        // The failed publish must not linger as a value this process
1349        // believes is live.
1350        assert!(dns
1351            .txt_values("_acme-challenge.agents.example.com")
1352            .is_empty());
1353    }
1354
1355    #[test]
1356    fn quote_txt_escapes_quotes_and_backslashes() {
1357        assert_eq!(xml::quote_txt("plain"), "\"plain\"");
1358        assert_eq!(xml::quote_txt("a\"b"), "\"a\\\"b\"");
1359        assert_eq!(xml::quote_txt("a\\b"), "\"a\\\\b\"");
1360    }
1361
1362    #[test]
1363    fn parses_change_id_and_status() {
1364        let body = change_response().unwrap().body;
1365        assert_eq!(xml::parse_change_id(&body).as_deref(), Some("C1"));
1366        let body = insync_response().unwrap().body;
1367        assert_eq!(xml::parse_status(&body).as_deref(), Some("INSYNC"));
1368    }
1369
1370    /// The `Authorization` header of a Route53 `GET` of `path` to `host`.
1371    fn route53_get(credentials: &Route53Credentials, host: &str, path: &str) -> String {
1372        let request = sigv4::Request {
1373            method: "GET",
1374            host,
1375            path,
1376            query: &[],
1377            body: "",
1378        };
1379        let now = time::macros::datetime!(2015-08-30 12:36:00 UTC);
1380        sigv4::sign(credentials, sigv4::ROUTE53, &request, now).authorization
1381    }
1382
1383    #[test]
1384    fn a_custom_endpoint_signs_the_host_it_sends() {
1385        let credentials = Route53Credentials {
1386            access_key_id: "AKIDEXAMPLE".to_string(),
1387            secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
1388            session_token: None,
1389        };
1390
1391        // The endpoint a test double or a private link would be pointed at.
1392        let endpoint = "https://route53.us-gov.amazonaws.com/";
1393        let host = sigv4::host_header(endpoint);
1394        assert_eq!(host, "route53.us-gov.amazonaws.com");
1395
1396        let elsewhere = route53_get(&credentials, &host, "/");
1397        let default = route53_get(&credentials, DEFAULT_HOST, "/");
1398
1399        // The host is signed, so two endpoints cannot share one signature.
1400        // Equal signatures here would mean the host was not covered at all.
1401        assert_ne!(elsewhere, default);
1402        assert_eq!(sigv4::host_header(DEFAULT_ENDPOINT), DEFAULT_HOST);
1403    }
1404
1405    #[test]
1406    fn parses_imds_security_credentials() {
1407        // A representative
1408        // `GET .../iam/security-credentials/<role>` response body; field
1409        // order and the extra fields this module does not read
1410        // (`Code`, `LastUpdated`, `Type`) match a real one.
1411        let body = r#"{
1412            "Code" : "Success",
1413            "LastUpdated" : "2024-01-01T00:00:00Z",
1414            "Type" : "AWS-HMAC",
1415            "AccessKeyId" : "ASIAEXAMPLE",
1416            "SecretAccessKey" : "secretexamplevalue",
1417            "Token" : "tokenexamplevalue",
1418            "Expiration" : "2024-01-01T06:00:00Z"
1419        }"#;
1420        let timed = parse_imds_credentials(body).expect("parses");
1421        assert_eq!(timed.credentials.access_key_id, "ASIAEXAMPLE");
1422        assert_eq!(timed.credentials.secret_access_key, "secretexamplevalue");
1423        assert_eq!(
1424            timed.credentials.session_token.as_deref(),
1425            Some("tokenexamplevalue")
1426        );
1427        assert_eq!(
1428            timed.expires_at,
1429            Some(time::macros::datetime!(2024-01-01 06:00:00 UTC))
1430        );
1431    }
1432
1433    #[test]
1434    fn imds_credentials_with_an_unreadable_expiration_are_a_response_error() {
1435        // Signing with a credential whose end this cannot read would mean
1436        // treating it as endless, which is the failure this holder exists to
1437        // stop.
1438        let body = r#"{"AccessKeyId":"A","SecretAccessKey":"s","Expiration":"six hours"}"#;
1439        assert!(matches!(
1440            parse_imds_credentials(body),
1441            Err(ImdsError::Response(_))
1442        ));
1443    }
1444
1445    #[test]
1446    fn imds_credentials_without_a_token_field_still_parse() {
1447        let body = r#"{"AccessKeyId":"AKIAEXAMPLE","SecretAccessKey":"secret"}"#;
1448        let timed = parse_imds_credentials(body).expect("parses");
1449        assert_eq!(timed.credentials.access_key_id, "AKIAEXAMPLE");
1450        assert_eq!(timed.credentials.session_token, None);
1451        assert_eq!(timed.expires_at, None);
1452    }
1453
1454    #[test]
1455    fn imds_credentials_missing_a_required_field_is_a_response_error() {
1456        let body = r#"{"Code":"Success"}"#;
1457        assert!(matches!(
1458            parse_imds_credentials(body),
1459            Err(ImdsError::Response(_))
1460        ));
1461    }
1462
1463    /// Answers [`InstanceCredentials`] from a scripted list and counts what
1464    /// it was asked, so a test can drive a credential through its expiry
1465    /// without an instance metadata service — which this environment, like
1466    /// the AWS account behind `Transport`, does not have. The last answer
1467    /// repeats once the list runs out.
1468    struct FakeImds {
1469        answers: Mutex<Vec<Result<TimedCredentials, String>>>,
1470        reads: std::sync::atomic::AtomicUsize,
1471        /// Long enough for other threads to reach `current()` while a
1472        /// refresh is in flight.
1473        delay: Duration,
1474    }
1475
1476    impl FakeImds {
1477        fn new(answers: Vec<Result<TimedCredentials, String>>) -> std::sync::Arc<Self> {
1478            std::sync::Arc::new(Self {
1479                answers: Mutex::new(answers),
1480                reads: std::sync::atomic::AtomicUsize::new(0),
1481                delay: Duration::ZERO,
1482            })
1483        }
1484
1485        fn with_delay(
1486            answers: Vec<Result<TimedCredentials, String>>,
1487            delay: Duration,
1488        ) -> std::sync::Arc<Self> {
1489            std::sync::Arc::new(Self {
1490                answers: Mutex::new(answers),
1491                reads: std::sync::atomic::AtomicUsize::new(0),
1492                delay,
1493            })
1494        }
1495
1496        fn reads(&self) -> usize {
1497            self.reads.load(std::sync::atomic::Ordering::SeqCst)
1498        }
1499    }
1500
1501    impl CredentialFetch for FakeImds {
1502        fn fetch(&self) -> Result<TimedCredentials, ImdsError> {
1503            let read = self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1504            if !self.delay.is_zero() {
1505                thread::sleep(self.delay);
1506            }
1507            let answers = self.answers.lock().unwrap();
1508            let answer = answers[read.min(answers.len() - 1)].clone();
1509            answer.map_err(ImdsError::Request)
1510        }
1511    }
1512
1513    impl CredentialFetch for std::sync::Arc<FakeImds> {
1514        fn fetch(&self) -> Result<TimedCredentials, ImdsError> {
1515            (**self).fetch()
1516        }
1517    }
1518
1519    /// A credential whose secret is `secret`, valid for `valid_for` from
1520    /// now; a negative `valid_for` is one that has already expired.
1521    fn expiring(secret: &str, valid_for: time::Duration) -> Result<TimedCredentials, String> {
1522        Ok(TimedCredentials {
1523            credentials: Route53Credentials {
1524                access_key_id: format!("ASIA{}", secret.to_uppercase()),
1525                secret_access_key: secret.to_string(),
1526                session_token: Some(format!("{secret}-token")),
1527            },
1528            expires_at: Some(time::OffsetDateTime::now_utc() + valid_for),
1529        })
1530    }
1531
1532    fn holder(fetch: &std::sync::Arc<FakeImds>) -> InstanceCredentials {
1533        InstanceCredentials::with_fetch(Box::new(fetch.clone())).expect("the first read answers")
1534    }
1535
1536    #[test]
1537    fn holds_one_credential_while_its_expiry_is_further_off_than_the_margin() {
1538        let imds = FakeImds::new(vec![
1539            expiring("first", time::Duration::hours(6)),
1540            expiring("second", time::Duration::hours(6)),
1541        ]);
1542        let credentials = holder(&imds);
1543
1544        for _ in 0..3 {
1545            assert_eq!(
1546                credentials.current().expect("held").secret_access_key,
1547                "first"
1548            );
1549        }
1550        assert_eq!(imds.reads(), 1, "one read at construction and no more");
1551    }
1552
1553    #[test]
1554    fn refreshes_inside_the_margin_before_the_credential_expires() {
1555        let imds = FakeImds::new(vec![
1556            expiring(
1557                "first",
1558                REFRESH_MARGIN.try_into().expect("a positive margin"),
1559            ),
1560            expiring("second", time::Duration::hours(6)),
1561        ]);
1562        let credentials = holder(&imds);
1563
1564        assert_eq!(
1565            credentials.current().expect("refreshed").secret_access_key,
1566            "second",
1567            "a credential inside the margin is replaced before it expires, not after"
1568        );
1569        assert_eq!(imds.reads(), 2);
1570        assert_eq!(
1571            credentials.current().expect("held").secret_access_key,
1572            "second",
1573            "and the replacement is then held"
1574        );
1575        assert_eq!(imds.reads(), 2);
1576    }
1577
1578    #[test]
1579    fn a_signature_made_after_expiry_carries_the_refreshed_secret() {
1580        let expired = expiring("first", time::Duration::seconds(-10));
1581        let fresh = expiring("second", time::Duration::hours(6));
1582        let imds = FakeImds::new(vec![expired.clone(), fresh.clone()]);
1583        let credentials = holder(&imds);
1584
1585        let path = "/2013-04-01/hostedzone/Z1/rrset";
1586        let signed = route53_get(
1587            &credentials.current().expect("refreshed"),
1588            DEFAULT_HOST,
1589            path,
1590        );
1591        let with_fresh = route53_get(
1592            &fresh.expect("a credential").credentials,
1593            DEFAULT_HOST,
1594            path,
1595        );
1596        let with_expired = route53_get(
1597            &expired.expect("a credential").credentials,
1598            DEFAULT_HOST,
1599            path,
1600        );
1601
1602        assert_eq!(signed, with_fresh);
1603        assert_ne!(signed, with_expired);
1604    }
1605
1606    #[test]
1607    fn a_refresh_failure_keeps_signing_while_the_credential_is_still_valid() {
1608        let imds = FakeImds::new(vec![
1609            expiring("first", time::Duration::seconds(60)),
1610            Err("connection refused".to_string()),
1611        ]);
1612        let credentials = holder(&imds);
1613
1614        assert_eq!(
1615            credentials
1616                .current()
1617                .expect("still valid")
1618                .secret_access_key,
1619            "first"
1620        );
1621        assert_eq!(imds.reads(), 2);
1622        assert_eq!(
1623            credentials
1624                .current()
1625                .expect("still valid")
1626                .secret_access_key,
1627            "first"
1628        );
1629        assert_eq!(imds.reads(), 3, "and the next request tries again");
1630    }
1631
1632    #[test]
1633    fn a_refresh_failure_past_expiry_reaches_the_caller() {
1634        let imds = FakeImds::new(vec![
1635            expiring("first", time::Duration::seconds(-10)),
1636            Err("connection refused".to_string()),
1637        ]);
1638        let credentials = holder(&imds);
1639
1640        assert!(
1641            matches!(credentials.current(), Err(ImdsError::Request(_))),
1642            "an expired credential and an unreachable IMDS is an error, not a signature \
1643             route53 will reject"
1644        );
1645        assert!(
1646            matches!(credentials.current(), Err(ImdsError::Request(_))),
1647            "and it stays retryable rather than latching"
1648        );
1649    }
1650
1651    #[test]
1652    fn concurrent_signers_make_one_refresh_between_them() {
1653        // The delay is long enough that every thread below reaches
1654        // `current()` while the refresh is still in flight.
1655        let imds = FakeImds::with_delay(
1656            vec![
1657                expiring("first", time::Duration::seconds(10)),
1658                expiring("second", time::Duration::hours(6)),
1659            ],
1660            Duration::from_millis(50),
1661        );
1662        let credentials = std::sync::Arc::new(holder(&imds));
1663
1664        let signers: Vec<_> = (0..8)
1665            .map(|_| {
1666                let credentials = credentials.clone();
1667                thread::spawn(move || credentials.current().expect("refreshed").secret_access_key)
1668            })
1669            .collect();
1670        for signer in signers {
1671            assert_eq!(signer.join().expect("the thread signs"), "second");
1672        }
1673
1674        assert_eq!(
1675            imds.reads(),
1676            2,
1677            "one read at construction and one refresh shared by every waiter"
1678        );
1679    }
1680
1681    /// Why every caller of `publish`, `provision` or `delete` has to reach
1682    /// this file's blocking client through `tokio::task::spawn_blocking`
1683    /// rather than inline on an async task.
1684    ///
1685    /// Building a `reqwest::blocking::Client`, as `api_client` does, builds
1686    /// a shell `current_thread` runtime purely to check it is safe to block, then drops it; dropping
1687    /// a runtime blocks the thread until its worker shuts down, and — in a
1688    /// debug build only, per reqwest's own `wait::enter` — that panics when
1689    /// the thread already belongs to a runtime that forbids blocking, which
1690    /// is exactly what an async task on `#[tokio::main]`'s two-worker
1691    /// runtime is. `spawn_blocking` runs on a thread the runtime itself
1692    /// marks safe to block, so the same call there does not panic.
1693    ///
1694    /// This is not a hazard specific to `didbot-dns`: it is why `routes.rs`
1695    /// crosses `spawn_blocking` for every call into this file's provider
1696    /// rather than calling `Registry::provision`/`delete` inline.
1697    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1698    async fn building_the_blocking_client_inline_on_an_async_task_is_the_hazard_spawn_blocking_avoids(
1699    ) {
1700        let inline = std::panic::catch_unwind(api_client);
1701        #[cfg(debug_assertions)]
1702        assert!(
1703            inline.is_err(),
1704            "building the blocking client no longer panics when done inline on an async \
1705             task; if a reqwest upgrade fixed this, every caller that crosses spawn_blocking \
1706             for it may not need to any more"
1707        );
1708        #[cfg(not(debug_assertions))]
1709        assert!(
1710            inline.is_ok(),
1711            "outside a debug build this should not panic, only silently risk blocking a worker"
1712        );
1713
1714        let via_spawn_blocking =
1715            tokio::task::spawn_blocking(|| std::panic::catch_unwind(api_client))
1716                .await
1717                .expect("the blocking task itself must not panic");
1718        assert!(
1719            via_spawn_blocking.is_ok(),
1720            "spawn_blocking should isolate the same construction from the panic the inline \
1721             case hits in a debug build"
1722        );
1723    }
1724
1725    /// A Route53 endpoint that accepts the connection and never answers
1726    /// costs a call `didbot_http::DEFAULT_TIMEOUT`, then a timeout error.
1727    /// Real time: the blocking client runs its own runtime.
1728    #[test]
1729    fn the_api_client_gives_up_on_an_endpoint_that_never_answers() {
1730        let addr = didbot_http::test_support::hung_server();
1731        let started = Instant::now();
1732        let err = api_client()
1733            .get(format!("http://{addr}/"))
1734            .send()
1735            .expect_err("nothing ever answers");
1736        assert!(err.is_timeout(), "{err:?}");
1737        assert!(started.elapsed() >= didbot_http::DEFAULT_TIMEOUT);
1738    }
1739
1740    /// The same for IMDS, at its own shorter timeout.
1741    #[test]
1742    fn the_imds_client_gives_up_on_an_endpoint_that_never_answers() {
1743        let addr = didbot_http::test_support::hung_server();
1744        let started = Instant::now();
1745        let err = imds_client()
1746            .expect("the client builds")
1747            .get(format!("http://{addr}/"))
1748            .send()
1749            .expect_err("nothing ever answers");
1750        assert!(err.is_timeout(), "{err:?}");
1751        let elapsed = started.elapsed();
1752        assert!(
1753            elapsed >= IMDS_TIMEOUT && elapsed < didbot_http::DEFAULT_TIMEOUT,
1754            "{elapsed:?}"
1755        );
1756    }
1757}