bindy/bind9/records/
a.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! A and AAAA 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::{DNSClass, Name, RData, Record, RecordType};
12use std::collections::HashSet;
13use std::net::{Ipv4Addr, Ipv6Addr};
14use std::str::FromStr;
15use tracing::{error, info};
16
17use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
18
19/// Compare existing DNS `RRset` with desired IPv4 addresses.
20///
21/// This function implements declarative reconciliation for A records by comparing
22/// the current state (existing DNS records) with desired state (spec).
23///
24/// # Arguments
25///
26/// * `existing_records` - Records currently in DNS (from query)
27/// * `desired_ips` - IP addresses from `ARecordSpec`
28///
29/// # Returns
30///
31/// `true` if existing `RRset` matches desired state exactly (no changes needed),
32/// `false` if update required (add/remove IPs needed).
33fn compare_ip_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool {
34    let existing_ips: HashSet<String> = existing_records
35        .iter()
36        .filter_map(|record| {
37            if let RData::A(ipv4) = &record.data {
38                Some(ipv4.to_string())
39            } else {
40                None
41            }
42        })
43        .collect();
44
45    let desired_set: HashSet<String> = desired_ips.iter().cloned().collect();
46    existing_ips == desired_set
47}
48
49/// Compare existing DNS `RRset` with desired IPv6 addresses.
50///
51/// This function implements declarative reconciliation for AAAA records by comparing
52/// the current state (existing DNS records) with desired state (spec).
53///
54/// # Arguments
55///
56/// * `existing_records` - Records currently in DNS (from query)
57/// * `desired_ips` - IP addresses from `AAAARecordSpec`
58///
59/// # Returns
60///
61/// `true` if existing `RRset` matches desired state exactly (no changes needed),
62/// `false` if update required (add/remove IPs needed).
63fn compare_ipv6_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool {
64    let existing_ips: HashSet<String> = existing_records
65        .iter()
66        .filter_map(|record| {
67            if let RData::AAAA(ipv6) = &record.data {
68                Some(ipv6.to_string())
69            } else {
70                None
71            }
72        })
73        .collect();
74
75    let desired_set: HashSet<String> = desired_ips.iter().cloned().collect();
76    existing_ips == desired_set
77}
78
79/// Add A records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
80///
81/// This function implements declarative `RRset` management:
82/// 1. Compares existing DNS records with desired IPs
83/// 2. If mismatch, deletes entire `RRset` and recreates with desired IPs
84/// 3. If match, skips update (idempotent)
85///
86/// # Arguments
87/// * `zone_name` - DNS zone name (e.g., "example.com")
88/// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
89/// * `ipv4_addresses` - List of IPv4 addresses for round-robin DNS
90/// * `ttl` - Time to live in seconds (None = use zone default)
91/// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
92/// * `key_data` - TSIG key for authentication
93///
94/// # Errors
95///
96/// Returns an error if the DNS update fails or the server rejects it.
97#[allow(clippy::too_many_arguments)]
98pub async fn add_a_record(
99    zone_name: &str,
100    name: &str,
101    ipv4_addresses: &[String],
102    ttl: Option<i32>,
103    server: &str,
104    key_data: &RndcKeyData,
105) -> Result<()> {
106    let should_update = should_update_record(
107        zone_name,
108        name,
109        RecordType::A,
110        "A",
111        server,
112        |existing_records| compare_ip_rrset(existing_records, ipv4_addresses),
113    )
114    .await?;
115
116    if !should_update {
117        return Ok(());
118    }
119
120    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
121        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
122
123    let zone =
124        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
125    let fqdn = build_record_fqdn(zone_name, name)?;
126
127    let mut client = build_authenticated_client(server, key_data).await?;
128
129    // Step 1: delete existing RRset (ignore errors — may not exist).
130    let delete_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(RecordType::A));
131    let _ = client.delete_rrset(delete_record, zone.clone()).await;
132
133    info!(
134        "Adding A record RRset: {} -> {:?} (TTL: {}, {} addresses)",
135        fqdn,
136        ipv4_addresses,
137        ttl_value,
138        ipv4_addresses.len()
139    );
140
141    // Step 2: append all desired IPs to create the new RRset.
142    for ip_str in ipv4_addresses {
143        let ipv4_addr = Ipv4Addr::from_str(ip_str)
144            .with_context(|| format!("Invalid IPv4 address: {ip_str}"))?;
145
146        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::A(ipv4_addr.into()));
147        record.dns_class = DNSClass::IN;
148
149        let response = client
150            .append(record, zone.clone(), false)
151            .await
152            .with_context(|| format!("Failed to add A record for {fqdn} -> {ip_str}"))?;
153
154        match response.metadata.response_code {
155            ResponseCode::NoError => {
156                info!("Successfully added A record: {} -> {}", name, ip_str);
157            }
158            code => {
159                error!(
160                    "DNS UPDATE rejected by server for {} -> {} with response code: {:?}",
161                    fqdn, ip_str, code
162                );
163                return Err(anyhow::anyhow!(
164                    "DNS update failed with response code: {code:?}"
165                ));
166            }
167        }
168    }
169
170    Ok(())
171}
172
173/// Add AAAA records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
174///
175/// This function implements declarative `RRset` management:
176/// 1. Compares existing DNS records with desired IPv6 addresses
177/// 2. If mismatch, deletes entire `RRset` and recreates with desired IPs
178/// 3. If match, skips update (idempotent)
179///
180/// # Arguments
181/// * `zone_name` - DNS zone name (e.g., "example.com")
182/// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
183/// * `ipv6_addresses` - List of IPv6 addresses for round-robin DNS
184/// * `ttl` - Time to live in seconds (None = use zone default)
185/// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
186/// * `key_data` - TSIG key for authentication
187///
188/// # Errors
189///
190/// Returns an error if the DNS update fails or the server rejects it.
191#[allow(clippy::too_many_arguments)]
192pub async fn add_aaaa_record(
193    zone_name: &str,
194    name: &str,
195    ipv6_addresses: &[String],
196    ttl: Option<i32>,
197    server: &str,
198    key_data: &RndcKeyData,
199) -> Result<()> {
200    let should_update = should_update_record(
201        zone_name,
202        name,
203        RecordType::AAAA,
204        "AAAA",
205        server,
206        |existing_records| compare_ipv6_rrset(existing_records, ipv6_addresses),
207    )
208    .await?;
209
210    if !should_update {
211        return Ok(());
212    }
213
214    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
215        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
216
217    let zone =
218        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
219    let fqdn = build_record_fqdn(zone_name, name)?;
220
221    let mut client = build_authenticated_client(server, key_data).await?;
222
223    let delete_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(RecordType::AAAA));
224    let _ = client.delete_rrset(delete_record, zone.clone()).await;
225
226    info!(
227        "Adding AAAA record RRset: {} -> {:?} (TTL: {}, {} addresses)",
228        fqdn,
229        ipv6_addresses,
230        ttl_value,
231        ipv6_addresses.len()
232    );
233
234    for ip_str in ipv6_addresses {
235        let ipv6_addr = Ipv6Addr::from_str(ip_str)
236            .with_context(|| format!("Invalid IPv6 address: {ip_str}"))?;
237
238        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::AAAA(ipv6_addr.into()));
239        record.dns_class = DNSClass::IN;
240
241        let response = client
242            .append(record, zone.clone(), false)
243            .await
244            .with_context(|| format!("Failed to add AAAA record for {fqdn} -> {ip_str}"))?;
245
246        match response.metadata.response_code {
247            ResponseCode::NoError => {
248                info!("Successfully added AAAA record: {} -> {}", name, ip_str);
249            }
250            code => {
251                error!(
252                    "DNS UPDATE rejected by server for {} -> {} with response code: {:?}",
253                    fqdn, ip_str, code
254                );
255                return Err(anyhow::anyhow!(
256                    "DNS update failed with response code: {code:?}"
257                ));
258            }
259        }
260    }
261
262    Ok(())
263}
264
265#[cfg(test)]
266#[path = "a_tests.rs"]
267mod a_tests;