Skip to main content

dns_update/providers/
route53.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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}