1use 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#[derive(Clone)]
113pub struct Route53Credentials {
114 pub access_key_id: String,
116 pub secret_access_key: String,
118 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#[derive(Debug, thiserror::Error)]
135pub enum ImdsError {
136 #[error("could not reach the instance metadata service: {0}")]
140 Request(String),
141 #[error("instance metadata service response was not usable: {0}")]
144 Response(String),
145}
146
147#[derive(Clone, Debug)]
150pub(crate) struct TimedCredentials {
151 pub(crate) credentials: Route53Credentials,
152 pub(crate) expires_at: Option<time::OffsetDateTime>,
155}
156
157pub(crate) trait CredentialFetch: Send + Sync {
162 fn fetch(&self) -> Result<TimedCredentials, ImdsError>;
163}
164
165struct 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
203pub const REFRESH_MARGIN: Duration = Duration::from_secs(300);
211
212pub struct InstanceCredentials {
235 fetch: Box<dyn CredentialFetch>,
236 held: std::sync::Mutex<TimedCredentials>,
237}
238
239impl InstanceCredentials {
240 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 pub fn current(&self) -> Result<Route53Credentials, ImdsError> {
274 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#[cfg(feature = "aws-fake")]
330impl InstanceCredentials {
331 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
345const IMDS_TIMEOUT: Duration = Duration::from_secs(5);
349
350fn imds_client() -> reqwest::Result<reqwest::blocking::Client> {
352 didbot_http::blocking_builder()
353 .timeout(IMDS_TIMEOUT)
354 .build()
355}
356
357fn 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#[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
400fn 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#[derive(Debug, Clone, Copy)]
427pub struct RetryPolicy {
428 pub max_attempts: u32,
430 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
443pub 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 ttl: u32,
457 txt_ttl: u32,
462 txt: TxtRecords,
463 caa: CaaRecords,
464}
465
466impl Route53Dns {
467 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 #[must_use]
513 pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
514 self.retry = retry;
515 self
516 }
517
518 #[must_use]
523 pub fn with_propagation_wait(mut self, wait: bool) -> Self {
524 self.wait_for_propagation = wait;
525 self
526 }
527
528 #[must_use]
531 pub fn with_propagation_timeout(mut self, timeout: Duration) -> Self {
532 self.propagation_timeout = timeout;
533 self
534 }
535
536 #[must_use]
543 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
544 self.poll_interval = interval;
545 self
546 }
547
548 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 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 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 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 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 let quoted: Vec<String> = before.iter().map(|v| xml::quote_txt(v)).collect();
746 self.submit_change(host, "DELETE", "TXT", self.txt_ttl, "ed)
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, "ed)
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 return Ok(());
777 }
778 let after = self.caa.values(host);
779
780 let outcome = if after.is_empty() {
781 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
803pub(crate) struct Response {
806 pub(crate) status: u16,
807 pub(crate) body: String,
808}
809
810pub(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 let credentials = self.credentials.current().map_err(|err| err.to_string())?;
843 let now = time::OffsetDateTime::now_utc();
844 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
895mod xml {
906 use serde::{Deserialize, Serialize};
907
908 #[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 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 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 #[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 #[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 const DEFAULT_HOST: &str = "route53.amazonaws.com";
1075 use std::sync::Mutex;
1076
1077 struct FakeTransport {
1082 responses: Mutex<Vec<Result<Response, String>>>,
1083 calls: Mutex<Vec<(String, String, String)>>, }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 struct FakeImds {
1469 answers: Mutex<Vec<Result<TimedCredentials, String>>>,
1470 reads: std::sync::atomic::AtomicUsize,
1471 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 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 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 #[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 #[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 #[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}