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