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