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::{
8    build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl,
9    rrset_ttl_matches, should_update_record,
10};
11use anyhow::{Context, Result};
12use hickory_net::client::ClientHandle;
13use hickory_proto::op::ResponseCode;
14use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType};
15use std::str::FromStr;
16use tracing::info;
17use url::Url;
18
19/// Compare existing DNS `RRset` with the desired CAA fields and TTL.
20///
21/// # Arguments
22///
23/// * `existing_records` - Records currently in DNS (from query)
24/// * `issuer_critical` - Desired issuer-critical flag from the spec
25/// * `tag` - Desired CAA tag (`issue`, `issuewild`, or `iodef`) from the spec
26/// * `value` - Desired CAA value from the spec
27/// * `desired_ttl` - Effective TTL from the spec
28///
29/// # Returns
30///
31/// `true` if the existing `RRset` matches the desired state exactly (no changes
32/// needed), `false` if an update is required (rdata or TTL differ).
33fn compare_caa_rrset(
34    existing_records: &[Record],
35    issuer_critical: bool,
36    tag: &str,
37    value: &str,
38    desired_ttl: u32,
39) -> bool {
40    if existing_records.len() != 1 {
41        return false;
42    }
43    if !rrset_ttl_matches(existing_records, desired_ttl) {
44        return false;
45    }
46    let RData::CAA(existing_caa) = &existing_records[0].data else {
47        return false;
48    };
49
50    let flags_match = existing_caa.issuer_critical == issuer_critical;
51    let tag_match = existing_caa.tag == tag;
52
53    let value_match = match tag {
54        "issue" | "issuewild" => existing_caa
55            .value_as_issue()
56            .ok()
57            .map(|(name, _opts)| name.map(|n| n.to_string()).unwrap_or_default())
58            .is_some_and(|s| s == value),
59        "iodef" => existing_caa
60            .value_as_iodef()
61            .ok()
62            .is_some_and(|url| url.as_str() == value),
63        _ => false,
64    };
65
66    flags_match && tag_match && value_match
67}
68
69/// Add a CAA record using dynamic DNS update (RFC 2136) with `RRset` synchronization.
70///
71/// If the existing `RRset` differs from the desired state, the entire CAA
72/// `RRset` for the name is deleted and recreated so stale rdata never lingers.
73///
74/// # Errors
75///
76/// Returns an error if:
77/// - DNS server connection fails
78/// - TSIG signer creation fails
79/// - DNS update is rejected by the server
80/// - Invalid domain name, flags, tag, or value
81#[allow(clippy::too_many_arguments)]
82pub async fn add_caa_record(
83    zone_name: &str,
84    name: &str,
85    flags: i32,
86    tag: &str,
87    value: &str,
88    ttl: Option<i32>,
89    server: &str,
90    key_data: &RndcKeyData,
91) -> Result<()> {
92    let issuer_critical = flags != 0;
93    let ttl_value = effective_record_ttl(ttl);
94
95    let should_update = should_update_record(
96        zone_name,
97        name,
98        RecordType::CAA,
99        "CAA",
100        server,
101        |existing_records| {
102            compare_caa_rrset(existing_records, issuer_critical, tag, value, ttl_value)
103        },
104    )
105    .await?;
106
107    if !should_update {
108        return Ok(());
109    }
110
111    let zone =
112        Name::from_str(zone_name).context(format!("Invalid zone name for CAA: {zone_name}"))?;
113    let fqdn = build_record_fqdn(zone_name, name)?;
114
115    let record_data = match tag {
116        "issue" => {
117            let ca_name = if value.is_empty() {
118                None
119            } else {
120                Some(Name::from_str(value).context(format!("Invalid CA domain name: {value}"))?)
121            };
122            rdata::CAA::new_issue(issuer_critical, ca_name, Vec::new())
123        }
124        "issuewild" => {
125            let ca_name = if value.is_empty() {
126                None
127            } else {
128                Some(Name::from_str(value).context(format!("Invalid CA domain name: {value}"))?)
129            };
130            rdata::CAA::new_issuewild(issuer_critical, ca_name, Vec::new())
131        }
132        "iodef" => {
133            let url = Url::parse(value).context(format!("Invalid iodef URL: {value}"))?;
134            rdata::CAA::new_iodef(issuer_critical, url)
135        }
136        _ => anyhow::bail!("Unsupported CAA tag: {tag}. Supported tags: issue, issuewild, iodef"),
137    };
138
139    let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::CAA(record_data));
140    record.dns_class = DNSClass::IN;
141
142    let mut client = build_authenticated_client(server, key_data).await?;
143
144    // Step 1: delete existing RRset (ignore errors — may not exist).
145    let delete_record = build_delete_rrset_record(&fqdn, RecordType::CAA);
146    let _ = client.delete_rrset(delete_record, zone.clone()).await;
147
148    // Step 2: append the desired record to create the new RRset.
149    let response = client
150        .append(record, zone, false)
151        .await
152        .context(format!("Failed to send CAA record update for {fqdn}"))?;
153
154    match response.metadata.response_code {
155        ResponseCode::NoError => {
156            info!(
157                "Successfully added CAA record: {} -> {} {} \"{}\" (TTL: {})",
158                fqdn, flags, tag, value, ttl_value
159            );
160            Ok(())
161        }
162        code => {
163            anyhow::bail!("DNS server rejected CAA record update for {fqdn}: {code:?}");
164        }
165    }
166}
167
168#[cfg(test)]
169#[path = "caa_tests.rs"]
170mod caa_tests;