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}