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::net::{Ipv4Addr, Ipv6Addr};
14use std::str::FromStr;
15use tracing::{error, info};
16
17use crate::bind9::rndc::create_tsig_signer;
18use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
19
20/// Add an A record using dynamic DNS update (RFC 2136).
21///
22/// # Arguments
23/// * `zone_name` - DNS zone name (e.g., "example.com")
24/// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
25/// * `ipv4` - IPv4 address
26/// * `ttl` - Time to live in seconds (None = use zone default)
27/// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
28/// * `key_data` - TSIG key for authentication
29///
30/// # Errors
31///
32/// Returns an error if the DNS update fails or the server rejects it.
33#[allow(clippy::too_many_arguments)]
34pub async fn add_a_record(
35    zone_name: &str,
36    name: &str,
37    ipv4: &str,
38    ttl: Option<i32>,
39    server: &str,
40    key_data: &RndcKeyData,
41) -> Result<()> {
42    use hickory_client::rr::RecordType;
43
44    // Check if update is needed using declarative reconciliation pattern
45    let should_update = should_update_record(
46        zone_name,
47        name,
48        RecordType::A,
49        "A",
50        server,
51        |existing_records| {
52            // Compare: should return true if records match desired state
53            if existing_records.len() == 1 {
54                if let Some(RData::A(existing_ip)) = existing_records[0].data() {
55                    return existing_ip.to_string() == ipv4;
56                }
57            }
58            false
59        },
60    )
61    .await?;
62
63    if !should_update {
64        return Ok(());
65    }
66
67    let zone_name_str = zone_name.to_string();
68    let name_str = name.to_string();
69    let ipv4_str = ipv4.to_string();
70    let server_str = server.to_string();
71    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
72        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
73
74    // Clone key_data for the blocking task
75    let key_data = key_data.clone();
76
77    // Execute DNS update in blocking thread (hickory-client is sync)
78    tokio::task::spawn_blocking(move || {
79        // Parse server address
80        let server_addr = server_str
81            .parse::<std::net::SocketAddr>()
82            .with_context(|| format!("Invalid server address: {server_str}"))?;
83
84        // Create UDP connection
85        let conn =
86            UdpClientConnection::new(server_addr).context("Failed to create UDP connection")?;
87
88        // Create TSIG signer
89        let signer = create_tsig_signer(&key_data)?;
90
91        // Create client with TSIG
92        let client = SyncClient::with_tsigner(conn, signer);
93
94        // Parse zone name
95        let zone = Name::from_str(&zone_name_str)
96            .with_context(|| format!("Invalid zone name: {zone_name_str}"))?;
97
98        // Build full record name
99        let fqdn = if name_str == "@" || name_str.is_empty() {
100            zone.clone()
101        } else {
102            Name::from_str(&format!("{name_str}.{zone_name_str}"))
103                .with_context(|| format!("Invalid record name: {name_str}.{zone_name_str}"))?
104        };
105
106        // Parse IPv4 address
107        let ipv4_addr = Ipv4Addr::from_str(&ipv4_str)
108            .with_context(|| format!("Invalid IPv4 address: {ipv4_str}"))?;
109
110        // Create A record
111        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::A(ipv4_addr.into()));
112        record.set_dns_class(DNSClass::IN);
113
114        // Send update using append for idempotent operation
115        // append() adds the record to the RRset, or creates a new RRset if none exists
116        // must_exist=false means no prerequisite check - truly idempotent
117        info!(
118            "Adding A record: {} -> {} (TTL: {})",
119            fqdn, ipv4_str, ttl_value
120        );
121        let response = client
122            .append(record, zone.clone(), false)
123            .with_context(|| format!("Failed to send DNS UPDATE for A record {fqdn}"))?;
124
125        // Check response code
126        match response.response_code() {
127            ResponseCode::NoError => {
128                info!("Successfully added A record: {} -> {}", name_str, ipv4_str);
129                Ok(())
130            }
131            code => {
132                error!(
133                    "DNS UPDATE rejected by server for {} -> {} with response code: {:?}",
134                    fqdn, ipv4_str, code
135                );
136                Err(anyhow::anyhow!(
137                    "DNS update failed with response code: {code:?}"
138                ))
139            }
140        }
141    })
142    .await
143    .with_context(|| format!("DNS update task panicked or failed for A record {name} -> {ipv4}"))?
144}
145
146/// Add an AAAA record using dynamic DNS update (RFC 2136).
147///
148/// # Errors
149///
150/// Returns an error if the DNS update fails or the server rejects it.
151#[allow(clippy::too_many_arguments)]
152pub async fn add_aaaa_record(
153    zone_name: &str,
154    name: &str,
155    ipv6: &str,
156    ttl: Option<i32>,
157    server: &str,
158    key_data: &RndcKeyData,
159) -> Result<()> {
160    use hickory_client::rr::RecordType;
161
162    // Check if update is needed using declarative reconciliation pattern
163    let should_update = should_update_record(
164        zone_name,
165        name,
166        RecordType::AAAA,
167        "AAAA",
168        server,
169        |existing_records| {
170            // Compare: should return true if records match desired state
171            if existing_records.len() == 1 {
172                if let Some(RData::AAAA(existing_ip)) = existing_records[0].data() {
173                    return existing_ip.to_string() == ipv6;
174                }
175            }
176            false
177        },
178    )
179    .await?;
180
181    if !should_update {
182        return Ok(());
183    }
184
185    let zone_name_str = zone_name.to_string();
186    let name_str = name.to_string();
187    let ipv6_str = ipv6.to_string();
188    let server_str = server.to_string();
189    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
190        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
191    let key_data = key_data.clone();
192
193    tokio::task::spawn_blocking(move || {
194        let server_addr = server_str
195            .parse::<std::net::SocketAddr>()
196            .with_context(|| format!("Invalid server address: {server_str}"))?;
197        let conn =
198            UdpClientConnection::new(server_addr).context("Failed to create UDP connection")?;
199        let signer = create_tsig_signer(&key_data)?;
200        let client = SyncClient::with_tsigner(conn, signer);
201
202        let zone = Name::from_str(&zone_name_str)
203            .with_context(|| format!("Invalid zone name: {zone_name_str}"))?;
204        let fqdn = if name_str == "@" || name_str.is_empty() {
205            zone.clone()
206        } else {
207            Name::from_str(&format!("{name_str}.{zone_name_str}"))
208                .with_context(|| format!("Invalid record name: {name_str}.{zone_name_str}"))?
209        };
210
211        let ipv6_addr = Ipv6Addr::from_str(&ipv6_str)
212            .with_context(|| format!("Invalid IPv6 address: {ipv6_str}"))?;
213        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::AAAA(ipv6_addr.into()));
214        record.set_dns_class(DNSClass::IN);
215
216        // Use append for idempotent operation (must_exist=false for no prerequisites)
217        info!(
218            "Adding AAAA record: {} -> {} (TTL: {})",
219            fqdn, ipv6_str, ttl_value
220        );
221        let response = client
222            .append(record, zone.clone(), false)
223            .with_context(|| format!("Failed to add AAAA record for {fqdn}"))?;
224
225        match response.response_code() {
226            ResponseCode::NoError => {
227                info!(
228                    "Successfully added AAAA record: {} -> {}",
229                    name_str, ipv6_str
230                );
231                Ok(())
232            }
233            code => Err(anyhow::anyhow!(
234                "DNS update failed with response code: {code:?}"
235            )),
236        }
237    })
238    .await
239    .context("DNS update task failed")?
240}
241
242#[cfg(test)]
243#[path = "a_tests.rs"]
244mod a_tests;