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_client::client::{Client, SyncClient};
19use hickory_client::rr::Name;
20use hickory_client::rr::{DNSClass, Record};
21use hickory_client::udp::UdpClientConnection;
22use std::str::FromStr;
23use tracing::{info, warn};
24
25/// Generic DNS record query function.
26///
27/// Queries a DNS server for records of a specific type and returns the results.
28///
29/// # Arguments
30///
31/// * `zone_name` - The DNS zone name
32/// * `name` - The record name (e.g., "www" for www.example.com, or "@" for apex)
33/// * `record_type` - The DNS record type (A, AAAA, TXT, MX, etc.)
34/// * `server` - The DNS server address (IP:port)
35///
36/// # Returns
37///
38/// Returns `Ok(vec)` with matching records (empty if none exist),
39/// or an error if the query fails.
40///
41/// # Errors
42///
43/// Returns an error if the DNS query fails or cannot be parsed.
44pub async fn query_dns_record(
45    zone_name: &str,
46    name: &str,
47    record_type: hickory_client::rr::RecordType,
48    server: &str,
49) -> Result<Vec<Record>> {
50    let zone_name_str = zone_name.to_string();
51    let name_str = name.to_string();
52    let server_str = server.to_string();
53
54    tokio::task::spawn_blocking(move || {
55        // Parse server address
56        let server_addr = server_str
57            .parse::<std::net::SocketAddr>()
58            .with_context(|| format!("Invalid server address: {server_str}"))?;
59
60        // Create UDP connection for query
61        let conn = UdpClientConnection::new(server_addr)
62            .context("Failed to create UDP connection for query")?;
63        let client = SyncClient::new(conn);
64
65        // Build full record name
66        let fqdn = if name_str == "@" || name_str.is_empty() {
67            Name::from_str(&zone_name_str)
68                .with_context(|| format!("Invalid zone name: {zone_name_str}"))?
69        } else {
70            Name::from_str(&format!("{name_str}.{zone_name_str}"))
71                .with_context(|| format!("Invalid record name: {name_str}.{zone_name_str}"))?
72        };
73
74        // Query for records
75        let response = client
76            .query(&fqdn, DNSClass::IN, record_type)
77            .with_context(|| format!("Failed to query {record_type:?} record for {fqdn}"))?;
78
79        // Extract matching records from response
80        let records: Vec<Record> = response
81            .answers()
82            .iter()
83            .filter(|r| r.record_type() == record_type)
84            .cloned()
85            .collect();
86
87        Ok(records)
88    })
89    .await
90    .context("DNS query task failed")?
91}
92
93/// Helper for declarative record reconciliation.
94///
95/// Implements the observe → diff → act pattern for DNS records:
96/// 1. Query existing record
97/// 2. Compare with desired state using provided callback
98/// 3. Skip if already correct, otherwise proceed with update
99///
100/// # Arguments
101///
102/// * `zone_name` - The DNS zone name
103/// * `name` - The record name
104/// * `record_type` - The DNS record type
105/// * `record_type_name` - Human-readable name (e.g., "A", "AAAA")
106/// * `server` - The DNS server address
107/// * `compare_fn` - Callback to compare existing records with desired state
108///
109/// # Returns
110///
111/// Returns `Ok(true)` if update is needed, `Ok(false)` if record already matches.
112///
113/// # Errors
114///
115/// Returns an error only if the query fails critically.
116pub async fn should_update_record<F>(
117    zone_name: &str,
118    name: &str,
119    record_type: hickory_client::rr::RecordType,
120    record_type_name: &str,
121    server: &str,
122    compare_fn: F,
123) -> Result<bool>
124where
125    F: FnOnce(&[Record]) -> bool,
126{
127    match query_dns_record(zone_name, name, record_type, server).await {
128        Ok(existing_records) if !existing_records.is_empty() => {
129            // Records exist - use callback to compare
130            if compare_fn(&existing_records) {
131                info!(
132                    "{} record {} already exists with correct value - no changes needed",
133                    record_type_name, name
134                );
135                Ok(false) // Skip update
136            } else {
137                info!(
138                    "{} record {} exists with different value(s), updating",
139                    record_type_name, name
140                );
141                Ok(true) // Need update
142            }
143        }
144        Ok(_) => {
145            // No records exist
146            info!(
147                "{} record {} does not exist, creating",
148                record_type_name, name
149            );
150            Ok(true) // Need creation
151        }
152        Err(e) => {
153            // Query failed - log warning but allow update attempt
154            warn!(
155                "Failed to query existing {} record {} (will attempt update anyway): {}",
156                record_type_name, name, e
157            );
158            Ok(true) // Proceed with update
159        }
160    }
161}