bindy/bind9/records/
caa.rs1use 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
19fn 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#[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 let delete_record = build_delete_rrset_record(&fqdn, RecordType::CAA);
146 let _ = client.delete_rrset(delete_record, zone.clone()).await;
147
148 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;