Skip to main content

dns_update/providers/
oraclecloud.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
12#![cfg(any(feature = "ring", feature = "aws-lc-rs"))]
13
14use crate::crypto::sha256_digest;
15use crate::jwt::{parse_rsa_pkcs8_pem, rsa_sha256_sign};
16use crate::utils::split_caa_value;
17use crate::utils::strip_trailing_dot;
18use crate::utils::txt_chunks_to_text;
19use crate::utils::{parse_mx, parse_srv, parse_tlsa};
20use crate::{CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, Result};
21use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
22use jiff::Timestamp;
23use reqwest::Method;
24use reqwest::header::{HeaderMap, HeaderValue};
25use serde::{Deserialize, Serialize};
26use std::net::AddrParseError;
27use std::sync::Arc;
28use std::time::Duration;
29
30#[cfg(feature = "ring")]
31use ring::signature::RsaKeyPair;
32
33#[cfg(all(feature = "aws-lc-rs", not(feature = "ring")))]
34use aws_lc_rs::signature::RsaKeyPair;
35
36const RETRIES: u32 = 3;
37const PAGE_LIMIT: u32 = 1000;
38
39#[derive(Debug, Clone)]
40pub struct OracleCloudConfig {
41    pub tenancy_ocid: String,
42    pub user_ocid: String,
43    pub fingerprint: String,
44    pub private_key_pem: String,
45    pub private_key_password: Option<String>,
46    pub region: String,
47    pub compartment_ocid: String,
48    pub request_timeout: Option<Duration>,
49}
50
51#[derive(Clone)]
52pub struct OracleCloudProvider {
53    config: OracleCloudConfig,
54    key_pair: Arc<RsaKeyPair>,
55    endpoint: String,
56}
57
58#[derive(Debug, Serialize, Deserialize, Clone)]
59struct OciRecord {
60    domain: String,
61    rtype: String,
62    rdata: String,
63    ttl: u32,
64    #[serde(rename = "isProtected", skip_serializing_if = "Option::is_none")]
65    is_protected: Option<bool>,
66    #[serde(rename = "recordHash", skip_serializing_if = "Option::is_none")]
67    record_hash: Option<String>,
68}
69
70#[derive(Debug, Serialize)]
71struct UpdateRecordsRequest {
72    items: Vec<OciRecord>,
73}
74
75#[derive(Debug, Serialize)]
76struct PatchRecordsRequest {
77    items: Vec<PatchOperation>,
78}
79
80#[derive(Debug, Serialize)]
81struct PatchOperation {
82    operation: &'static str,
83    rdata: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    ttl: Option<u32>,
86}
87
88#[derive(Debug, Deserialize)]
89struct RecordCollection {
90    items: Vec<OciRecord>,
91}
92
93#[derive(Debug, Deserialize)]
94struct Zone {
95    name: String,
96    id: String,
97}
98
99impl OracleCloudProvider {
100    pub(crate) fn new(config: OracleCloudConfig) -> Result<Self> {
101        if config.tenancy_ocid.is_empty() {
102            return Err(Error::Client("tenancy_ocid is required".into()));
103        }
104        if config.user_ocid.is_empty() {
105            return Err(Error::Client("user_ocid is required".into()));
106        }
107        if config.fingerprint.is_empty() {
108            return Err(Error::Client("fingerprint is required".into()));
109        }
110        if config.region.is_empty() {
111            return Err(Error::Client("region is required".into()));
112        }
113        if config.compartment_ocid.is_empty() {
114            return Err(Error::Client("compartment_ocid is required".into()));
115        }
116        if config
117            .private_key_password
118            .as_ref()
119            .is_some_and(|p| !p.is_empty())
120        {
121            return Err(Error::Unsupported(
122                "OCI private keys with a passphrase are not supported".into(),
123            ));
124        }
125
126        let key_pair = parse_rsa_pkcs8_pem(&config.private_key_pem)
127            .map_err(|e| Error::Client(format!("Failed to parse OCI private key: {}", e)))?;
128
129        let endpoint = format!("https://dns.{}.oraclecloud.com", config.region);
130
131        Ok(Self {
132            config,
133            key_pair: Arc::new(key_pair),
134            endpoint,
135        })
136    }
137
138    #[cfg(test)]
139    pub(crate) fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
140        self.endpoint = endpoint.into().trim_end_matches('/').to_string();
141        self
142    }
143
144    fn key_id(&self) -> String {
145        format!(
146            "{}/{}/{}",
147            self.config.tenancy_ocid, self.config.user_ocid, self.config.fingerprint
148        )
149    }
150
151    fn sign_request(&self, method: &Method, url: &str, body: Option<&str>) -> Result<HeaderMap> {
152        let parsed = reqwest::Url::parse(url)
153            .map_err(|e| Error::Client(format!("Failed to parse URL {}: {}", url, e)))?;
154        let host = parsed
155            .host_str()
156            .ok_or_else(|| Error::Client(format!("URL missing host: {}", url)))?
157            .to_string();
158        let host_header = if let Some(port) = parsed.port() {
159            format!("{}:{}", host, port)
160        } else {
161            host.clone()
162        };
163        let mut path_and_query = parsed.path().to_string();
164        if let Some(q) = parsed.query() {
165            path_and_query.push('?');
166            path_and_query.push_str(q);
167        }
168
169        let method_lower = method.as_str().to_lowercase();
170        let date = Timestamp::now()
171            .strftime("%a, %d %b %Y %H:%M:%S GMT")
172            .to_string();
173
174        let mut signed_pairs: Vec<(String, String)> = Vec::new();
175        signed_pairs.push((
176            "(request-target)".to_string(),
177            format!("{} {}", method_lower, path_and_query),
178        ));
179        signed_pairs.push(("host".to_string(), host_header.clone()));
180        signed_pairs.push(("date".to_string(), date.clone()));
181
182        let needs_body_headers = matches!(*method, Method::POST | Method::PUT | Method::PATCH);
183        let body_bytes = body.unwrap_or("").as_bytes();
184        let content_sha256 = B64.encode(sha256_digest(body_bytes));
185        let content_length = body_bytes.len().to_string();
186        if needs_body_headers {
187            signed_pairs.push(("x-content-sha256".to_string(), content_sha256.clone()));
188            signed_pairs.push(("content-type".to_string(), "application/json".to_string()));
189            signed_pairs.push(("content-length".to_string(), content_length.clone()));
190        }
191
192        let signing_string = signed_pairs
193            .iter()
194            .map(|(k, v)| format!("{}: {}", k, v))
195            .collect::<Vec<_>>()
196            .join("\n");
197        let signature = rsa_sha256_sign(&self.key_pair, signing_string.as_bytes())
198            .map_err(|e| Error::Client(format!("Failed to sign request: {}", e)))?;
199        let signature_b64 = B64.encode(&signature);
200
201        let headers_list = signed_pairs
202            .iter()
203            .map(|(k, _)| k.as_str())
204            .collect::<Vec<_>>()
205            .join(" ");
206        let authorization = format!(
207            "Signature version=\"1\",keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"{}\",signature=\"{}\"",
208            self.key_id(),
209            headers_list,
210            signature_b64,
211        );
212
213        let mut headers = HeaderMap::new();
214        headers.insert(
215            "host",
216            HeaderValue::from_str(&host_header)
217                .map_err(|e| Error::Client(format!("Invalid host header: {}", e)))?,
218        );
219        headers.insert(
220            "date",
221            HeaderValue::from_str(&date)
222                .map_err(|e| Error::Client(format!("Invalid date header: {}", e)))?,
223        );
224        headers.insert(
225            "authorization",
226            HeaderValue::from_str(&authorization)
227                .map_err(|e| Error::Client(format!("Invalid authorization header: {}", e)))?,
228        );
229        if needs_body_headers {
230            headers.insert(
231                "x-content-sha256",
232                HeaderValue::from_str(&content_sha256)
233                    .map_err(|e| Error::Client(format!("Invalid x-content-sha256: {}", e)))?,
234            );
235            headers.insert("content-type", HeaderValue::from_static("application/json"));
236            headers.insert(
237                "content-length",
238                HeaderValue::from_str(&content_length)
239                    .map_err(|e| Error::Client(format!("Invalid content-length: {}", e)))?,
240            );
241        }
242
243        Ok(headers)
244    }
245
246    async fn send_signed(
247        &self,
248        method: Method,
249        url: &str,
250        body: Option<String>,
251    ) -> Result<(reqwest::StatusCode, String, HeaderMap)> {
252        let client = reqwest::Client::builder()
253            .timeout(
254                self.config
255                    .request_timeout
256                    .unwrap_or(Duration::from_secs(30)),
257            )
258            .build()
259            .map_err(|e| Error::Client(format!("Failed to build HTTP client: {}", e)))?;
260
261        let mut attempts: u32 = 0;
262        loop {
263            let headers = self.sign_request(&method, url, body.as_deref())?;
264            let mut request = client.request(method.clone(), url).headers(headers);
265            if let Some(b) = body.as_ref() {
266                request = request.body(b.clone());
267            }
268            let response = request
269                .send()
270                .await
271                .map_err(|e| Error::Api(format!("Failed to send request to {}: {}", url, e)))?;
272            let status = response.status();
273            let response_headers = response.headers().clone();
274
275            if status.as_u16() == 429 && attempts < RETRIES {
276                let retry_after = response_headers
277                    .get("retry-after")
278                    .and_then(|v| v.to_str().ok())
279                    .and_then(|s| s.parse::<u64>().ok())
280                    .unwrap_or(1);
281                tokio::time::sleep(Duration::from_secs(retry_after)).await;
282                attempts += 1;
283                continue;
284            }
285
286            let text = response
287                .text()
288                .await
289                .map_err(|e| Error::Api(format!("Failed to read response body: {}", e)))?;
290            return Ok((status, text, response_headers));
291        }
292    }
293
294    fn record_to_rdata(record: &DnsRecord) -> Result<(String, String)> {
295        let (rtype, rdata) = match record {
296            DnsRecord::A(ip) => ("A".to_string(), ip.to_string()),
297            DnsRecord::AAAA(ip) => ("AAAA".to_string(), ip.to_string()),
298            DnsRecord::CNAME(c) => ("CNAME".to_string(), format_target(c)),
299            DnsRecord::NS(n) => ("NS".to_string(), format_target(n)),
300            DnsRecord::MX(mx) => (
301                "MX".to_string(),
302                format!("{} {}", mx.priority, format_target(&mx.exchange)),
303            ),
304            DnsRecord::TXT(txt) => {
305                let mut rdata = String::new();
306                txt_chunks_to_text(&mut rdata, txt, " ");
307                ("TXT".to_string(), rdata)
308            }
309            DnsRecord::SRV(srv) => (
310                "SRV".to_string(),
311                format!(
312                    "{} {} {} {}",
313                    srv.priority,
314                    srv.weight,
315                    srv.port,
316                    format_target(&srv.target)
317                ),
318            ),
319            DnsRecord::CAA(caa) => {
320                let (flags, tag, value) = caa.clone().decompose();
321                (
322                    "CAA".to_string(),
323                    format!("{} {} \"{}\"", flags, tag, value),
324                )
325            }
326            DnsRecord::TLSA(tlsa) => ("TLSA".to_string(), tlsa.to_string()),
327        };
328        Ok((rtype, rdata))
329    }
330
331    async fn resolve_zone(&self, origin: &str) -> Result<String> {
332        let trimmed = origin.trim_end_matches('.');
333        let url = format!(
334            "{}/20180115/zones?compartmentId={}&name={}",
335            self.endpoint,
336            urlencode(&self.config.compartment_ocid),
337            urlencode(trimmed),
338        );
339        let (status, body, _) = self.send_signed(Method::GET, &url, None).await?;
340        if !status.is_success() {
341            return Err(map_error(status, &body));
342        }
343        let zones: Vec<Zone> = serde_json::from_str(&body)
344            .map_err(|e| Error::Serialize(format!("Failed to parse zones list: {}", e)))?;
345        zones
346            .into_iter()
347            .find(|z| z.name.trim_end_matches('.') == trimmed)
348            .map(|z| z.id)
349            .ok_or(Error::NotFound)
350    }
351
352    fn records_url(&self, zone_id: &str, domain: &str, rtype: &str) -> String {
353        format!(
354            "{}/20180115/zones/{}/records/{}/{}?compartmentId={}",
355            self.endpoint,
356            urlencode(zone_id),
357            urlencode(domain),
358            urlencode(rtype),
359            urlencode(&self.config.compartment_ocid),
360        )
361    }
362
363    fn records_url_paged(
364        &self,
365        zone_id: &str,
366        domain: &str,
367        rtype: &str,
368        page: Option<&str>,
369    ) -> String {
370        let mut url = format!(
371            "{}/20180115/zones/{}/records/{}/{}?compartmentId={}&limit={}",
372            self.endpoint,
373            urlencode(zone_id),
374            urlencode(domain),
375            urlencode(rtype),
376            urlencode(&self.config.compartment_ocid),
377            PAGE_LIMIT,
378        );
379        if let Some(page) = page {
380            url.push_str("&page=");
381            url.push_str(&urlencode(page));
382        }
383        url
384    }
385
386    async fn get_records(
387        &self,
388        zone_id: &str,
389        domain: &str,
390        rtype: &str,
391    ) -> Result<Vec<OciRecord>> {
392        let mut all: Vec<OciRecord> = Vec::new();
393        let mut next_page: Option<String> = None;
394        loop {
395            let url = self.records_url_paged(zone_id, domain, rtype, next_page.as_deref());
396            let (status, body, headers) = self.send_signed(Method::GET, &url, None).await?;
397            if status.as_u16() == 404 {
398                return Ok(Vec::new());
399            }
400            if !status.is_success() {
401                return Err(map_error(status, &body));
402            }
403            let collection: RecordCollection = serde_json::from_str(&body)
404                .map_err(|e| Error::Serialize(format!("Failed to parse records: {}", e)))?;
405            all.extend(collection.items);
406            next_page = headers
407                .get("opc-next-page")
408                .and_then(|v| v.to_str().ok())
409                .map(|s| s.to_string());
410            if next_page.is_none() {
411                return Ok(all);
412            }
413        }
414    }
415
416    async fn put_records(
417        &self,
418        zone_id: &str,
419        domain: &str,
420        rtype: &str,
421        items: Vec<OciRecord>,
422    ) -> Result<()> {
423        let url = self.records_url(zone_id, domain, rtype);
424        let request = UpdateRecordsRequest { items };
425        let body = serde_json::to_string(&request)
426            .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
427        let (status, response_body, _) = self.send_signed(Method::PUT, &url, Some(body)).await?;
428        if !status.is_success() {
429            return Err(map_error(status, &response_body));
430        }
431        Ok(())
432    }
433
434    async fn patch_records(
435        &self,
436        zone_id: &str,
437        domain: &str,
438        rtype: &str,
439        items: Vec<PatchOperation>,
440    ) -> Result<()> {
441        let url = self.records_url(zone_id, domain, rtype);
442        let request = PatchRecordsRequest { items };
443        let body = serde_json::to_string(&request)
444            .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
445        let (status, response_body, _) = self.send_signed(Method::PATCH, &url, Some(body)).await?;
446        if !status.is_success() {
447            return Err(map_error(status, &response_body));
448        }
449        Ok(())
450    }
451
452    async fn delete_rrset(&self, zone_id: &str, domain: &str, rtype: &str) -> Result<()> {
453        let url = self.records_url(zone_id, domain, rtype);
454        let (status, body, _) = self.send_signed(Method::DELETE, &url, None).await?;
455        if status.as_u16() == 404 {
456            return Ok(());
457        }
458        if !status.is_success() {
459            return Err(map_error(status, &body));
460        }
461        Ok(())
462    }
463
464    pub(crate) async fn set_rrset(
465        &self,
466        name: impl IntoFqdn<'_>,
467        record_type: DnsRecordType,
468        ttl: u32,
469        records: Vec<DnsRecord>,
470        origin: impl IntoFqdn<'_>,
471    ) -> Result<()> {
472        check_record_types(record_type, &records)?;
473        let name = name.into_name().to_string();
474        let origin = origin.into_name().to_string();
475        let zone_id = self.resolve_zone(&origin).await?;
476        let rtype = record_type.as_str();
477
478        if records.is_empty() {
479            return self.delete_rrset(&zone_id, &name, rtype).await;
480        }
481
482        let mut items = Vec::with_capacity(records.len());
483        for record in records {
484            let (_, rdata) = Self::record_to_rdata(&record)?;
485            items.push(OciRecord {
486                domain: name.clone(),
487                rtype: rtype.to_string(),
488                rdata,
489                ttl,
490                is_protected: None,
491                record_hash: None,
492            });
493        }
494        self.put_records(&zone_id, &name, rtype, items).await
495    }
496
497    pub(crate) async fn add_to_rrset(
498        &self,
499        name: impl IntoFqdn<'_>,
500        record_type: DnsRecordType,
501        ttl: u32,
502        records: Vec<DnsRecord>,
503        origin: impl IntoFqdn<'_>,
504    ) -> Result<()> {
505        check_record_types(record_type, &records)?;
506        if records.is_empty() {
507            return Ok(());
508        }
509        let name = name.into_name().to_string();
510        let origin = origin.into_name().to_string();
511        let zone_id = self.resolve_zone(&origin).await?;
512        let rtype = record_type.as_str();
513
514        let existing = self.get_records(&zone_id, &name, rtype).await?;
515        let mut items = Vec::with_capacity(records.len());
516        for record in records {
517            let (_, rdata) = Self::record_to_rdata(&record)?;
518            if existing
519                .iter()
520                .any(|e| e.rtype.eq_ignore_ascii_case(rtype) && e.rdata == rdata)
521            {
522                continue;
523            }
524            items.push(PatchOperation {
525                operation: "ADD",
526                rdata,
527                ttl: Some(ttl),
528            });
529        }
530        if items.is_empty() {
531            return Ok(());
532        }
533        self.patch_records(&zone_id, &name, rtype, items).await
534    }
535
536    pub(crate) async fn remove_from_rrset(
537        &self,
538        name: impl IntoFqdn<'_>,
539        record_type: DnsRecordType,
540        records: Vec<DnsRecord>,
541        origin: impl IntoFqdn<'_>,
542    ) -> Result<()> {
543        check_record_types(record_type, &records)?;
544        if records.is_empty() {
545            return Ok(());
546        }
547        let name = name.into_name().to_string();
548        let origin = origin.into_name().to_string();
549        let zone_id = self.resolve_zone(&origin).await?;
550        let rtype = record_type.as_str();
551
552        let mut items = Vec::with_capacity(records.len());
553        for record in records {
554            let (_, rdata) = Self::record_to_rdata(&record)?;
555            items.push(PatchOperation {
556                operation: "REMOVE",
557                rdata,
558                ttl: None,
559            });
560        }
561        match self.patch_records(&zone_id, &name, rtype, items).await {
562            Ok(()) => Ok(()),
563            Err(Error::NotFound) => Ok(()),
564            Err(e) => Err(e),
565        }
566    }
567
568    pub(crate) async fn list_rrset(
569        &self,
570        name: impl IntoFqdn<'_>,
571        record_type: DnsRecordType,
572        origin: impl IntoFqdn<'_>,
573    ) -> Result<Vec<DnsRecord>> {
574        let name = name.into_name().to_string();
575        let origin = origin.into_name().to_string();
576        let zone_id = match self.resolve_zone(&origin).await {
577            Ok(id) => id,
578            Err(Error::NotFound) => return Ok(Vec::new()),
579            Err(e) => return Err(e),
580        };
581        let rtype = record_type.as_str();
582        let items = self.get_records(&zone_id, &name, rtype).await?;
583        let mut out = Vec::with_capacity(items.len());
584        for item in items {
585            if !item.rtype.eq_ignore_ascii_case(rtype) {
586                continue;
587            }
588            out.push(parse_rdata(record_type, &item.rdata)?);
589        }
590        Ok(out)
591    }
592}
593
594fn format_target(value: &str) -> String {
595    format!("{}.", value.trim_end_matches('.'))
596}
597
598fn urlencode(value: &str) -> String {
599    serde_urlencoded::to_string([("v", value)])
600        .ok()
601        .and_then(|s| s.strip_prefix("v=").map(str::to_string))
602        .unwrap_or_else(|| value.to_string())
603}
604
605fn map_error(status: reqwest::StatusCode, body: &str) -> Error {
606    match status.as_u16() {
607        400 => Error::BadRequest,
608        401 | 403 => Error::Unauthorized,
609        404 => Error::NotFound,
610        _ => Error::Api(format!("Oracle Cloud DNS error {}: {}", status, body)),
611    }
612}
613
614fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> Result<()> {
615    for r in records {
616        if r.as_type() != expected {
617            return Err(Error::Api(format!(
618                "RRSet record type mismatch: expected {}, got {}",
619                expected.as_str(),
620                r.as_type().as_str(),
621            )));
622        }
623    }
624    Ok(())
625}
626
627fn parse_rdata(record_type: DnsRecordType, value: &str) -> Result<DnsRecord> {
628    Ok(match record_type {
629        DnsRecordType::A => DnsRecord::A(value.parse().map_err(|e: AddrParseError| {
630            Error::Parse(format!("invalid A value '{value}': {e}"))
631        })?),
632        DnsRecordType::AAAA => DnsRecord::AAAA(value.parse().map_err(|e: AddrParseError| {
633            Error::Parse(format!("invalid AAAA value '{value}': {e}"))
634        })?),
635        DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(value).to_string()),
636        DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(value).to_string()),
637        DnsRecordType::MX => parse_mx(value)?,
638        DnsRecordType::TXT => DnsRecord::TXT(parse_txt(value)),
639        DnsRecordType::SRV => parse_srv(value)?,
640        DnsRecordType::TLSA => parse_tlsa(value)?,
641        DnsRecordType::CAA => parse_caa(value)?,
642    })
643}
644
645fn parse_txt(value: &str) -> String {
646    let trimmed = value.trim();
647    let mut out = String::with_capacity(trimmed.len());
648    let mut bytes = trimmed.bytes().peekable();
649    while let Some(&b) = bytes.peek() {
650        if b != b'"' {
651            bytes.next();
652            continue;
653        }
654        bytes.next();
655        loop {
656            match bytes.next() {
657                Some(b'"') => break,
658                Some(b'\\') => {
659                    if let Some(next) = bytes.next() {
660                        out.push(next as char);
661                    }
662                }
663                Some(other) => out.push(other as char),
664                None => break,
665            }
666        }
667    }
668    if out.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('"') {
669        return trimmed.to_string();
670    }
671    out
672}
673
674fn parse_caa(value: &str) -> Result<DnsRecord> {
675    let mut parts = value.splitn(3, char::is_whitespace);
676    let flags: u8 = parts
677        .next()
678        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
679        .parse()
680        .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
681    let tag = parts
682        .next()
683        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
684        .to_ascii_lowercase();
685    let raw_value = parts
686        .next()
687        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
688        .trim();
689    let unquoted = raw_value
690        .strip_prefix('"')
691        .and_then(|s| s.strip_suffix('"'))
692        .map(|s| s.replace("\\\"", "\""))
693        .unwrap_or_else(|| raw_value.to_string());
694
695    let issuer_critical = flags & 0x80 != 0;
696    match tag.as_str() {
697        "issue" => {
698            let (name, options) = split_caa_value(&unquoted);
699            Ok(DnsRecord::CAA(CAARecord::Issue {
700                issuer_critical,
701                name,
702                options,
703            }))
704        }
705        "issuewild" => {
706            let (name, options) = split_caa_value(&unquoted);
707            Ok(DnsRecord::CAA(CAARecord::IssueWild {
708                issuer_critical,
709                name,
710                options,
711            }))
712        }
713        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
714            issuer_critical,
715            url: unquoted,
716        })),
717        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
718    }
719}