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;
29
30/// Build an unauthenticated UDP DNS client for read-only queries.
31async fn build_query_client(server_str: &str) -> Result<Client<TokioRuntimeProvider>> {
32    let server_addr: SocketAddr = server_str
33        .parse()
34        .with_context(|| format!("Invalid server address: {server_str}"))?;
35    let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
36    let (client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
37    tokio::spawn(bg);
38    Ok(client)
39}
40
41/// Build a TSIG-authenticated UDP DNS client for RFC 2136 dynamic updates.
42pub(crate) async fn build_authenticated_client(
43    server_str: &str,
44    key_data: &RndcKeyData,
45) -> Result<Client<TokioRuntimeProvider>> {
46    let server_addr: SocketAddr = server_str
47        .parse()
48        .with_context(|| format!("Invalid server address: {server_str}"))?;
49    let signer = create_tsig_signer(key_data)?;
50    let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default())
51        .with_signer(Some(signer))
52        .build();
53    let (client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
54    tokio::spawn(bg);
55    Ok(client)
56}
57
58/// Build the fully-qualified record name for a given (zone, name).
59///
60/// `@` or empty `name` produces the zone apex; otherwise the name is concatenated with the zone.
61pub(crate) fn build_record_fqdn(zone_name: &str, name: &str) -> Result<Name> {
62    if name == "@" || name.is_empty() {
63        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))
64    } else {
65        Name::from_str(&format!("{name}.{zone_name}"))
66            .with_context(|| format!("Invalid record name: {name}.{zone_name}"))
67    }
68}
69
70/// Generic DNS record query function.
71///
72/// Queries a DNS server for records of a specific type and returns the results.
73///
74/// # Arguments
75///
76/// * `zone_name` - The DNS zone name
77/// * `name` - The record name (e.g., "www" for www.example.com, or "@" for apex)
78/// * `record_type` - The DNS record type (A, AAAA, TXT, MX, etc.)
79/// * `server` - The DNS server address (IP:port)
80///
81/// # Returns
82///
83/// Returns `Ok(vec)` with matching records (empty if none exist),
84/// or an error if the query fails.
85///
86/// # Errors
87///
88/// Returns an error if the DNS query fails or cannot be parsed.
89pub async fn query_dns_record(
90    zone_name: &str,
91    name: &str,
92    record_type: RecordType,
93    server: &str,
94) -> Result<Vec<Record>> {
95    let mut client = build_query_client(server).await?;
96    let fqdn = build_record_fqdn(zone_name, name)?;
97
98    let response = client
99        .query(fqdn.clone(), DNSClass::IN, record_type)
100        .await
101        .with_context(|| format!("Failed to query {record_type:?} record for {fqdn}"))?;
102
103    let records: Vec<Record> = response
104        .answers
105        .iter()
106        .filter(|r| r.record_type() == record_type)
107        .cloned()
108        .collect();
109
110    Ok(records)
111}
112
113/// Helper for declarative record reconciliation.
114///
115/// Implements the observe → diff → act pattern for DNS records:
116/// 1. Query existing record
117/// 2. Compare with desired state using provided callback
118/// 3. Skip if already correct, otherwise proceed with update
119///
120/// # Arguments
121///
122/// * `zone_name` - The DNS zone name
123/// * `name` - The record name
124/// * `record_type` - The DNS record type
125/// * `record_type_name` - Human-readable name (e.g., "A", "AAAA")
126/// * `server` - The DNS server address
127/// * `compare_fn` - Callback to compare existing records with desired state
128///
129/// # Returns
130///
131/// Returns `Ok(true)` if update is needed, `Ok(false)` if record already matches.
132///
133/// # Errors
134///
135/// Returns an error only if the query fails critically.
136pub async fn should_update_record<F>(
137    zone_name: &str,
138    name: &str,
139    record_type: RecordType,
140    record_type_name: &str,
141    server: &str,
142    compare_fn: F,
143) -> Result<bool>
144where
145    F: FnOnce(&[Record]) -> bool,
146{
147    match query_dns_record(zone_name, name, record_type, server).await {
148        Ok(existing_records) if !existing_records.is_empty() => {
149            if compare_fn(&existing_records) {
150                info!(
151                    "{} record {} already exists with correct value - no changes needed",
152                    record_type_name, name
153                );
154                Ok(false)
155            } else {
156                info!(
157                    "{} record {} exists with different value(s), updating",
158                    record_type_name, name
159                );
160                Ok(true)
161            }
162        }
163        Ok(_) => {
164            info!(
165                "{} record {} does not exist, creating",
166                record_type_name, name
167            );
168            Ok(true)
169        }
170        Err(e) => {
171            warn!(
172                "Failed to query existing {} record {} (will attempt update anyway): {}",
173                record_type_name, name, e
174            );
175            Ok(true)
176        }
177    }
178}
179
180/// Delete a DNS record of any type using dynamic DNS update (RFC 2136).
181///
182/// This function sends an RFC 2136 DELETE operation to remove ALL records
183/// of the specified type for the given name.
184///
185/// # Arguments
186///
187/// * `zone_name` - The DNS zone name (e.g., "example.com")
188/// * `name` - The record name (e.g., "www" for www.example.com, or "@" for apex)
189/// * `record_type` - The DNS record type to delete (A, AAAA, TXT, MX, etc.)
190/// * `server` - The DNS server address (IP:port, e.g., "10.0.0.1:53")
191/// * `key_data` - TSIG key for authentication
192///
193/// # Returns
194///
195/// Returns `Ok(())` if deletion succeeded (or if record didn't exist).
196///
197/// # Errors
198///
199/// Returns an error if the DNS server rejects the update or connection fails.
200pub async fn delete_dns_record(
201    zone_name: &str,
202    name: &str,
203    record_type: RecordType,
204    server: &str,
205    key_data: &RndcKeyData,
206) -> Result<()> {
207    let mut client = build_authenticated_client(server, key_data).await?;
208    let zone =
209        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
210    let fqdn = build_record_fqdn(zone_name, name)?;
211
212    info!(
213        "Deleting {:?} record: {} from zone {}",
214        record_type, fqdn, zone_name
215    );
216
217    // Build a placeholder record. delete_rrset() overwrites class/ttl/data based on record_type.
218    let dummy_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(record_type));
219
220    let response = client
221        .delete_rrset(dummy_record, zone)
222        .await
223        .with_context(|| {
224            format!("Failed to send DNS UPDATE to delete {record_type:?} record {fqdn}")
225        })?;
226
227    match response.metadata.response_code {
228        ResponseCode::NoError => {
229            info!(
230                "Successfully deleted {:?} record: {} from zone {}",
231                record_type, name, zone_name
232            );
233            Ok(())
234        }
235        code => {
236            warn!(
237                "DNS DELETE for {:?} record {fqdn} returned code: {:?} (may not have existed)",
238                record_type, code
239            );
240            // Deletion is idempotent: don't treat "not found" as error.
241            Ok(())
242        }
243    }
244}
245
246#[cfg(test)]
247mod mod_tests;