Skip to main content

dns_update/providers/
edgedns.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::http::{HttpClient, HttpClientBuilder};
14use crate::utils::txt_chunks_to_text;
15use crate::{
16    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue as DnsKeyValue, MXRecord,
17    Result, SRVRecord,
18};
19use base64::Engine;
20use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
21use chrono::Utc;
22use serde::{Deserialize, Serialize};
23use std::time::Duration;
24
25const DEFAULT_API_BASE: &str = "/config-dns/v2";
26const DEFAULT_MAX_BODY: usize = 131072;
27
28#[derive(Debug, Clone)]
29pub struct EdgeDnsConfig {
30    pub host: String,
31    pub client_token: String,
32    pub client_secret: String,
33    pub access_token: String,
34    pub account_switch_key: Option<String>,
35    pub request_timeout: Option<Duration>,
36}
37
38#[derive(Clone)]
39pub struct EdgeDnsProvider {
40    client: HttpClient,
41    host: String,
42    scheme: String,
43    base_path: String,
44    client_token: String,
45    client_secret: String,
46    access_token: String,
47    account_switch_key: Option<String>,
48    max_body: usize,
49}
50
51#[derive(Serialize, Debug)]
52struct RecordBody<'a> {
53    name: &'a str,
54    #[serde(rename = "type")]
55    record_type: &'a str,
56    ttl: u32,
57    rdata: Vec<String>,
58}
59
60impl EdgeDnsProvider {
61    pub(crate) fn new(config: EdgeDnsConfig) -> Result<Self> {
62        if config.host.is_empty() {
63            return Err(Error::Client("edgedns: host is required".to_string()));
64        }
65        if config.client_token.is_empty()
66            || config.client_secret.is_empty()
67            || config.access_token.is_empty()
68        {
69            return Err(Error::Client(
70                "edgedns: client_token, client_secret and access_token are required".to_string(),
71            ));
72        }
73        let client = HttpClientBuilder::default()
74            .with_timeout(config.request_timeout)
75            .build();
76        let (host, scheme) = parse_host(&config.host);
77        Ok(Self {
78            client,
79            host,
80            scheme,
81            base_path: DEFAULT_API_BASE.to_string(),
82            client_token: config.client_token,
83            client_secret: config.client_secret,
84            access_token: config.access_token,
85            account_switch_key: config.account_switch_key,
86            max_body: DEFAULT_MAX_BODY,
87        })
88    }
89
90    #[cfg(test)]
91    pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
92        let endpoint = endpoint.as_ref().trim_end_matches('/').to_string();
93        let (host, scheme) = parse_host(&endpoint);
94        self.host = host;
95        self.scheme = scheme;
96        self
97    }
98
99    fn base_url(&self) -> String {
100        format!("{}://{}{}", self.scheme, self.host, self.base_path)
101    }
102
103    pub(crate) async fn set_rrset(
104        &self,
105        name: impl IntoFqdn<'_>,
106        record_type: DnsRecordType,
107        ttl: u32,
108        records: Vec<DnsRecord>,
109        origin: impl IntoFqdn<'_>,
110    ) -> Result<()> {
111        check_record_types(record_type, &records)?;
112        let name = name.into_name().to_ascii_lowercase();
113        let zone = origin.into_name().to_ascii_lowercase();
114        if zone.is_empty() {
115            return Err(Error::Api("edgedns: origin zone is required".to_string()));
116        }
117        let type_str = edgedns_record_type(record_type)?;
118        let path = self.record_path(&zone, &name, type_str);
119        let url = format!("{}{}", self.base_url(), path);
120
121        if records.is_empty() {
122            match self.send("DELETE", &url, None).await {
123                Ok(_) => return Ok(()),
124                Err(Error::NotFound) => return Ok(()),
125                Err(e) => return Err(e),
126            }
127        }
128
129        let rdata = build_rdata(record_type, &records)?;
130        let body = RecordBody {
131            name: &name,
132            record_type: type_str,
133            ttl,
134            rdata,
135        };
136        let payload =
137            serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
138        match self.send("PUT", &url, Some(&payload)).await {
139            Ok(_) => Ok(()),
140            Err(Error::NotFound) => {
141                self.send("POST", &url, Some(&payload)).await?;
142                Ok(())
143            }
144            Err(e) => Err(e),
145        }
146    }
147
148    pub(crate) async fn add_to_rrset(
149        &self,
150        name: impl IntoFqdn<'_>,
151        record_type: DnsRecordType,
152        ttl: u32,
153        records: Vec<DnsRecord>,
154        origin: impl IntoFqdn<'_>,
155    ) -> Result<()> {
156        check_record_types(record_type, &records)?;
157        if records.is_empty() {
158            return Ok(());
159        }
160        let name = name.into_name().to_ascii_lowercase();
161        let zone = origin.into_name().to_ascii_lowercase();
162        if zone.is_empty() {
163            return Err(Error::Api("edgedns: origin zone is required".to_string()));
164        }
165        let type_str = edgedns_record_type(record_type)?;
166        let path = self.record_path(&zone, &name, type_str);
167        let url = format!("{}{}", self.base_url(), path);
168
169        let current = self.fetch_rdata(&url).await?;
170        let desired = build_rdata(record_type, &records)?;
171        let mut merged = current;
172        for entry in desired {
173            if !merged.iter().any(|existing| existing == &entry) {
174                merged.push(entry);
175            }
176        }
177
178        let body = RecordBody {
179            name: &name,
180            record_type: type_str,
181            ttl,
182            rdata: merged,
183        };
184        let payload =
185            serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
186        match self.send("PUT", &url, Some(&payload)).await {
187            Ok(_) => Ok(()),
188            Err(Error::NotFound) => {
189                self.send("POST", &url, Some(&payload)).await?;
190                Ok(())
191            }
192            Err(e) => Err(e),
193        }
194    }
195
196    pub(crate) async fn remove_from_rrset(
197        &self,
198        name: impl IntoFqdn<'_>,
199        record_type: DnsRecordType,
200        records: Vec<DnsRecord>,
201        origin: impl IntoFqdn<'_>,
202    ) -> Result<()> {
203        check_record_types(record_type, &records)?;
204        if records.is_empty() {
205            return Ok(());
206        }
207        let name = name.into_name().to_ascii_lowercase();
208        let zone = origin.into_name().to_ascii_lowercase();
209        if zone.is_empty() {
210            return Err(Error::Api("edgedns: origin zone is required".to_string()));
211        }
212        let type_str = edgedns_record_type(record_type)?;
213        let path = self.record_path(&zone, &name, type_str);
214        let url = format!("{}{}", self.base_url(), path);
215
216        let current = self.fetch_rrset(&url).await?;
217        let Some(existing) = current else {
218            return Ok(());
219        };
220        let to_remove = build_rdata(record_type, &records)?;
221        let remaining: Vec<String> = existing
222            .rdata
223            .into_iter()
224            .filter(|entry| !to_remove.iter().any(|drop| drop == entry))
225            .collect();
226
227        if remaining.is_empty() {
228            match self.send("DELETE", &url, None).await {
229                Ok(_) => return Ok(()),
230                Err(Error::NotFound) => return Ok(()),
231                Err(e) => return Err(e),
232            }
233        }
234
235        let body = RecordBody {
236            name: &name,
237            record_type: type_str,
238            ttl: existing.ttl,
239            rdata: remaining,
240        };
241        let payload =
242            serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
243        self.send("PUT", &url, Some(&payload)).await?;
244        Ok(())
245    }
246
247    pub(crate) async fn list_rrset(
248        &self,
249        name: impl IntoFqdn<'_>,
250        record_type: DnsRecordType,
251        origin: impl IntoFqdn<'_>,
252    ) -> Result<Vec<DnsRecord>> {
253        let name = name.into_name().to_ascii_lowercase();
254        let zone = origin.into_name().to_ascii_lowercase();
255        if zone.is_empty() {
256            return Err(Error::Api("edgedns: origin zone is required".to_string()));
257        }
258        let type_str = edgedns_record_type(record_type)?;
259        let path = self.record_path(&zone, &name, type_str);
260        let url = format!("{}{}", self.base_url(), path);
261        let Some(current) = self.fetch_rrset(&url).await? else {
262            return Ok(Vec::new());
263        };
264        let mut out = Vec::with_capacity(current.rdata.len());
265        for entry in current.rdata {
266            out.push(rdata_to_record(record_type, &entry)?);
267        }
268        Ok(out)
269    }
270
271    async fn fetch_rrset(&self, url: &str) -> Result<Option<RecordResponse>> {
272        match self.send("GET", url, None).await {
273            Ok(text) => {
274                let parsed: RecordResponse = serde_json::from_str(&text)
275                    .map_err(|e| Error::Parse(format!("edgedns rrset parse: {e}")))?;
276                Ok(Some(parsed))
277            }
278            Err(Error::NotFound) => Ok(None),
279            Err(e) => Err(e),
280        }
281    }
282
283    async fn fetch_rdata(&self, url: &str) -> Result<Vec<String>> {
284        Ok(self
285            .fetch_rrset(url)
286            .await?
287            .map(|r| r.rdata)
288            .unwrap_or_default())
289    }
290
291    fn record_path(&self, zone: &str, name: &str, record_type: &str) -> String {
292        format!(
293            "/zones/{}/names/{}/types/{}",
294            url_encode(zone),
295            url_encode(name),
296            record_type
297        )
298    }
299
300    async fn send(&self, method: &str, url: &str, body: Option<&str>) -> Result<String> {
301        let parsed = url
302            .parse::<reqwest::Url>()
303            .map_err(|e| Error::Client(format!("edgedns url parse: {e}")))?;
304        let path_query = match parsed.query() {
305            Some(q) => format!("{}?{}", parsed.path(), q),
306            None => parsed.path().to_string(),
307        };
308        let host = match parsed.port() {
309            Some(p) => format!("{}:{}", parsed.host_str().unwrap_or(""), p),
310            None => parsed.host_str().unwrap_or("").to_string(),
311        };
312        let scheme = parsed.scheme().to_string();
313        let timestamp = Utc::now().format("%Y%m%dT%H:%M:%S+0000").to_string();
314        let nonce = generate_nonce();
315
316        let body_for_hash = if matches!(method, "POST" | "PUT") {
317            body.unwrap_or("").as_bytes()
318        } else {
319            &[][..]
320        };
321        let content_hash = if body_for_hash.is_empty() {
322            String::new()
323        } else {
324            let truncated = if body_for_hash.len() > self.max_body {
325                &body_for_hash[..self.max_body]
326            } else {
327                body_for_hash
328            };
329            BASE64_STANDARD.encode(sha256_digest(truncated))
330        };
331
332        let auth_without_signature = format!(
333            "EG1-HMAC-SHA256 client_token={};access_token={};timestamp={};nonce={};",
334            self.client_token, self.access_token, timestamp, nonce
335        );
336
337        let canonical_headers = String::new();
338        let data_to_sign = format!(
339            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
340            method.to_ascii_uppercase(),
341            scheme,
342            host,
343            path_query,
344            canonical_headers,
345            content_hash,
346            auth_without_signature
347        );
348
349        let signing_key = BASE64_STANDARD.encode(hmac_sha256(
350            self.client_secret.as_bytes(),
351            timestamp.as_bytes(),
352        ));
353        let signature =
354            BASE64_STANDARD.encode(hmac_sha256(signing_key.as_bytes(), data_to_sign.as_bytes()));
355        let authorization = format!("{}signature={}", auth_without_signature, signature);
356
357        let mut request = match method {
358            "GET" => self.client.get(url),
359            "POST" => self.client.post(url),
360            "PUT" => self.client.put(url),
361            "DELETE" => self.client.delete(url),
362            other => {
363                return Err(Error::Unsupported(format!(
364                    "edgedns unsupported method: {other}"
365                )));
366            }
367        };
368        request = request
369            .with_header("Authorization", authorization)
370            .with_header("Accept", "application/json");
371        if let Some(asw) = &self.account_switch_key {
372            request = request.with_header("X-AccountSwitchKey", asw);
373        }
374        if let Some(body) = body {
375            request = request
376                .with_header("Content-Type", "application/json")
377                .with_raw_body(body.to_string());
378        }
379
380        request.send_raw().await
381    }
382}
383
384fn parse_host(input: &str) -> (String, String) {
385    let trimmed = input.trim_end_matches('/');
386    if let Some(rest) = trimmed.strip_prefix("https://") {
387        return (rest.to_string(), "https".to_string());
388    }
389    if let Some(rest) = trimmed.strip_prefix("http://") {
390        return (rest.to_string(), "http".to_string());
391    }
392    (trimmed.to_string(), "https".to_string())
393}
394
395fn url_encode(value: &str) -> String {
396    let mut out = String::with_capacity(value.len());
397    for byte in value.as_bytes() {
398        let c = *byte as char;
399        if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
400            out.push(c);
401        } else {
402            out.push_str(&format!("%{:02X}", byte));
403        }
404    }
405    out
406}
407
408fn generate_nonce() -> String {
409    let now = Utc::now();
410    let nanos = now.timestamp_nanos_opt().unwrap_or(now.timestamp());
411    let mut buf = [0u8; 16];
412    let bytes = (nanos as u128).to_le_bytes();
413    buf.copy_from_slice(&bytes);
414    format!(
415        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
416        u32::from_le_bytes(buf[0..4].try_into().unwrap()),
417        u16::from_le_bytes(buf[4..6].try_into().unwrap()),
418        u16::from_le_bytes(buf[6..8].try_into().unwrap()),
419        u16::from_le_bytes(buf[8..10].try_into().unwrap()),
420        {
421            let mut full = [0u8; 8];
422            full[2..8].copy_from_slice(&buf[10..16]);
423            u64::from_be_bytes(full)
424        }
425    )
426}
427
428fn edgedns_record_type(record_type: DnsRecordType) -> Result<&'static str> {
429    match record_type {
430        DnsRecordType::A => Ok("A"),
431        DnsRecordType::AAAA => Ok("AAAA"),
432        DnsRecordType::CNAME => Ok("CNAME"),
433        DnsRecordType::NS => Ok("NS"),
434        DnsRecordType::MX => Ok("MX"),
435        DnsRecordType::TXT => Ok("TXT"),
436        DnsRecordType::SRV => Ok("SRV"),
437        DnsRecordType::CAA => Ok("CAA"),
438        DnsRecordType::TLSA => Err(Error::Unsupported(
439            "TLSA records are not supported by EdgeDNS".to_string(),
440        )),
441    }
442}
443
444struct EdgeDnsRecord {
445    record_type: String,
446    rdata: Vec<String>,
447}
448
449impl TryFrom<&DnsRecord> for EdgeDnsRecord {
450    type Error = Error;
451
452    fn try_from(record: &DnsRecord) -> Result<Self> {
453        Ok(match record {
454            DnsRecord::A(addr) => Self {
455                record_type: "A".to_string(),
456                rdata: vec![addr.to_string()],
457            },
458            DnsRecord::AAAA(addr) => Self {
459                record_type: "AAAA".to_string(),
460                rdata: vec![addr.to_string()],
461            },
462            DnsRecord::CNAME(value) => Self {
463                record_type: "CNAME".to_string(),
464                rdata: vec![ensure_dot(value)],
465            },
466            DnsRecord::NS(value) => Self {
467                record_type: "NS".to_string(),
468                rdata: vec![ensure_dot(value)],
469            },
470            DnsRecord::MX(MXRecord { priority, exchange }) => Self {
471                record_type: "MX".to_string(),
472                rdata: vec![format!("{} {}", priority, ensure_dot(exchange))],
473            },
474            DnsRecord::TXT(value) => Self {
475                record_type: "TXT".to_string(),
476                rdata: vec![{
477                    let mut out = String::new();
478                    txt_chunks_to_text(&mut out, value, " ");
479                    out
480                }],
481            },
482            DnsRecord::SRV(SRVRecord {
483                target,
484                priority,
485                weight,
486                port,
487            }) => Self {
488                record_type: "SRV".to_string(),
489                rdata: vec![format!(
490                    "{} {} {} {}",
491                    priority,
492                    weight,
493                    port,
494                    ensure_dot(target)
495                )],
496            },
497            DnsRecord::CAA(caa) => Self {
498                record_type: "CAA".to_string(),
499                rdata: vec![caa_to_rdata(caa)],
500            },
501            DnsRecord::TLSA(_) => {
502                return Err(Error::Unsupported(
503                    "TLSA records are not supported by EdgeDNS".to_string(),
504                ));
505            }
506        })
507    }
508}
509
510fn caa_to_rdata(caa: &CAARecord) -> String {
511    match caa {
512        CAARecord::Issue {
513            issuer_critical,
514            name,
515            options,
516        } => {
517            let flags = if *issuer_critical { 128 } else { 0 };
518            let mut value = name.clone().unwrap_or_default();
519            for opt in options {
520                value.push_str(&format!(";{}", opt));
521            }
522            format!("{} issue \"{}\"", flags, value)
523        }
524        CAARecord::IssueWild {
525            issuer_critical,
526            name,
527            options,
528        } => {
529            let flags = if *issuer_critical { 128 } else { 0 };
530            let mut value = name.clone().unwrap_or_default();
531            for opt in options {
532                value.push_str(&format!(";{}", opt));
533            }
534            format!("{} issuewild \"{}\"", flags, value)
535        }
536        CAARecord::Iodef {
537            issuer_critical,
538            url,
539        } => {
540            let flags = if *issuer_critical { 128 } else { 0 };
541            format!("{} iodef \"{}\"", flags, url)
542        }
543    }
544}
545
546fn ensure_dot(value: &str) -> String {
547    if value.ends_with('.') {
548        value.to_string()
549    } else {
550        format!("{}.", value)
551    }
552}
553
554#[derive(Deserialize)]
555struct RecordResponse {
556    #[serde(default)]
557    ttl: u32,
558    #[serde(default)]
559    rdata: Vec<String>,
560}
561
562fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> Result<()> {
563    for r in records {
564        if r.as_type() != expected {
565            return Err(Error::Api(format!(
566                "RRSet record type mismatch: expected {}, got {}",
567                expected.as_str(),
568                r.as_type().as_str(),
569            )));
570        }
571    }
572    Ok(())
573}
574
575fn build_rdata(record_type: DnsRecordType, records: &[DnsRecord]) -> Result<Vec<String>> {
576    let mut out = Vec::with_capacity(records.len());
577    for record in records {
578        let representation = EdgeDnsRecord::try_from(record)?;
579        let expected_type = edgedns_record_type(record_type)?;
580        if representation.record_type != expected_type {
581            return Err(Error::Api(format!(
582                "RRSet record type mismatch: expected {}, got {}",
583                expected_type, representation.record_type,
584            )));
585        }
586        for entry in representation.rdata {
587            out.push(entry);
588        }
589    }
590    Ok(out)
591}
592
593fn rdata_to_record(record_type: DnsRecordType, entry: &str) -> Result<DnsRecord> {
594    match record_type {
595        DnsRecordType::A => entry
596            .parse()
597            .map(DnsRecord::A)
598            .map_err(|e| Error::Parse(format!("edgedns A rdata: {e}"))),
599        DnsRecordType::AAAA => entry
600            .parse()
601            .map(DnsRecord::AAAA)
602            .map_err(|e| Error::Parse(format!("edgedns AAAA rdata: {e}"))),
603        DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(entry))),
604        DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(entry))),
605        DnsRecordType::MX => {
606            let (priority_str, exchange) = entry
607                .split_once(' ')
608                .ok_or_else(|| Error::Parse(format!("edgedns MX rdata: {entry}")))?;
609            let priority: u16 = priority_str
610                .parse()
611                .map_err(|e| Error::Parse(format!("edgedns MX priority: {e}")))?;
612            Ok(DnsRecord::MX(MXRecord {
613                priority,
614                exchange: strip_trailing_dot(exchange.trim()),
615            }))
616        }
617        DnsRecordType::TXT => Ok(DnsRecord::TXT(parse_txt_rdata(entry))),
618        DnsRecordType::SRV => {
619            let mut parts = entry.split_whitespace();
620            let priority: u16 = parts
621                .next()
622                .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
623                .parse()
624                .map_err(|e| Error::Parse(format!("edgedns SRV priority: {e}")))?;
625            let weight: u16 = parts
626                .next()
627                .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
628                .parse()
629                .map_err(|e| Error::Parse(format!("edgedns SRV weight: {e}")))?;
630            let port: u16 = parts
631                .next()
632                .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
633                .parse()
634                .map_err(|e| Error::Parse(format!("edgedns SRV port: {e}")))?;
635            let target = parts
636                .next()
637                .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?;
638            Ok(DnsRecord::SRV(SRVRecord {
639                priority,
640                weight,
641                port,
642                target: strip_trailing_dot(target),
643            }))
644        }
645        DnsRecordType::CAA => parse_caa_rdata(entry),
646        DnsRecordType::TLSA => Err(Error::Unsupported(
647            "TLSA records are not supported by EdgeDNS".to_string(),
648        )),
649    }
650}
651
652fn strip_trailing_dot(value: &str) -> String {
653    value.trim_end_matches('.').to_string()
654}
655
656fn parse_txt_rdata(entry: &str) -> String {
657    let trimmed = entry.trim();
658    let mut out = String::new();
659    let chars = trimmed.chars().peekable();
660    let mut in_quotes = false;
661    let mut escape = false;
662    for ch in chars {
663        if escape {
664            out.push(ch);
665            escape = false;
666            continue;
667        }
668        match ch {
669            '\\' if in_quotes => escape = true,
670            '"' => in_quotes = !in_quotes,
671            _ if in_quotes => out.push(ch),
672            _ => {}
673        }
674    }
675    if out.is_empty() {
676        trimmed.to_string()
677    } else {
678        out
679    }
680}
681
682fn parse_caa_rdata(entry: &str) -> Result<DnsRecord> {
683    let mut parts = entry.splitn(3, ' ');
684    let flags_str = parts
685        .next()
686        .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
687    let tag = parts
688        .next()
689        .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
690    let value_quoted = parts
691        .next()
692        .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
693    let flags: u8 = flags_str
694        .parse()
695        .map_err(|e| Error::Parse(format!("edgedns CAA flags: {e}")))?;
696    let issuer_critical = flags & 128 != 0;
697    let value = value_quoted
698        .trim()
699        .trim_start_matches('"')
700        .trim_end_matches('"')
701        .to_string();
702    match tag {
703        "issue" => {
704            let (name, options) = parse_caa_value(&value);
705            Ok(DnsRecord::CAA(CAARecord::Issue {
706                issuer_critical,
707                name,
708                options,
709            }))
710        }
711        "issuewild" => {
712            let (name, options) = parse_caa_value(&value);
713            Ok(DnsRecord::CAA(CAARecord::IssueWild {
714                issuer_critical,
715                name,
716                options,
717            }))
718        }
719        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
720            issuer_critical,
721            url: value,
722        })),
723        other => Err(Error::Unsupported(format!(
724            "edgedns CAA tag unsupported: {other}"
725        ))),
726    }
727}
728
729fn parse_caa_value(value: &str) -> (Option<String>, Vec<DnsKeyValue>) {
730    let mut parts = value.split(';').map(str::trim);
731    let head = parts.next().unwrap_or("").to_string();
732    let name = if head.is_empty() { None } else { Some(head) };
733    let options = parts
734        .filter(|p| !p.is_empty())
735        .map(|p| match p.split_once('=') {
736            Some((k, v)) => DnsKeyValue {
737                key: k.trim().to_string(),
738                value: v.trim().to_string(),
739            },
740            None => DnsKeyValue {
741                key: p.trim().to_string(),
742                value: String::new(),
743            },
744        })
745        .collect();
746    (name, options)
747}