bindy/bind9/records/
mod.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! DNS record management functions using dynamic DNS updates (RFC 2136).
5//!
6//! This module provides functions for managing DNS records via the nsupdate protocol.
7//! Each record type has its own submodule with specialized functions.
8
9pub mod a;
10pub mod caa;
11pub mod cname;
12pub mod mx;
13pub mod ns;
14pub mod ptr;
15pub mod srv;
16pub mod txt;
17
18use anyhow::{Context, Result};
19use hickory_net::client::{Client, ClientHandle};
20use hickory_net::runtime::TokioRuntimeProvider;
21use hickory_net::udp::UdpClientStream;
22use hickory_proto::op::ResponseCode;
23use hickory_proto::rr::{DNSClass, Name, RData, Record, RecordType};
24use std::net::SocketAddr;
25use std::str::FromStr;
26use tracing::{info, warn};
27
28use crate::bind9::rndc::create_tsig_signer;
29use crate::bind9::types::RndcKeyData;
30use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
31
32/// Fallback TTL (seconds) used only if [`DEFAULT_DNS_RECORD_TTL_SECS`] cannot be
33/// converted to `u32`.
34const FALLBACK_DNS_RECORD_TTL_SECS: u32 = 300;
35
36/// Resolve the effective TTL for a DNS record from an optional spec value.
37///
38/// Returns the spec TTL when present and representable as `u32`; otherwise falls
39/// back to [`DEFAULT_DNS_RECORD_TTL_SECS`].
40///
41/// This is the single source of truth for the TTL written to DNS, and is also
42/// used when diffing desired state against existing records so that TTL-only
43/// spec changes trigger an update.
44pub(crate) fn effective_record_ttl(ttl: Option<i32>) -> u32 {
45    u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)).unwrap_or_else(|_| {
46        u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(FALLBACK_DNS_RECORD_TTL_SECS)
47    })
48}
49
50/// Check whether every record in an existing `RRset` carries the desired TTL.
51///
52/// Used by the per-record-type compare functions so that a TTL-only spec change
53/// is detected as a mismatch and triggers the update path.
54pub(crate) fn rrset_ttl_matches(existing_records: &[Record], desired_ttl: u32) -> bool {
55    existing_records
56        .iter()
57        .all(|record| record.ttl == desired_ttl)
58}
59
60/// Build the placeholder record used by RFC 2136 "delete `RRset`" operations.
61///
62/// `delete_rrset()` overwrites class/TTL/data based on `record_type`, so the
63/// returned record only needs to carry the FQDN and the record type.
64pub(crate) fn build_delete_rrset_record(fqdn: &Name, record_type: RecordType) -> Record {
65    Record::from_rdata(fqdn.clone(), 0, RData::Update0(record_type))
66}
67
68/// Response codes that indicate an RFC 2136 delete succeeded (`NoError`) or the
69/// target records already did not exist (`NXDomain`, `NXRRSet`) — idempotent
70/// success.
71///
72/// Every other code (e.g. `Refused`, `NotAuth`, `NotZone`, `ServFail`) is a real
73/// failure and must be surfaced to the caller.
74pub(crate) fn is_idempotent_delete_response_code(code: ResponseCode) -> bool {
75    matches!(
76        code,
77        ResponseCode::NoError | ResponseCode::NXDomain | ResponseCode::NXRRSet
78    )
79}
80
81/// Build an unauthenticated UDP DNS client for read-only queries.
82async fn build_query_client(server_str: &str) -> Result<Client<TokioRuntimeProvider>> {
83    let server_addr: SocketAddr = server_str
84        .parse()
85        .with_context(|| format!("Invalid server address: {server_str}"))?;
86    let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
87    let (client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
88    tokio::spawn(bg);
89    Ok(client)
90}
91
92/// Build a TSIG-authenticated UDP DNS client for RFC 2136 dynamic updates.
93pub(crate) async fn build_authenticated_client(
94    server_str: &str,
95    key_data: &RndcKeyData,
96) -> Result<Client<TokioRuntimeProvider>> {
97    let server_addr: SocketAddr = server_str
98        .parse()
99        .with_context(|| format!("Invalid server address: {server_str}"))?;
100    let signer = create_tsig_signer(key_data)?;
101    let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default())
102        .with_signer(Some(signer))
103        .build();
104    let (client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
105    tokio::spawn(bg);
106    Ok(client)
107}
108
109/// Build the fully-qualified record name for a given (zone, name).
110///
111/// `@` or empty `name` produces the zone apex; otherwise the name is concatenated with the zone.
112pub(crate) fn build_record_fqdn(zone_name: &str, name: &str) -> Result<Name> {
113    if name == "@" || name.is_empty() {
114        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))
115    } else {
116        Name::from_str(&format!("{name}.{zone_name}"))
117            .with_context(|| format!("Invalid record name: {name}.{zone_name}"))
118    }
119}
120
121/// Generic DNS record query function.
122///
123/// Queries a DNS server for records of a specific type and returns the results.
124///
125/// # Arguments
126///
127/// * `zone_name` - The DNS zone name
128/// * `name` - The record name (e.g., "www" for www.example.com, or "@" for apex)
129/// * `record_type` - The DNS record type (A, AAAA, TXT, MX, etc.)
130/// * `server` - The DNS server address (IP:port)
131///
132/// # Returns
133///
134/// Returns `Ok(vec)` with matching records (empty if none exist),
135/// or an error if the query fails.
136///
137/// # Errors
138///
139/// Returns an error if the DNS query fails or cannot be parsed.
140pub async fn query_dns_record(
141    zone_name: &str,
142    name: &str,
143    record_type: RecordType,
144    server: &str,
145) -> Result<Vec<Record>> {
146    let mut client = build_query_client(server).await?;
147    let fqdn = build_record_fqdn(zone_name, name)?;
148
149    let response = client
150        .query(fqdn.clone(), DNSClass::IN, record_type)
151        .await
152        .with_context(|| format!("Failed to query {record_type:?} record for {fqdn}"))?;
153
154    let records: Vec<Record> = response
155        .answers
156        .iter()
157        .filter(|r| r.record_type() == record_type)
158        .cloned()
159        .collect();
160
161    Ok(records)
162}
163
164/// Helper for declarative record reconciliation.
165///
166/// Implements the observe → diff → act pattern for DNS records:
167/// 1. Query existing record
168/// 2. Compare with desired state using provided callback
169/// 3. Skip if already correct, otherwise proceed with update
170///
171/// # Arguments
172///
173/// * `zone_name` - The DNS zone name
174/// * `name` - The record name
175/// * `record_type` - The DNS record type
176/// * `record_type_name` - Human-readable name (e.g., "A", "AAAA")
177/// * `server` - The DNS server address
178/// * `compare_fn` - Callback to compare existing records with desired state
179///
180/// # Returns
181///
182/// Returns `Ok(true)` if update is needed, `Ok(false)` if record already matches.
183///
184/// # Errors
185///
186/// Returns an error only if the query fails critically.
187pub async fn should_update_record<F>(
188    zone_name: &str,
189    name: &str,
190    record_type: RecordType,
191    record_type_name: &str,
192    server: &str,
193    compare_fn: F,
194) -> Result<bool>
195where
196    F: FnOnce(&[Record]) -> bool,
197{
198    match query_dns_record(zone_name, name, record_type, server).await {
199        Ok(existing_records) if !existing_records.is_empty() => {
200            if compare_fn(&existing_records) {
201                info!(
202                    "{} record {} already exists with correct value - no changes needed",
203                    record_type_name, name
204                );
205                Ok(false)
206            } else {
207                info!(
208                    "{} record {} exists with different value(s), updating",
209                    record_type_name, name
210                );
211                Ok(true)
212            }
213        }
214        Ok(_) => {
215            info!(
216                "{} record {} does not exist, creating",
217                record_type_name, name
218            );
219            Ok(true)
220        }
221        Err(e) => {
222            warn!(
223                "Failed to query existing {} record {} (will attempt update anyway): {}",
224                record_type_name, name, e
225            );
226            Ok(true)
227        }
228    }
229}
230
231/// Delete a DNS record of any type using dynamic DNS update (RFC 2136).
232///
233/// This function sends an RFC 2136 DELETE operation to remove ALL records
234/// of the specified type for the given name.
235///
236/// # Arguments
237///
238/// * `zone_name` - The DNS zone name (e.g., "example.com")
239/// * `name` - The record name (e.g., "www" for www.example.com, or "@" for apex)
240/// * `record_type` - The DNS record type to delete (A, AAAA, TXT, MX, etc.)
241/// * `server` - The DNS server address (IP:port, e.g., "10.0.0.1:53")
242/// * `key_data` - TSIG key for authentication
243///
244/// # Returns
245///
246/// Returns `Ok(())` if deletion succeeded (`NoError`) or the record already did
247/// not exist (`NXDomain`/`NXRRSet` — idempotent success).
248///
249/// # Errors
250///
251/// Returns an error if the connection fails or the DNS server rejects the
252/// update with any other response code (e.g. `Refused`, `NotAuth`, `NotZone`,
253/// `ServFail`), so TSIG/ACL failures are never silently swallowed.
254pub async fn delete_dns_record(
255    zone_name: &str,
256    name: &str,
257    record_type: RecordType,
258    server: &str,
259    key_data: &RndcKeyData,
260) -> Result<()> {
261    let mut client = build_authenticated_client(server, key_data).await?;
262    let zone =
263        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
264    let fqdn = build_record_fqdn(zone_name, name)?;
265
266    info!(
267        "Deleting {:?} record: {} from zone {}",
268        record_type, fqdn, zone_name
269    );
270
271    // Build a placeholder record. delete_rrset() overwrites class/ttl/data based on record_type.
272    let dummy_record = build_delete_rrset_record(&fqdn, record_type);
273
274    let response = client
275        .delete_rrset(dummy_record, zone)
276        .await
277        .with_context(|| {
278            format!("Failed to send DNS UPDATE to delete {record_type:?} record {fqdn}")
279        })?;
280
281    let code = response.metadata.response_code;
282    if !is_idempotent_delete_response_code(code) {
283        return Err(anyhow::anyhow!(
284            "DNS DELETE for {record_type:?} record {fqdn} in zone {zone_name} \
285             rejected with response code: {code:?}"
286        ));
287    }
288
289    if code == ResponseCode::NoError {
290        info!(
291            "Successfully deleted {:?} record: {} from zone {}",
292            record_type, name, zone_name
293        );
294        return Ok(());
295    }
296
297    // NXDomain/NXRRSet: the record did not exist — deletion is idempotent.
298    warn!(
299        "DNS DELETE for {:?} record {fqdn} returned code: {:?} (record did not exist)",
300        record_type, code
301    );
302    Ok(())
303}
304
305#[cfg(test)]
306mod mod_tests;