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::should_update_record;
8use anyhow::{Context, Result};
9use hickory_client::client::{Client, SyncClient};
10use hickory_client::op::ResponseCode;
11use hickory_client::rr::{rdata, DNSClass, Name, RData, Record};
12use hickory_client::udp::UdpClientConnection;
13use std::str::FromStr;
14use tracing::info;
15use url::Url;
16
17use crate::bind9::rndc::create_tsig_signer;
18use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
19
20/// Add a CAA record using dynamic DNS update (RFC 2136).
21///
22/// # Errors
23///
24/// Returns an error if:
25/// - DNS server connection fails
26/// - TSIG signer creation fails
27/// - DNS update is rejected by the server
28/// - Invalid domain name, flags, tag, or value
29#[allow(clippy::too_many_arguments)]
30#[allow(clippy::too_many_lines)]
31pub async fn add_caa_record(
32    zone_name: &str,
33    name: &str,
34    flags: i32,
35    tag: &str,
36    value: &str,
37    ttl: Option<i32>,
38    server: &str,
39    key_data: &RndcKeyData,
40) -> Result<()> {
41    use hickory_client::rr::RecordType;
42
43    // Check if update is needed using declarative reconciliation pattern
44    let flags_for_comparison = flags;
45    let tag_for_comparison = tag.to_string();
46    let value_for_comparison = value.to_string();
47    let should_update = should_update_record(
48        zone_name,
49        name,
50        RecordType::CAA,
51        "CAA",
52        server,
53        |existing_records| {
54            // Compare: should return true if records match desired state
55            if existing_records.len() == 1 {
56                if let Some(RData::CAA(existing_caa)) = existing_records[0].data() {
57                    let issuer_critical = flags_for_comparison != 0;
58                    let flags_match = existing_caa.issuer_critical() == issuer_critical;
59                    let tag_match = existing_caa.tag().to_string() == tag_for_comparison;
60
61                    // Compare value based on tag type
62                    let value_match = match tag_for_comparison.as_str() {
63                        "issue" | "issuewild" => {
64                            // Compare CA name (may be None for empty/deny policy)
65                            let existing_value = existing_caa.value().to_string();
66                            existing_value == value_for_comparison
67                        }
68                        "iodef" => {
69                            let existing_value = existing_caa.value().to_string();
70                            existing_value == value_for_comparison
71                        }
72                        _ => false,
73                    };
74
75                    return flags_match && tag_match && value_match;
76                }
77            }
78            false
79        },
80    )
81    .await?;
82
83    if !should_update {
84        return Ok(());
85    }
86
87    let zone_name_str = zone_name.to_string();
88    let name_str = name.to_string();
89    let tag_str = tag.to_string();
90    let value_str = value.to_string();
91    let server_str = server.to_string();
92    let key_data = key_data.clone();
93
94    tokio::task::spawn_blocking(move || {
95        let server_addr = server_str.parse::<std::net::SocketAddr>().context(format!(
96            "Invalid server address for CAA record update: {server_str}"
97        ))?;
98
99        let conn = UdpClientConnection::new(server_addr)
100            .context("Failed to create UDP connection for CAA record")?;
101
102        let signer =
103            create_tsig_signer(&key_data).context("Failed to create TSIG signer for CAA record")?;
104
105        let client = SyncClient::with_tsigner(conn, signer);
106
107        let fqdn_str = if name_str.is_empty() || name_str == "@" {
108            zone_name_str.clone()
109        } else {
110            format!("{name_str}.{zone_name_str}")
111        };
112
113        let fqdn = Name::from_str(&fqdn_str)
114            .context(format!("Invalid FQDN for CAA record: {fqdn_str}"))?;
115
116        let zone = Name::from_str(&zone_name_str)
117            .context(format!("Invalid zone name for CAA: {zone_name_str}"))?;
118
119        let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
120            .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
121
122        // CAA flags: 0 = not critical, 128 = critical
123        let issuer_critical = flags != 0;
124
125        // Create CAA record based on tag type
126        let record_data = match tag_str.as_str() {
127            "issue" => {
128                // Parse value as domain name
129                let ca_name = if value_str.is_empty() {
130                    None
131                } else {
132                    Some(
133                        Name::from_str(&value_str)
134                            .context(format!("Invalid CA domain name: {value_str}"))?,
135                    )
136                };
137                rdata::CAA::new_issue(issuer_critical, ca_name, Vec::new())
138            }
139            "issuewild" => {
140                let ca_name = if value_str.is_empty() {
141                    None
142                } else {
143                    Some(
144                        Name::from_str(&value_str)
145                            .context(format!("Invalid CA domain name: {value_str}"))?,
146                    )
147                };
148                rdata::CAA::new_issuewild(issuer_critical, ca_name, Vec::new())
149            }
150            "iodef" => {
151                let url =
152                    Url::parse(&value_str).context(format!("Invalid iodef URL: {value_str}"))?;
153                rdata::CAA::new_iodef(issuer_critical, url)
154            }
155            _ => anyhow::bail!(
156                "Unsupported CAA tag: {tag_str}. Supported tags: issue, issuewild, iodef"
157            ),
158        };
159
160        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::CAA(record_data));
161        record.set_dns_class(DNSClass::IN);
162
163        // Use append for idempotent operation
164        let response = client
165            .append(record, zone.clone(), false)
166            .context(format!("Failed to send CAA record update for {fqdn_str}"))?;
167
168        match response.response_code() {
169            ResponseCode::NoError => {
170                info!(
171                    "Successfully added CAA record: {} -> {} {} \"{}\" (TTL: {})",
172                    fqdn_str, flags, tag_str, value_str, ttl_value
173                );
174            }
175            code => {
176                anyhow::bail!("DNS server rejected CAA record update for {fqdn_str}: {code:?}");
177            }
178        }
179
180        Ok(())
181    })
182    .await
183    .context("CAA record update task panicked")??;
184
185    Ok(())
186}
187
188#[cfg(test)]
189#[path = "caa_tests.rs"]
190mod caa_tests;