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::should_update_record;
8use anyhow::{Context, Result};
9use hickory_client::client::{Client, SyncClient};
10use hickory_client::op::ResponseCode;
11use hickory_client::rr::{DNSClass, Name, RData, Record};
12use hickory_client::udp::UdpClientConnection;
13use std::collections::HashSet;
14use std::net::{Ipv4Addr, Ipv6Addr};
15use std::str::FromStr;
16use tracing::{error, info};
17
18use crate::bind9::rndc::create_tsig_signer;
19use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
20
21/// Compare existing DNS `RRset` with desired IPv4 addresses.
22///
23/// This function implements declarative reconciliation for A records by comparing
24/// the current state (existing DNS records) with desired state (spec).
25///
26/// # Arguments
27///
28/// * `existing_records` - Records currently in DNS (from query)
29/// * `desired_ips` - IP addresses from `ARecordSpec`
30///
31/// # Returns
32///
33/// `true` if existing `RRset` matches desired state exactly (no changes needed),
34/// `false` if update required (add/remove IPs needed).
35fn compare_ip_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool {
36    // Extract IPs from existing DNS records
37    let existing_ips: HashSet<String> = existing_records
38        .iter()
39        .filter_map(|record| {
40            // Extract IP from RData
41            if let Some(RData::A(ipv4)) = record.data() {
42                Some(ipv4.to_string())
43            } else {
44                None // Ignore non-A records (shouldn't happen)
45            }
46        })
47        .collect();
48
49    // Convert desired IPs to HashSet for comparison
50    // HashSet automatically handles:
51    // - Duplicates (deduplication)
52    // - Order independence
53    let desired_set: HashSet<String> = desired_ips.iter().cloned().collect();
54
55    // Compare sets - true if identical (same IPs, no extras, no missing)
56    existing_ips == desired_set
57}
58
59/// Compare existing DNS `RRset` with desired IPv6 addresses.
60///
61/// This function implements declarative reconciliation for AAAA records by comparing
62/// the current state (existing DNS records) with desired state (spec).
63///
64/// # Arguments
65///
66/// * `existing_records` - Records currently in DNS (from query)
67/// * `desired_ips` - IP addresses from `AAAARecordSpec`
68///
69/// # Returns
70///
71/// `true` if existing `RRset` matches desired state exactly (no changes needed),
72/// `false` if update required (add/remove IPs needed).
73fn compare_ipv6_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool {
74    // Extract IPs from existing DNS records
75    let existing_ips: HashSet<String> = existing_records
76        .iter()
77        .filter_map(|record| {
78            // Extract IP from RData
79            if let Some(RData::AAAA(ipv6)) = record.data() {
80                Some(ipv6.to_string())
81            } else {
82                None
83            }
84        })
85        .collect();
86
87    // Convert desired IPs to set
88    let desired_set: HashSet<String> = desired_ips.iter().cloned().collect();
89
90    // Compare sets
91    existing_ips == desired_set
92}
93
94/// Add A records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
95///
96/// This function implements declarative `RRset` management:
97/// 1. Compares existing DNS records with desired IPs
98/// 2. If mismatch, deletes entire `RRset` and recreates with desired IPs
99/// 3. If match, skips update (idempotent)
100///
101/// # Arguments
102/// * `zone_name` - DNS zone name (e.g., "example.com")
103/// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
104/// * `ipv4_addresses` - List of IPv4 addresses for round-robin DNS
105/// * `ttl` - Time to live in seconds (None = use zone default)
106/// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
107/// * `key_data` - TSIG key for authentication
108///
109/// # Errors
110///
111/// Returns an error if the DNS update fails or the server rejects it.
112#[allow(clippy::too_many_arguments)]
113pub async fn add_a_record(
114    zone_name: &str,
115    name: &str,
116    ipv4_addresses: &[String],
117    ttl: Option<i32>,
118    server: &str,
119    key_data: &RndcKeyData,
120) -> Result<()> {
121    use hickory_client::rr::RecordType;
122
123    // Check if update is needed using declarative reconciliation pattern
124    let should_update = should_update_record(
125        zone_name,
126        name,
127        RecordType::A,
128        "A",
129        server,
130        |existing_records| {
131            // Compare RRsets: returns true if existing matches desired state
132            compare_ip_rrset(existing_records, ipv4_addresses)
133        },
134    )
135    .await?;
136
137    if !should_update {
138        return Ok(());
139    }
140
141    let zone_name_str = zone_name.to_string();
142    let name_str = name.to_string();
143    let ipv4_addresses_vec: Vec<String> = ipv4_addresses.to_vec();
144    let server_str = server.to_string();
145    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
146        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
147
148    // Clone key_data for the blocking task
149    let key_data = key_data.clone();
150
151    // Clone for error message (ipv4_addresses_vec will be moved into closure)
152    let ipv4_addresses_for_error = ipv4_addresses_vec.clone();
153
154    // Execute DNS update in blocking thread (hickory-client is sync)
155    tokio::task::spawn_blocking(move || {
156        // Parse server address
157        let server_addr = server_str
158            .parse::<std::net::SocketAddr>()
159            .with_context(|| format!("Invalid server address: {server_str}"))?;
160
161        // Create UDP connection
162        let conn =
163            UdpClientConnection::new(server_addr).context("Failed to create UDP connection")?;
164
165        // Create TSIG signer
166        let signer = create_tsig_signer(&key_data)?;
167
168        // Create client with TSIG
169        let client = SyncClient::with_tsigner(conn, signer);
170
171        // Parse zone name
172        let zone = Name::from_str(&zone_name_str)
173            .with_context(|| format!("Invalid zone name: {zone_name_str}"))?;
174
175        // Build full record name
176        let fqdn = if name_str == "@" || name_str.is_empty() {
177            zone.clone()
178        } else {
179            Name::from_str(&format!("{name_str}.{zone_name_str}"))
180                .with_context(|| format!("Invalid record name: {name_str}.{zone_name_str}"))?
181        };
182
183        // Step 1: Delete existing RRset (ignore errors - may not exist)
184        let mut delete_record = Record::new();
185        delete_record.set_name(fqdn.clone());
186        delete_record.set_record_type(RecordType::A);
187        delete_record.set_dns_class(DNSClass::IN);
188        let _ = client.delete_rrset(delete_record, zone.clone());
189
190        // Step 2: Add all desired IPs to create new RRset
191        info!(
192            "Adding A record RRset: {} -> {:?} (TTL: {}, {} addresses)",
193            fqdn,
194            ipv4_addresses_vec,
195            ttl_value,
196            ipv4_addresses_vec.len()
197        );
198
199        for ip_str in &ipv4_addresses_vec {
200            // Parse IPv4 address
201            let ipv4_addr = Ipv4Addr::from_str(ip_str)
202                .with_context(|| format!("Invalid IPv4 address: {ip_str}"))?;
203
204            // Create A record
205            let mut record =
206                Record::from_rdata(fqdn.clone(), ttl_value, RData::A(ipv4_addr.into()));
207            record.set_dns_class(DNSClass::IN);
208
209            // Append record to RRset
210            let response = client
211                .append(record, zone.clone(), false)
212                .with_context(|| format!("Failed to add A record for {fqdn} -> {ip_str}"))?;
213
214            // Check response code
215            match response.response_code() {
216                ResponseCode::NoError => {
217                    info!("Successfully added A record: {} -> {}", name_str, ip_str);
218                }
219                code => {
220                    error!(
221                        "DNS UPDATE rejected by server for {} -> {} with response code: {:?}",
222                        fqdn, ip_str, code
223                    );
224                    return Err(anyhow::anyhow!(
225                        "DNS update failed with response code: {code:?}"
226                    ));
227                }
228            }
229        }
230
231        Ok(())
232    })
233    .await
234    .with_context(|| format!("DNS update task panicked or failed for A record {name} -> {ipv4_addresses_for_error:?}"))?
235}
236
237/// Add AAAA records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
238///
239/// This function implements declarative `RRset` management:
240/// 1. Compares existing DNS records with desired IPv6 addresses
241/// 2. If mismatch, deletes entire `RRset` and recreates with desired IPs
242/// 3. If match, skips update (idempotent)
243///
244/// # Arguments
245/// * `zone_name` - DNS zone name (e.g., "example.com")
246/// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
247/// * `ipv6_addresses` - List of IPv6 addresses for round-robin DNS
248/// * `ttl` - Time to live in seconds (None = use zone default)
249/// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
250/// * `key_data` - TSIG key for authentication
251///
252/// # Errors
253///
254/// Returns an error if the DNS update fails or the server rejects it.
255#[allow(clippy::too_many_arguments)]
256pub async fn add_aaaa_record(
257    zone_name: &str,
258    name: &str,
259    ipv6_addresses: &[String],
260    ttl: Option<i32>,
261    server: &str,
262    key_data: &RndcKeyData,
263) -> Result<()> {
264    use hickory_client::rr::RecordType;
265
266    // Check if update is needed using declarative reconciliation pattern
267    let should_update = should_update_record(
268        zone_name,
269        name,
270        RecordType::AAAA,
271        "AAAA",
272        server,
273        |existing_records| {
274            // Compare RRsets: returns true if existing matches desired state
275            compare_ipv6_rrset(existing_records, ipv6_addresses)
276        },
277    )
278    .await?;
279
280    if !should_update {
281        return Ok(());
282    }
283
284    let zone_name_str = zone_name.to_string();
285    let name_str = name.to_string();
286    let ipv6_addresses_vec: Vec<String> = ipv6_addresses.to_vec();
287    let server_str = server.to_string();
288    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
289        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
290    let key_data = key_data.clone();
291
292    // Clone for error message (ipv6_addresses_vec will be moved into closure)
293    let ipv6_addresses_for_error = ipv6_addresses_vec.clone();
294
295    tokio::task::spawn_blocking(move || {
296        let server_addr = server_str
297            .parse::<std::net::SocketAddr>()
298            .with_context(|| format!("Invalid server address: {server_str}"))?;
299        let conn =
300            UdpClientConnection::new(server_addr).context("Failed to create UDP connection")?;
301        let signer = create_tsig_signer(&key_data)?;
302        let client = SyncClient::with_tsigner(conn, signer);
303
304        let zone = Name::from_str(&zone_name_str)
305            .with_context(|| format!("Invalid zone name: {zone_name_str}"))?;
306        let fqdn = if name_str == "@" || name_str.is_empty() {
307            zone.clone()
308        } else {
309            Name::from_str(&format!("{name_str}.{zone_name_str}"))
310                .with_context(|| format!("Invalid record name: {name_str}.{zone_name_str}"))?
311        };
312
313        // Step 1: Delete existing RRset (ignore errors - may not exist)
314        let mut delete_record = Record::new();
315        delete_record.set_name(fqdn.clone());
316        delete_record.set_record_type(RecordType::AAAA);
317        delete_record.set_dns_class(DNSClass::IN);
318        let _ = client.delete_rrset(delete_record, zone.clone());
319
320        // Step 2: Add all desired IPv6 addresses to create new RRset
321        info!(
322            "Adding AAAA record RRset: {} -> {:?} (TTL: {}, {} addresses)",
323            fqdn,
324            ipv6_addresses_vec,
325            ttl_value,
326            ipv6_addresses_vec.len()
327        );
328
329        for ip_str in &ipv6_addresses_vec {
330            let ipv6_addr = Ipv6Addr::from_str(ip_str)
331                .with_context(|| format!("Invalid IPv6 address: {ip_str}"))?;
332            let mut record =
333                Record::from_rdata(fqdn.clone(), ttl_value, RData::AAAA(ipv6_addr.into()));
334            record.set_dns_class(DNSClass::IN);
335
336            let response = client
337                .append(record, zone.clone(), false)
338                .with_context(|| format!("Failed to add AAAA record for {fqdn} -> {ip_str}"))?;
339
340            match response.response_code() {
341                ResponseCode::NoError => {
342                    info!("Successfully added AAAA record: {} -> {}", name_str, ip_str);
343                }
344                code => {
345                    error!(
346                        "DNS UPDATE rejected by server for {} -> {} with response code: {:?}",
347                        fqdn, ip_str, code
348                    );
349                    return Err(anyhow::anyhow!(
350                        "DNS update failed with response code: {code:?}"
351                    ));
352                }
353            }
354        }
355
356        Ok(())
357    })
358    .await
359    .with_context(|| format!("DNS update task panicked or failed for AAAA record {name} -> {ipv6_addresses_for_error:?}"))?
360}
361
362#[cfg(test)]
363#[path = "a_tests.rs"]
364mod a_tests;