bindy/bind9/records/
caa.rs1use 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#[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;