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