bindy/bind9/records/
caa.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! CAA record management.
5
6use super::super::types::RndcKeyData;
7use super::{build_authenticated_client, build_record_fqdn, should_update_record};
8use anyhow::{Context, Result};
9use hickory_net::client::ClientHandle;
10use hickory_proto::op::ResponseCode;
11use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType};
12use std::str::FromStr;
13use tracing::info;
14use url::Url;
15
16use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
17
18/// Add a CAA record using dynamic DNS update (RFC 2136).
19///
20/// # Errors
21///
22/// Returns an error if:
23/// - DNS server connection fails
24/// - TSIG signer creation fails
25/// - DNS update is rejected by the server
26/// - Invalid domain name, flags, tag, or value
27#[allow(clippy::too_many_arguments)]
28pub async fn add_caa_record(
29    zone_name: &str,
30    name: &str,
31    flags: i32,
32    tag: &str,
33    value: &str,
34    ttl: Option<i32>,
35    server: &str,
36    key_data: &RndcKeyData,
37) -> Result<()> {
38    let issuer_critical = flags != 0;
39    let tag_for_comparison = tag.to_string();
40    let value_for_comparison = value.to_string();
41
42    let should_update = should_update_record(
43        zone_name,
44        name,
45        RecordType::CAA,
46        "CAA",
47        server,
48        |existing_records| {
49            if existing_records.len() == 1 {
50                if let RData::CAA(existing_caa) = &existing_records[0].data {
51                    let flags_match = existing_caa.issuer_critical == issuer_critical;
52                    let tag_match = existing_caa.tag == tag_for_comparison;
53
54                    let value_match = match tag_for_comparison.as_str() {
55                        "issue" | "issuewild" => existing_caa
56                            .value_as_issue()
57                            .ok()
58                            .map(|(name, _opts)| name.map(|n| n.to_string()).unwrap_or_default())
59                            .is_some_and(|s| s == value_for_comparison),
60                        "iodef" => existing_caa
61                            .value_as_iodef()
62                            .ok()
63                            .is_some_and(|url| url.as_str() == value_for_comparison),
64                        _ => false,
65                    };
66
67                    return flags_match && tag_match && value_match;
68                }
69            }
70            false
71        },
72    )
73    .await?;
74
75    if !should_update {
76        return Ok(());
77    }
78
79    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
80        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
81
82    let zone =
83        Name::from_str(zone_name).context(format!("Invalid zone name for CAA: {zone_name}"))?;
84    let fqdn = build_record_fqdn(zone_name, name)?;
85
86    let record_data = match tag {
87        "issue" => {
88            let ca_name = if value.is_empty() {
89                None
90            } else {
91                Some(Name::from_str(value).context(format!("Invalid CA domain name: {value}"))?)
92            };
93            rdata::CAA::new_issue(issuer_critical, ca_name, Vec::new())
94        }
95        "issuewild" => {
96            let ca_name = if value.is_empty() {
97                None
98            } else {
99                Some(Name::from_str(value).context(format!("Invalid CA domain name: {value}"))?)
100            };
101            rdata::CAA::new_issuewild(issuer_critical, ca_name, Vec::new())
102        }
103        "iodef" => {
104            let url = Url::parse(value).context(format!("Invalid iodef URL: {value}"))?;
105            rdata::CAA::new_iodef(issuer_critical, url)
106        }
107        _ => anyhow::bail!("Unsupported CAA tag: {tag}. Supported tags: issue, issuewild, iodef"),
108    };
109
110    let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::CAA(record_data));
111    record.dns_class = DNSClass::IN;
112
113    let mut client = build_authenticated_client(server, key_data).await?;
114    let response = client
115        .append(record, zone, false)
116        .await
117        .context(format!("Failed to send CAA record update for {fqdn}"))?;
118
119    match response.metadata.response_code {
120        ResponseCode::NoError => {
121            info!(
122                "Successfully added CAA record: {} -> {} {} \"{}\" (TTL: {})",
123                fqdn, flags, tag, value, ttl_value
124            );
125            Ok(())
126        }
127        code => {
128            anyhow::bail!("DNS server rejected CAA record update for {fqdn}: {code:?}");
129        }
130    }
131}
132
133#[cfg(test)]
134#[path = "caa_tests.rs"]
135mod caa_tests;