1use crate::crypto::{hmac_sha256, sha256_digest};
13use crate::utils::txt_chunks_to_text;
14use crate::{
15 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
16 TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector,
17};
18use quick_xml::de::from_str;
19use quick_xml::se::to_string;
20use reqwest::header::{HeaderMap, HeaderValue};
21use reqwest::{Client, Response};
22use serde::{Deserialize, Serialize};
23use std::borrow::Cow;
24use std::net::AddrParseError;
25use std::time::{Duration, SystemTime};
26
27const ROUTE53_API_VERSION: &str = "2013-04-01";
28const ROUTE53_SERVICE: &str = "route53";
29const ROUTE53_DEFAULT_ENDPOINT: &str = "https://route53.amazonaws.com";
30const ROUTE53_XMLNS: &str = "https://route53.amazonaws.com/doc/2013-04-01/";
31const MAX_RETRIES: u32 = 3;
32
33#[derive(Debug, Clone)]
34pub struct Route53Config {
35 pub access_key_id: String,
36 pub secret_access_key: String,
37 pub session_token: Option<String>,
38 pub region: Option<String>,
39 pub hosted_zone_id: Option<String>,
40 pub private_zone_only: Option<bool>,
41}
42
43#[derive(Debug, Clone)]
44pub struct Route53Provider {
45 client: Client,
46 config: Route53Config,
47 region: String,
48 endpoint: Cow<'static, str>,
49}
50
51impl Route53Provider {
52 pub fn new(config: Route53Config) -> Self {
53 let region = config
54 .region
55 .clone()
56 .unwrap_or_else(|| "us-east-1".to_string());
57
58 Self {
59 client: Client::new(),
60 config,
61 region,
62 endpoint: Cow::Borrowed(ROUTE53_DEFAULT_ENDPOINT),
63 }
64 }
65
66 #[cfg(test)]
67 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
68 Self {
69 endpoint: endpoint.into(),
70 ..self
71 }
72 }
73
74 pub(crate) async fn set_rrset(
75 &self,
76 name: impl IntoFqdn<'_>,
77 record_type: DnsRecordType,
78 ttl: u32,
79 records: Vec<DnsRecord>,
80 _origin: impl IntoFqdn<'_>,
81 ) -> crate::Result<()> {
82 check_record_types(record_type, &records)?;
83 let name = name.into_fqdn().into_owned();
84 let hosted_zone_id = self.resolve_zone_id(&name).await?;
85
86 if records.is_empty() {
87 let existing = self
88 .find_existing_rrset(&hosted_zone_id, &name, record_type)
89 .await?;
90 match existing {
91 None => Ok(()),
92 Some(rrset) => {
93 let change_batch = ChangeBatch {
94 comment: Some(format!("Delete {} RRSet for {}", record_type, name)),
95 changes: Changes {
96 changes: vec![Change {
97 action: ChangeAction::Delete,
98 resource_record_set: rrset,
99 }],
100 },
101 };
102 self.send_change_request(&hosted_zone_id, change_batch)
103 .await
104 }
105 }
106 } else {
107 let resource_records = ResourceRecords {
108 resource_records: build_resource_records(&records)?,
109 };
110 let rrset = ResourceRecordSet::new(
111 name.to_string(),
112 record_type.as_str().to_string(),
113 ttl as i64,
114 resource_records,
115 );
116 let change_batch = ChangeBatch {
117 comment: Some(format!("Set {} RRSet for {}", record_type, name)),
118 changes: Changes {
119 changes: vec![Change {
120 action: ChangeAction::Upsert,
121 resource_record_set: rrset,
122 }],
123 },
124 };
125 self.send_change_request(&hosted_zone_id, change_batch)
126 .await
127 }
128 }
129
130 pub(crate) async fn add_to_rrset(
131 &self,
132 name: impl IntoFqdn<'_>,
133 record_type: DnsRecordType,
134 ttl: u32,
135 records: Vec<DnsRecord>,
136 _origin: impl IntoFqdn<'_>,
137 ) -> crate::Result<()> {
138 check_record_types(record_type, &records)?;
139 if records.is_empty() {
140 return Ok(());
141 }
142 let name = name.into_fqdn().into_owned();
143 let hosted_zone_id = self.resolve_zone_id(&name).await?;
144
145 let mut desired = build_resource_records(&records)?;
146 let existing_rrset = self
147 .find_existing_rrset(&hosted_zone_id, &name, record_type)
148 .await?;
149
150 let (mut union, effective_ttl): (Vec<ResourceRecord>, i64) =
151 if let Some(existing) = existing_rrset {
152 let existing_ttl = existing.ttl;
153 (existing.resource_records.resource_records, existing_ttl)
154 } else {
155 (Vec::new(), ttl as i64)
156 };
157
158 for record in desired.drain(..) {
159 if !union.iter().any(|r| r.value == record.value) {
160 union.push(record);
161 }
162 }
163
164 let rrset = ResourceRecordSet::new(
165 name.to_string(),
166 record_type.as_str().to_string(),
167 effective_ttl,
168 ResourceRecords {
169 resource_records: union,
170 },
171 );
172 let change_batch = ChangeBatch {
173 comment: Some(format!("Add to {} RRSet for {}", record_type, name)),
174 changes: Changes {
175 changes: vec![Change {
176 action: ChangeAction::Upsert,
177 resource_record_set: rrset,
178 }],
179 },
180 };
181 self.send_change_request(&hosted_zone_id, change_batch)
182 .await
183 }
184
185 pub(crate) async fn remove_from_rrset(
186 &self,
187 name: impl IntoFqdn<'_>,
188 record_type: DnsRecordType,
189 records: Vec<DnsRecord>,
190 _origin: impl IntoFqdn<'_>,
191 ) -> crate::Result<()> {
192 check_record_types(record_type, &records)?;
193 if records.is_empty() {
194 return Ok(());
195 }
196 let name = name.into_fqdn().into_owned();
197 let hosted_zone_id = self.resolve_zone_id(&name).await?;
198
199 let existing_rrset = match self
200 .find_existing_rrset(&hosted_zone_id, &name, record_type)
201 .await?
202 {
203 Some(r) => r,
204 None => return Ok(()),
205 };
206
207 let to_remove = build_resource_records(&records)?;
208 let existing_ttl = existing_rrset.ttl;
209 let existing_records = existing_rrset.resource_records.resource_records.clone();
210 let filtered: Vec<ResourceRecord> = existing_records
211 .iter()
212 .filter(|r| !to_remove.iter().any(|x| x.value == r.value))
213 .cloned()
214 .collect();
215
216 if filtered.len() == existing_records.len() {
217 return Ok(());
218 }
219
220 if filtered.is_empty() {
221 let rrset = ResourceRecordSet::new(
222 name.to_string(),
223 record_type.as_str().to_string(),
224 existing_ttl,
225 ResourceRecords {
226 resource_records: existing_records,
227 },
228 );
229 let change_batch = ChangeBatch {
230 comment: Some(format!(
231 "Remove all from {} RRSet for {}",
232 record_type, name
233 )),
234 changes: Changes {
235 changes: vec![Change {
236 action: ChangeAction::Delete,
237 resource_record_set: rrset,
238 }],
239 },
240 };
241 self.send_change_request(&hosted_zone_id, change_batch)
242 .await
243 } else {
244 let rrset = ResourceRecordSet::new(
245 name.to_string(),
246 record_type.as_str().to_string(),
247 existing_ttl,
248 ResourceRecords {
249 resource_records: filtered,
250 },
251 );
252 let change_batch = ChangeBatch {
253 comment: Some(format!("Remove from {} RRSet for {}", record_type, name)),
254 changes: Changes {
255 changes: vec![Change {
256 action: ChangeAction::Upsert,
257 resource_record_set: rrset,
258 }],
259 },
260 };
261 self.send_change_request(&hosted_zone_id, change_batch)
262 .await
263 }
264 }
265
266 pub(crate) async fn list_rrset(
267 &self,
268 name: impl IntoFqdn<'_>,
269 record_type: DnsRecordType,
270 _origin: impl IntoFqdn<'_>,
271 ) -> crate::Result<Vec<DnsRecord>> {
272 let name = name.into_fqdn().into_owned();
273 let hosted_zone_id = self.resolve_zone_id(&name).await?;
274 let existing = self
275 .find_existing_rrset(&hosted_zone_id, &name, record_type)
276 .await?;
277 let Some(rrset) = existing else {
278 return Ok(Vec::new());
279 };
280 let mut out = Vec::with_capacity(rrset.resource_records.resource_records.len());
281 for r in rrset.resource_records.resource_records {
282 out.push(parse_value(record_type, &r.value)?);
283 }
284 Ok(out)
285 }
286
287 async fn resolve_zone_id(&self, name: &str) -> crate::Result<String> {
288 if let Some(zone_id) = &self.config.hosted_zone_id {
289 return Ok(zone_id.trim_start_matches("/hostedzone/").to_string());
290 }
291
292 let zones = self.list_hosted_zones_by_name().await?;
293 let private_zone_only = self.config.private_zone_only.unwrap_or(false);
294 let mut matching: Vec<HostedZone> = zones
295 .into_iter()
296 .filter(|z| !private_zone_only || z.config.private_zone)
297 .filter(|z| {
298 let zone_name = z.name.trim_end_matches('.');
299 let candidate = name.trim_end_matches('.');
300 candidate == zone_name || candidate.ends_with(&format!(".{}", zone_name))
301 })
302 .collect();
303 matching.sort_by_key(|z| std::cmp::Reverse(z.name.len()));
304 matching
305 .into_iter()
306 .next()
307 .map(|z| z.id.trim_start_matches("/hostedzone/").to_string())
308 .ok_or_else(|| Error::Api(format!("No suitable hosted zone found for name: {}", name)))
309 }
310
311 async fn list_hosted_zones_by_name(&self) -> crate::Result<Vec<HostedZone>> {
312 let mut zones: Vec<HostedZone> = Vec::new();
313 let mut next_dns_name: Option<String> = None;
314 let mut next_hosted_zone_id: Option<String> = None;
315 loop {
316 let mut url = format!(
317 "{}/{}/hostedzonesbyname",
318 self.endpoint.as_ref(),
319 ROUTE53_API_VERSION
320 );
321 let mut query_parts: Vec<(&str, String)> = Vec::new();
322 if let Some(n) = &next_dns_name {
323 query_parts.push(("dnsname", n.clone()));
324 }
325 if let Some(z) = &next_hosted_zone_id {
326 query_parts.push(("hostedzoneid", z.clone()));
327 }
328 if !query_parts.is_empty() {
329 let q = serde_urlencoded::to_string(&query_parts)
330 .map_err(|e| Error::Serialize(e.to_string()))?;
331 url.push('?');
332 url.push_str(&q);
333 }
334
335 let response = self.send_signed_request("GET", &url, None).await?;
336 let body = response
337 .text()
338 .await
339 .map_err(|e| Error::Api(format!("Failed to read response: {}", e)))?;
340 let list_response: ListHostedZonesByNameResponse =
341 from_str(&body).map_err(|e| Error::Api(format!("XML parsing error: {}", e)))?;
342 zones.extend(list_response.hosted_zones.hosted_zones);
343 if !list_response.is_truncated {
344 break;
345 }
346 next_dns_name = list_response.next_dns_name;
347 next_hosted_zone_id = list_response.next_hosted_zone_id;
348 if next_dns_name.is_none() && next_hosted_zone_id.is_none() {
349 break;
350 }
351 }
352 Ok(zones)
353 }
354
355 async fn find_existing_rrset(
356 &self,
357 hosted_zone_id: &str,
358 name: &str,
359 record_type: DnsRecordType,
360 ) -> crate::Result<Option<ResourceRecordSet>> {
361 let type_str = record_type.as_str();
362 let normalized_name = ensure_trailing_dot(name);
363 let rrsets = self
364 .list_resource_record_sets(hosted_zone_id, &normalized_name, type_str)
365 .await?;
366 Ok(rrsets.into_iter().find(|r| {
367 r.type_ == type_str
368 && r.set_identifier.is_none()
369 && names_match(&r.name, &normalized_name)
370 }))
371 }
372
373 async fn list_resource_record_sets(
374 &self,
375 hosted_zone_id: &str,
376 start_name: &str,
377 start_type: &str,
378 ) -> crate::Result<Vec<ResourceRecordSet>> {
379 let mut out: Vec<ResourceRecordSet> = Vec::new();
380 let mut next_name = Some(start_name.to_string());
381 let mut next_type = Some(start_type.to_string());
382 let mut next_identifier: Option<String> = None;
383 let mut first = true;
384 loop {
385 let mut query: Vec<(&str, String)> = Vec::new();
386 if let Some(n) = &next_name {
387 query.push(("name", n.clone()));
388 }
389 if let Some(t) = &next_type {
390 query.push(("type", t.clone()));
391 }
392 if let Some(i) = &next_identifier {
393 query.push(("identifier", i.clone()));
394 }
395 let query_string =
396 serde_urlencoded::to_string(&query).map_err(|e| Error::Serialize(e.to_string()))?;
397 let url = format!(
398 "{}/{}/hostedzone/{}/rrset?{}",
399 self.endpoint.as_ref(),
400 ROUTE53_API_VERSION,
401 hosted_zone_id.trim_start_matches("/hostedzone/"),
402 query_string,
403 );
404 let response = self.send_signed_request("GET", &url, None).await?;
405 let body = response
406 .text()
407 .await
408 .map_err(|e| Error::Api(format!("Failed to read response: {}", e)))?;
409 let list_response: ListResourceRecordSetsResponse =
410 from_str(&body).map_err(|e| Error::Api(format!("XML parsing error: {}", e)))?;
411
412 let mut stop = false;
413 for rrset in list_response.resource_record_sets.resource_record_sets {
414 if !names_match(&rrset.name, start_name) {
415 stop = true;
416 break;
417 }
418 if rrset.type_ != start_type {
419 if first && rrset.type_.as_str() < start_type {
420 continue;
421 }
422 stop = true;
423 break;
424 }
425 out.push(rrset);
426 }
427 first = false;
428 if stop || !list_response.is_truncated {
429 break;
430 }
431 next_name = list_response.next_record_name;
432 next_type = list_response.next_record_type;
433 next_identifier = list_response.next_record_identifier;
434 if next_name.is_none() && next_type.is_none() {
435 break;
436 }
437 }
438 Ok(out)
439 }
440
441 async fn send_change_request(
442 &self,
443 hosted_zone_id: &str,
444 change_batch: ChangeBatch,
445 ) -> crate::Result<()> {
446 let url = format!(
447 "{}/{}/hostedzone/{}/rrset",
448 self.endpoint.as_ref(),
449 ROUTE53_API_VERSION,
450 hosted_zone_id.trim_start_matches("/hostedzone/")
451 );
452
453 let request = ChangeResourceRecordSetsRequest {
454 xmlns: ROUTE53_XMLNS,
455 change_batch,
456 };
457
458 let xml_body = to_string(&request).map_err(|e| Error::Serialize(format!("{}", e)))?;
459 let payload = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}", xml_body);
460
461 self.send_signed_request("POST", &url, Some(payload))
462 .await?;
463 Ok(())
464 }
465
466 async fn send_signed_request(
467 &self,
468 method: &str,
469 url: &str,
470 body: Option<String>,
471 ) -> crate::Result<Response> {
472 let mut attempts: u32 = 0;
473 loop {
474 let result = self
475 .send_signed_request_once(method, url, body.as_deref())
476 .await;
477 match result {
478 Ok(response) => return Ok(response),
479 Err(SignedRequestError::Retryable(_)) if attempts < MAX_RETRIES => {
480 let delay = Duration::from_millis(250 * (1u64 << attempts));
481 tokio::time::sleep(delay).await;
482 attempts += 1;
483 continue;
484 }
485 Err(SignedRequestError::Retryable(e)) => return Err(e),
486 Err(SignedRequestError::Permanent(e)) => return Err(e),
487 }
488 }
489 }
490
491 async fn send_signed_request_once(
492 &self,
493 method: &str,
494 url: &str,
495 body: Option<&str>,
496 ) -> Result<Response, SignedRequestError> {
497 use chrono::{DateTime, Utc};
498 let datetime: DateTime<Utc> = SystemTime::now().into();
499 let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string();
500 let date_stamp = datetime.format("%Y%m%d").to_string();
501
502 let parsed_url: reqwest::Url = url
503 .parse()
504 .map_err(|e| SignedRequestError::Permanent(Error::Parse(format!("{e}"))))?;
505 let host_for_header = parsed_url
506 .host_str()
507 .ok_or_else(|| {
508 SignedRequestError::Permanent(Error::Parse(format!("invalid URL: {url}")))
509 })?
510 .to_string();
511 let host_header_value = match parsed_url.port() {
512 Some(port) => format!("{host_for_header}:{port}"),
513 None => host_for_header.clone(),
514 };
515 let canonical_uri = parsed_url.path();
516 let canonical_querystring = canonical_query_string(parsed_url.query().unwrap_or(""));
517
518 let mut headers = HeaderMap::new();
519 headers.insert(
520 "host",
521 HeaderValue::from_str(&host_header_value).map_err(|e| {
522 SignedRequestError::Permanent(Error::Api(format!("invalid host header: {e}")))
523 })?,
524 );
525 headers.insert(
526 "x-amz-date",
527 HeaderValue::from_str(&amz_date).map_err(|e| {
528 SignedRequestError::Permanent(Error::Api(format!("invalid date header: {e}")))
529 })?,
530 );
531 headers.insert("content-type", HeaderValue::from_static("application/xml"));
532
533 let mut signed_header_names: Vec<&'static str> = vec!["content-type", "host", "x-amz-date"];
534 let mut canonical_header_lines: Vec<String> = vec![
535 format!("content-type:application/xml"),
536 format!("host:{host_header_value}"),
537 format!("x-amz-date:{amz_date}"),
538 ];
539
540 if let Some(session_token) = &self.config.session_token {
541 headers.insert(
542 "x-amz-security-token",
543 HeaderValue::from_str(session_token).map_err(|e| {
544 SignedRequestError::Permanent(Error::Api(format!(
545 "invalid session token header: {e}"
546 )))
547 })?,
548 );
549 signed_header_names.push("x-amz-security-token");
550 canonical_header_lines.push(format!("x-amz-security-token:{session_token}"));
551 }
552
553 let signed_headers = signed_header_names.join(";");
554 let canonical_headers = format!("{}\n", canonical_header_lines.join("\n"));
555
556 let body_str = body.unwrap_or("");
557 let payload_hash = hex::encode(sha256_digest(body_str.as_bytes()));
558
559 let canonical_request = format!(
560 "{}\n{}\n{}\n{}\n{}\n{}",
561 method,
562 canonical_uri,
563 canonical_querystring,
564 canonical_headers,
565 signed_headers,
566 payload_hash
567 );
568
569 let algorithm = "AWS4-HMAC-SHA256";
570 let credential_scope = format!(
571 "{}/{}/{}/aws4_request",
572 date_stamp, self.region, ROUTE53_SERVICE
573 );
574 let string_to_sign = format!(
575 "{}\n{}\n{}\n{}",
576 algorithm,
577 amz_date,
578 credential_scope,
579 hex::encode(sha256_digest(canonical_request.as_bytes()))
580 );
581
582 let signing_key = self.get_signature_key(&date_stamp);
583 let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
584
585 let authorization_header = format!(
586 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
587 algorithm, self.config.access_key_id, credential_scope, signed_headers, signature
588 );
589 headers.insert(
590 "Authorization",
591 HeaderValue::from_str(&authorization_header).map_err(|e| {
592 SignedRequestError::Permanent(Error::Api(format!(
593 "invalid authorization header: {e}"
594 )))
595 })?,
596 );
597
598 let parsed_method = method
599 .parse::<reqwest::Method>()
600 .map_err(|e| SignedRequestError::Permanent(Error::Parse(e.to_string())))?;
601 let mut request = self.client.request(parsed_method, url).headers(headers);
602 if let Some(body_content) = body {
603 request = request.body(body_content.to_string());
604 }
605
606 let response = request
607 .send()
608 .await
609 .map_err(|e| SignedRequestError::Permanent(Error::Api(e.to_string())))?;
610
611 let status = response.status();
612 let code = status.as_u16();
613 if status.is_success() {
614 return Ok(response);
615 }
616
617 let body_text = response.text().await.unwrap_or_default();
618 let retryable = code == 429
619 || code == 503
620 || body_text.contains("Throttling")
621 || body_text.contains("PriorRequestNotComplete");
622 let err = match code {
623 401 => Error::Unauthorized,
624 404 => Error::NotFound,
625 400 if !retryable => Error::Api(format!("Route53 BadRequest: {}", body_text)),
626 _ => Error::Api(format!("Route53 API error: {} - {}", status, body_text)),
627 };
628 if retryable {
629 Err(SignedRequestError::Retryable(err))
630 } else {
631 Err(SignedRequestError::Permanent(err))
632 }
633 }
634
635 fn get_signature_key(&self, date_stamp: &str) -> Vec<u8> {
636 let k_date = hmac_sha256(
637 format!("AWS4{}", self.config.secret_access_key).as_bytes(),
638 date_stamp.as_bytes(),
639 );
640 let k_region = hmac_sha256(&k_date, self.region.as_bytes());
641 let k_service = hmac_sha256(&k_region, ROUTE53_SERVICE.as_bytes());
642 hmac_sha256(&k_service, b"aws4_request")
643 }
644}
645
646enum SignedRequestError {
647 Retryable(Error),
648 Permanent(Error),
649}
650
651fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
652 for r in records {
653 if r.as_type() != expected {
654 return Err(Error::Api(format!(
655 "RRSet record type mismatch: expected {}, got {}",
656 expected.as_str(),
657 r.as_type().as_str(),
658 )));
659 }
660 }
661 Ok(())
662}
663
664fn build_resource_records(records: &[DnsRecord]) -> crate::Result<Vec<ResourceRecord>> {
665 let mut out = Vec::with_capacity(records.len());
666 let mut seen: Vec<String> = Vec::with_capacity(records.len());
667 for record in records {
668 let value = render_record_value(record);
669 if seen.iter().any(|s| s == &value) {
670 continue;
671 }
672 seen.push(value.clone());
673 out.push(ResourceRecord { value });
674 }
675 Ok(out)
676}
677
678fn render_record_value(record: &DnsRecord) -> String {
679 match record {
680 DnsRecord::A(addr) => addr.to_string(),
681 DnsRecord::AAAA(addr) => addr.to_string(),
682 DnsRecord::CNAME(name) => name.clone(),
683 DnsRecord::NS(name) => name.clone(),
684 DnsRecord::MX(mx) => mx.to_string(),
685 DnsRecord::TXT(text) => {
686 let mut value = String::new();
687 txt_chunks_to_text(&mut value, text, " ");
688 value
689 }
690 DnsRecord::SRV(srv) => srv.to_string(),
691 DnsRecord::TLSA(tlsa) => tlsa.to_string(),
692 DnsRecord::CAA(caa) => caa.to_string(),
693 }
694}
695
696fn parse_value(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
697 Ok(match record_type {
698 DnsRecordType::A => DnsRecord::A(value.parse().map_err(|e: AddrParseError| {
699 Error::Parse(format!("invalid A value '{value}': {e}"))
700 })?),
701 DnsRecordType::AAAA => DnsRecord::AAAA(value.parse().map_err(|e: AddrParseError| {
702 Error::Parse(format!("invalid AAAA value '{value}': {e}"))
703 })?),
704 DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(value)),
705 DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(value)),
706 DnsRecordType::MX => parse_mx(value)?,
707 DnsRecordType::TXT => DnsRecord::TXT(parse_txt(value)),
708 DnsRecordType::SRV => parse_srv(value)?,
709 DnsRecordType::TLSA => parse_tlsa(value)?,
710 DnsRecordType::CAA => parse_caa(value)?,
711 })
712}
713
714fn parse_mx(value: &str) -> crate::Result<DnsRecord> {
715 let mut parts = value.splitn(2, char::is_whitespace);
716 let priority = parts
717 .next()
718 .ok_or_else(|| Error::Parse(format!("invalid MX value '{value}'")))?
719 .parse()
720 .map_err(|e| Error::Parse(format!("invalid MX priority in '{value}': {e}")))?;
721 let exchange = parts
722 .next()
723 .ok_or_else(|| Error::Parse(format!("invalid MX value '{value}'")))?
724 .trim();
725 Ok(DnsRecord::MX(MXRecord {
726 priority,
727 exchange: strip_trailing_dot(exchange),
728 }))
729}
730
731fn parse_srv(value: &str) -> crate::Result<DnsRecord> {
732 let mut parts = value.split_whitespace();
733 let priority = parts
734 .next()
735 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
736 .parse()
737 .map_err(|e| Error::Parse(format!("invalid SRV priority in '{value}': {e}")))?;
738 let weight = parts
739 .next()
740 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
741 .parse()
742 .map_err(|e| Error::Parse(format!("invalid SRV weight in '{value}': {e}")))?;
743 let port = parts
744 .next()
745 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
746 .parse()
747 .map_err(|e| Error::Parse(format!("invalid SRV port in '{value}': {e}")))?;
748 let target = parts
749 .next()
750 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?;
751 Ok(DnsRecord::SRV(SRVRecord {
752 priority,
753 weight,
754 port,
755 target: strip_trailing_dot(target),
756 }))
757}
758
759fn parse_tlsa(value: &str) -> crate::Result<DnsRecord> {
760 let mut parts = value.split_whitespace();
761 let usage: u8 = parts
762 .next()
763 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
764 .parse()
765 .map_err(|e| Error::Parse(format!("invalid TLSA usage in '{value}': {e}")))?;
766 let selector: u8 = parts
767 .next()
768 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
769 .parse()
770 .map_err(|e| Error::Parse(format!("invalid TLSA selector in '{value}': {e}")))?;
771 let matching: u8 = parts
772 .next()
773 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
774 .parse()
775 .map_err(|e| Error::Parse(format!("invalid TLSA matching in '{value}': {e}")))?;
776 let hex_str: String = parts.collect::<Vec<_>>().join("");
777 if hex_str.is_empty() {
778 return Err(Error::Parse(format!("invalid TLSA value '{value}'")));
779 }
780 let cert_data = decode_hex(&hex_str)?;
781 Ok(DnsRecord::TLSA(TLSARecord {
782 cert_usage: tlsa_cert_usage_from_u8(usage)?,
783 selector: tlsa_selector_from_u8(selector)?,
784 matching: tlsa_matching_from_u8(matching)?,
785 cert_data,
786 }))
787}
788
789fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
790 let mut parts = value.splitn(3, char::is_whitespace);
791 let flags: u8 = parts
792 .next()
793 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
794 .parse()
795 .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
796 let tag = parts
797 .next()
798 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
799 .to_ascii_lowercase();
800 let raw_value = parts
801 .next()
802 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
803 .trim();
804 let unquoted = raw_value
805 .strip_prefix('"')
806 .and_then(|s| s.strip_suffix('"'))
807 .map(|s| s.replace("\\\"", "\""))
808 .unwrap_or_else(|| raw_value.to_string());
809
810 let issuer_critical = flags & 0x80 != 0;
811 match tag.as_str() {
812 "issue" => {
813 let (name, options) = parse_caa_kv(&unquoted);
814 Ok(DnsRecord::CAA(CAARecord::Issue {
815 issuer_critical,
816 name,
817 options,
818 }))
819 }
820 "issuewild" => {
821 let (name, options) = parse_caa_kv(&unquoted);
822 Ok(DnsRecord::CAA(CAARecord::IssueWild {
823 issuer_critical,
824 name,
825 options,
826 }))
827 }
828 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
829 issuer_critical,
830 url: unquoted,
831 })),
832 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
833 }
834}
835
836fn parse_caa_kv(value: &str) -> (Option<String>, Vec<KeyValue>) {
837 let mut parts = value.split(';').map(str::trim);
838 let name_part = parts.next().unwrap_or("").trim().to_string();
839 let name = if name_part.is_empty() {
840 None
841 } else {
842 Some(name_part)
843 };
844 let options = parts
845 .filter(|p| !p.is_empty())
846 .map(|p| match p.split_once('=') {
847 Some((k, v)) => KeyValue {
848 key: k.trim().to_string(),
849 value: v.trim().to_string(),
850 },
851 None => KeyValue {
852 key: p.trim().to_string(),
853 value: String::new(),
854 },
855 })
856 .collect();
857 (name, options)
858}
859
860fn parse_txt(value: &str) -> String {
861 let trimmed = value.trim();
862 let mut out = String::with_capacity(trimmed.len());
863 let mut bytes = trimmed.bytes().peekable();
864 let mut saw_quote = false;
865 while let Some(&b) = bytes.peek() {
866 if b != b'"' {
867 bytes.next();
868 continue;
869 }
870 saw_quote = true;
871 bytes.next();
872 loop {
873 match bytes.next() {
874 Some(b'"') => break,
875 Some(b'\\') => {
876 if let Some(next) = bytes.next() {
877 out.push(next as char);
878 }
879 }
880 Some(other) => out.push(other as char),
881 None => break,
882 }
883 }
884 }
885 if !saw_quote {
886 return trimmed.to_string();
887 }
888 out
889}
890
891fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
892 if !hex.len().is_multiple_of(2) {
893 return Err(Error::Parse(format!("invalid hex string: {hex}")));
894 }
895 (0..hex.len())
896 .step_by(2)
897 .map(|i| {
898 u8::from_str_radix(&hex[i..i + 2], 16)
899 .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
900 })
901 .collect()
902}
903
904fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
905 Ok(match value {
906 0 => TlsaCertUsage::PkixTa,
907 1 => TlsaCertUsage::PkixEe,
908 2 => TlsaCertUsage::DaneTa,
909 3 => TlsaCertUsage::DaneEe,
910 255 => TlsaCertUsage::Private,
911 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
912 })
913}
914
915fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
916 Ok(match value {
917 0 => TlsaSelector::Full,
918 1 => TlsaSelector::Spki,
919 255 => TlsaSelector::Private,
920 _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
921 })
922}
923
924fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
925 Ok(match value {
926 0 => TlsaMatching::Raw,
927 1 => TlsaMatching::Sha256,
928 2 => TlsaMatching::Sha512,
929 255 => TlsaMatching::Private,
930 _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
931 })
932}
933
934fn ensure_trailing_dot(value: &str) -> String {
935 if value.ends_with('.') {
936 value.to_string()
937 } else {
938 format!("{value}.")
939 }
940}
941
942fn strip_trailing_dot(value: &str) -> String {
943 value.strip_suffix('.').unwrap_or(value).to_string()
944}
945
946fn names_match(a: &str, b: &str) -> bool {
947 a.trim_end_matches('.') == b.trim_end_matches('.')
948}
949
950fn canonical_query_string(query: &str) -> String {
951 if query.is_empty() {
952 return String::new();
953 }
954 let mut pairs: Vec<(String, String)> = Vec::new();
955 for pair in query.split('&') {
956 if pair.is_empty() {
957 continue;
958 }
959 let (k, v) = match pair.split_once('=') {
960 Some((k, v)) => (k.to_string(), v.to_string()),
961 None => (pair.to_string(), String::new()),
962 };
963 pairs.push((k, v));
964 }
965 pairs.sort();
966 pairs
967 .into_iter()
968 .map(|(k, v)| format!("{}={}", k, v))
969 .collect::<Vec<_>>()
970 .join("&")
971}
972
973#[derive(Debug, Serialize)]
974#[serde(rename = "ChangeResourceRecordSetsRequest")]
975struct ChangeResourceRecordSetsRequest {
976 #[serde(rename = "@xmlns")]
977 xmlns: &'static str,
978 #[serde(rename = "ChangeBatch")]
979 change_batch: ChangeBatch,
980}
981
982#[derive(Debug, Serialize, Deserialize)]
983struct ChangeBatch {
984 #[serde(rename = "Comment", skip_serializing_if = "Option::is_none")]
985 comment: Option<String>,
986 #[serde(rename = "Changes")]
987 changes: Changes,
988}
989
990#[derive(Debug, Serialize, Deserialize)]
991struct Changes {
992 #[serde(rename = "Change")]
993 changes: Vec<Change>,
994}
995
996#[derive(Debug, Serialize, Deserialize)]
997struct Change {
998 #[serde(rename = "Action")]
999 action: ChangeAction,
1000 #[serde(rename = "ResourceRecordSet")]
1001 resource_record_set: ResourceRecordSet,
1002}
1003
1004#[derive(Debug, Serialize, Deserialize)]
1005#[serde(rename_all = "UPPERCASE")]
1006enum ChangeAction {
1007 Create,
1008 Delete,
1009 Upsert,
1010}
1011
1012#[derive(Debug, Serialize, Deserialize)]
1013struct ResourceRecordSet {
1014 #[serde(rename = "Name")]
1015 name: String,
1016 #[serde(rename = "Type")]
1017 type_: String,
1018 #[serde(rename = "TTL")]
1019 ttl: i64,
1020 #[serde(rename = "ResourceRecords")]
1021 resource_records: ResourceRecords,
1022 #[serde(rename = "SetIdentifier", skip_serializing_if = "Option::is_none")]
1023 set_identifier: Option<String>,
1024 #[serde(rename = "Weight", skip_serializing_if = "Option::is_none")]
1025 weight: Option<i64>,
1026 #[serde(rename = "Region", skip_serializing_if = "Option::is_none")]
1027 region: Option<String>,
1028 #[serde(rename = "GeoLocation", skip_serializing_if = "Option::is_none")]
1029 geo_location: Option<GeoLocation>,
1030 #[serde(rename = "HealthCheckId", skip_serializing_if = "Option::is_none")]
1031 health_check_id: Option<String>,
1032 #[serde(
1033 rename = "TrafficPolicyInstanceId",
1034 skip_serializing_if = "Option::is_none"
1035 )]
1036 traffic_policy_instance_id: Option<String>,
1037}
1038
1039impl ResourceRecordSet {
1040 fn new(name: String, type_: String, ttl: i64, resource_records: ResourceRecords) -> Self {
1041 Self {
1042 name,
1043 type_,
1044 ttl,
1045 resource_records,
1046 set_identifier: None,
1047 weight: None,
1048 region: None,
1049 geo_location: None,
1050 health_check_id: None,
1051 traffic_policy_instance_id: None,
1052 }
1053 }
1054}
1055
1056#[derive(Debug, Serialize, Deserialize, Clone)]
1057struct ResourceRecords {
1058 #[serde(rename = "ResourceRecord", default)]
1059 resource_records: Vec<ResourceRecord>,
1060}
1061
1062#[derive(Debug, Serialize, Deserialize, Clone)]
1063struct ResourceRecord {
1064 #[serde(rename = "Value")]
1065 value: String,
1066}
1067
1068#[derive(Debug, Serialize, Deserialize)]
1069struct GeoLocation {
1070 #[serde(rename = "ContinentCode", skip_serializing_if = "Option::is_none")]
1071 continent_code: Option<String>,
1072 #[serde(rename = "CountryCode", skip_serializing_if = "Option::is_none")]
1073 country_code: Option<String>,
1074 #[serde(rename = "SubdivisionCode", skip_serializing_if = "Option::is_none")]
1075 subdivision_code: Option<String>,
1076}
1077
1078#[derive(Debug, Serialize, Deserialize)]
1079struct ListHostedZonesByNameResponse {
1080 #[serde(rename = "HostedZones")]
1081 hosted_zones: HostedZones,
1082 #[serde(rename = "IsTruncated")]
1083 is_truncated: bool,
1084 #[serde(rename = "NextDNSName", skip_serializing_if = "Option::is_none")]
1085 next_dns_name: Option<String>,
1086 #[serde(rename = "NextHostedZoneId", skip_serializing_if = "Option::is_none")]
1087 next_hosted_zone_id: Option<String>,
1088 #[serde(rename = "MaxItems")]
1089 max_items: String,
1090}
1091
1092#[derive(Debug, Serialize, Deserialize)]
1093struct HostedZones {
1094 #[serde(rename = "HostedZone", default)]
1095 hosted_zones: Vec<HostedZone>,
1096}
1097
1098#[derive(Debug, Serialize, Deserialize)]
1099struct HostedZone {
1100 #[serde(rename = "Id")]
1101 id: String,
1102 #[serde(rename = "Name")]
1103 name: String,
1104 #[serde(rename = "CallerReference")]
1105 caller_reference: String,
1106 #[serde(rename = "Config")]
1107 config: HostedZoneConfig,
1108}
1109
1110#[derive(Debug, Serialize, Deserialize)]
1111struct HostedZoneConfig {
1112 #[serde(rename = "PrivateZone")]
1113 private_zone: bool,
1114}
1115
1116#[derive(Debug, Serialize, Deserialize)]
1117struct ListResourceRecordSetsResponse {
1118 #[serde(rename = "ResourceRecordSets")]
1119 resource_record_sets: ResourceRecordSets,
1120 #[serde(rename = "IsTruncated")]
1121 is_truncated: bool,
1122 #[serde(rename = "MaxItems")]
1123 max_items: String,
1124 #[serde(rename = "NextRecordName", skip_serializing_if = "Option::is_none")]
1125 next_record_name: Option<String>,
1126 #[serde(rename = "NextRecordType", skip_serializing_if = "Option::is_none")]
1127 next_record_type: Option<String>,
1128 #[serde(
1129 rename = "NextRecordIdentifier",
1130 skip_serializing_if = "Option::is_none"
1131 )]
1132 next_record_identifier: Option<String>,
1133}
1134
1135#[derive(Debug, Serialize, Deserialize)]
1136struct ResourceRecordSets {
1137 #[serde(rename = "ResourceRecordSet", default)]
1138 resource_record_sets: Vec<ResourceRecordSet>,
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144 use quick_xml::se::to_string;
1145
1146 #[test]
1147 fn test_serialization() {
1148 let req = ChangeResourceRecordSetsRequest {
1149 xmlns: ROUTE53_XMLNS,
1150 change_batch: ChangeBatch {
1151 comment: Some("Test".to_string()),
1152 changes: Changes {
1153 changes: vec![Change {
1154 action: ChangeAction::Create,
1155 resource_record_set: ResourceRecordSet::new(
1156 "example.com".to_string(),
1157 "A".to_string(),
1158 300,
1159 ResourceRecords {
1160 resource_records: vec![ResourceRecord {
1161 value: "127.0.0.1".to_string(),
1162 }],
1163 },
1164 ),
1165 }],
1166 },
1167 },
1168 };
1169 let out = to_string(&req).unwrap();
1170 assert!(out.starts_with("<ChangeResourceRecordSetsRequest"));
1171 }
1172
1173 #[test]
1174 fn test_parse_txt_roundtrip() {
1175 let original = "hello \"world\"";
1176 let rendered = render_record_value(&DnsRecord::TXT(original.to_string()));
1177 let parsed = parse_txt(&rendered);
1178 assert_eq!(parsed, original);
1179 }
1180
1181 #[test]
1182 fn test_parse_mx_roundtrip() {
1183 let original = DnsRecord::MX(MXRecord {
1184 priority: 10,
1185 exchange: "mail.example.com".to_string(),
1186 });
1187 let rendered = render_record_value(&original);
1188 let parsed = parse_value(DnsRecordType::MX, &rendered).unwrap();
1189 assert_eq!(parsed, original);
1190 }
1191
1192 #[test]
1193 fn test_parse_tlsa_roundtrip() {
1194 let original = DnsRecord::TLSA(TLSARecord {
1195 cert_usage: TlsaCertUsage::DaneEe,
1196 selector: TlsaSelector::Spki,
1197 matching: TlsaMatching::Sha256,
1198 cert_data: vec![0xab, 0xcd, 0xef],
1199 });
1200 let rendered = render_record_value(&original);
1201 let parsed = parse_value(DnsRecordType::TLSA, &rendered).unwrap();
1202 assert_eq!(parsed, original);
1203 }
1204
1205 #[test]
1206 fn test_canonical_query_string_sorts_pairs() {
1207 let q = canonical_query_string("type=A&name=example.com.");
1208 assert_eq!(q, "name=example.com.&type=A");
1209 }
1210}