bindcar/
rndc.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC command execution using native RNDC protocol
5//!
6//! This module communicates with BIND9 using the RNDC protocol directly,
7//! rather than shelling out to the rndc binary. This provides:
8//! - Better error handling with structured responses
9//! - No subprocess overhead
10//! - Native async support
11//! - Direct access to error messages from BIND9
12
13use anyhow::{Context, Result};
14use rndc::RndcClient;
15use std::time::Instant;
16use tracing::{debug, error, info};
17
18use crate::metrics;
19
20/// RNDC configuration parsed from rndc.conf
21#[derive(Clone)]
22pub struct RndcConfig {
23    pub server: String,
24    pub algorithm: String,
25    pub secret: String,
26}
27
28// Manual `Debug` that redacts the TSIG secret (A4). The derived impl would print
29// the plaintext key on any `{:?}` / panic backtrace / `.context()`; redacting at
30// the type keeps that credential out of logs and diagnostics entirely.
31impl std::fmt::Debug for RndcConfig {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("RndcConfig")
34            .field("server", &self.server)
35            .field("algorithm", &self.algorithm)
36            .field("secret", &"[REDACTED]")
37            .finish()
38    }
39}
40
41/// HMAC algorithms accepted for the RNDC control channel (SHA-2 family only).
42/// HMAC-MD5 / HMAC-SHA1 are deprecated and intentionally excluded (A12).
43const ACCEPTED_RNDC_ALGORITHMS: &[&str] = &["sha224", "sha256", "sha384", "sha512"];
44
45/// Maximum length of a DNS zone name (RFC 1035 total name length).
46const MAX_ZONE_NAME_LEN: usize = 253;
47
48/// Default RNDC port when the address carries no explicit port.
49const DEFAULT_RNDC_PORT: u16 = 953;
50
51/// Re-validate a zone name at the RNDC sink (defense-in-depth, A9).
52///
53/// Every executor method interpolates a caller-supplied zone name into an RNDC
54/// control-channel command line. The HTTP handlers validate first, but this
55/// guard-at-the-sink means a missing caller-side check (which has regressed
56/// before, see commit `c514ff9`) cannot inject extra RNDC arguments. Only the
57/// DNS charset `[A-Za-z0-9._-]` (bounded length, no `..`) is accepted.
58///
59/// # Errors
60/// Returns an error if the name is empty, too long, contains `..`, or has a
61/// character outside the permitted set.
62pub(crate) fn validate_rndc_zone_name(zone_name: &str) -> Result<()> {
63    if zone_name.is_empty() || zone_name.len() > MAX_ZONE_NAME_LEN {
64        return Err(anyhow::anyhow!("invalid zone name length"));
65    }
66    if zone_name.contains("..") {
67        return Err(anyhow::anyhow!("zone name must not contain '..'"));
68    }
69    if let Some(bad) = zone_name
70        .chars()
71        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
72    {
73        return Err(anyhow::anyhow!(
74            "zone name contains invalid character: {:?}",
75            bad
76        ));
77    }
78    Ok(())
79}
80
81/// RNDC command executor using native protocol
82pub struct RndcExecutor {
83    client: RndcClient,
84}
85
86impl RndcExecutor {
87    /// Create a new RNDC executor
88    ///
89    /// # Arguments
90    /// * `server` - RNDC server address (e.g., "127.0.0.1:953")
91    /// * `algorithm` - HMAC algorithm, accepts both formats:
92    ///   - With prefix: "hmac-md5", "hmac-sha1", "hmac-sha224", "hmac-sha256", "hmac-sha384", "hmac-sha512"
93    ///   - Without prefix: "md5", "sha1", "sha224", "sha256", "sha384", "sha512"
94    /// * `secret` - Base64-encoded RNDC secret key
95    ///
96    /// # Returns
97    /// A new RndcExecutor instance
98    pub fn new(server: String, algorithm: String, secret: String) -> Result<Self> {
99        // Trim whitespace from all parameters to handle environment variable issues
100        let server = server.trim();
101        let mut algorithm = algorithm.trim().to_string();
102        let secret = secret.trim();
103
104        // The rndc crate v0.1.3 only accepts algorithms WITHOUT the "hmac-" prefix
105        // Strip it if present (rndc.conf files typically use "hmac-sha256" format)
106        if algorithm.starts_with("hmac-") {
107            algorithm = algorithm.trim_start_matches("hmac-").to_string();
108        }
109
110        debug!("Using algorithm: {} for server: {}", algorithm, server);
111
112        // Validate algorithm. HMAC-MD5 and HMAC-SHA1 are deprecated and rejected
113        // for the RNDC control channel in this zero-trust posture (A12); only the
114        // SHA-2 family is accepted.
115        if !ACCEPTED_RNDC_ALGORITHMS.contains(&algorithm.as_str()) {
116            return Err(anyhow::anyhow!(
117                "Invalid or deprecated algorithm '{}'. Accepted algorithms (without 'hmac-' prefix): {:?}",
118                algorithm,
119                ACCEPTED_RNDC_ALGORITHMS
120            ));
121        }
122
123        let client = RndcClient::new(server, &algorithm, secret)?;
124
125        Ok(Self { client })
126    }
127
128    /// Execute an RNDC command
129    ///
130    /// # Arguments
131    /// * `command` - Command string (e.g., "status", "reload example.com")
132    ///
133    /// # Returns
134    /// The response text from RNDC on success
135    ///
136    /// # Errors
137    /// Returns an error if the RNDC command fails, including detailed error
138    /// information from the BIND9 server
139    async fn execute(&self, command: &str) -> Result<String> {
140        debug!("Executing RNDC command: {}", command);
141
142        let start = Instant::now();
143        let command_name = command.split_whitespace().next().unwrap_or("unknown");
144
145        // Execute RNDC command using native protocol
146        let result = tokio::task::spawn_blocking({
147            let client = self.client.clone();
148            let command = command.to_string();
149            move || client.rndc_command(&command)
150        })
151        .await
152        .with_context(|| {
153            format!(
154                "Failed to execute RNDC command '{}': task join error",
155                command_name
156            )
157        })?;
158
159        let duration = start.elapsed().as_secs_f64();
160
161        let rndc_result = result.map_err(|e| {
162            let error_msg = format!("RNDC command '{}' failed: {}", command_name, e);
163            error!("{}", error_msg);
164            metrics::record_rndc_command(command_name, false, duration);
165            anyhow::anyhow!("{}", error_msg)
166        })?;
167
168        // Check if the RNDC result contains an error
169        if let Some(err) = &rndc_result.err {
170            let error_msg = format!("RNDC command '{}' failed: {}", command_name, err);
171            error!("{}", error_msg);
172            metrics::record_rndc_command(command_name, false, duration);
173            return Err(anyhow::anyhow!("{}", error_msg));
174        }
175
176        // Success - return the text response
177        let response = rndc_result.text.unwrap_or_default();
178        debug!("RNDC command '{}' succeeded: {}", command_name, response);
179        metrics::record_rndc_command(command_name, true, duration);
180        Ok(response)
181    }
182
183    /// Get server status
184    pub async fn status(&self) -> Result<String> {
185        self.execute("status").await
186    }
187
188    /// Add a zone
189    ///
190    /// # Arguments
191    /// * `zone_name` - Name of the zone (e.g., "example.com")
192    /// * `zone_config` - Zone configuration block (e.g., "{ type primary; file \"/var/cache/bind/example.com.zone\"; }")
193    pub async fn addzone(&self, zone_name: &str, zone_config: &str) -> Result<String> {
194        validate_rndc_zone_name(zone_name)?;
195        let command = format!("addzone {} {}", zone_name, zone_config);
196        self.execute(&command).await
197    }
198
199    /// Delete a zone
200    pub async fn delzone(&self, zone_name: &str) -> Result<String> {
201        validate_rndc_zone_name(zone_name)?;
202        let command = format!("delzone {}", zone_name);
203        self.execute(&command).await
204    }
205
206    /// Reload a zone
207    pub async fn reload(&self, zone_name: &str) -> Result<String> {
208        validate_rndc_zone_name(zone_name)?;
209        let command = format!("reload {}", zone_name);
210        self.execute(&command).await
211    }
212
213    /// Get zone status
214    pub async fn zonestatus(&self, zone_name: &str) -> Result<String> {
215        validate_rndc_zone_name(zone_name)?;
216        let command = format!("zonestatus {}", zone_name);
217        self.execute(&command).await
218    }
219
220    /// Freeze a zone (disable dynamic updates)
221    pub async fn freeze(&self, zone_name: &str) -> Result<String> {
222        validate_rndc_zone_name(zone_name)?;
223        let command = format!("freeze {}", zone_name);
224        self.execute(&command).await
225    }
226
227    /// Thaw a zone (enable dynamic updates)
228    pub async fn thaw(&self, zone_name: &str) -> Result<String> {
229        validate_rndc_zone_name(zone_name)?;
230        let command = format!("thaw {}", zone_name);
231        self.execute(&command).await
232    }
233
234    /// Notify secondaries about zone changes
235    pub async fn notify(&self, zone_name: &str) -> Result<String> {
236        validate_rndc_zone_name(zone_name)?;
237        let command = format!("notify {}", zone_name);
238        self.execute(&command).await
239    }
240
241    /// Force a zone retransfer from primary
242    ///
243    /// This command is used on secondary zones to discard the current zone data
244    /// and initiate a fresh transfer from the primary server.
245    pub async fn retransfer(&self, zone_name: &str) -> Result<String> {
246        validate_rndc_zone_name(zone_name)?;
247        let command = format!("retransfer {}", zone_name);
248        self.execute(&command).await
249    }
250
251    /// Modify a zone configuration
252    ///
253    /// # Arguments
254    /// * `zone_name` - Name of the zone (e.g., "example.com")
255    /// * `zone_config` - Zone configuration block (e.g., "{ also-notify { 10.0.0.1; }; allow-transfer { 10.0.0.2; }; }")
256    pub async fn modzone(&self, zone_name: &str, zone_config: &str) -> Result<String> {
257        validate_rndc_zone_name(zone_name)?;
258        let command = format!("modzone {} {}", zone_name, zone_config);
259        self.execute(&command).await
260    }
261
262    /// Show zone configuration
263    ///
264    /// # Arguments
265    /// * `zone_name` - Name of the zone (e.g., "example.com")
266    ///
267    /// # Returns
268    /// The zone configuration in BIND9 format
269    pub async fn showzone(&self, zone_name: &str) -> Result<String> {
270        validate_rndc_zone_name(zone_name)?;
271        let command = format!("showzone {}", zone_name);
272        self.execute(&command).await
273    }
274}
275
276impl Clone for RndcExecutor {
277    fn clone(&self) -> Self {
278        Self {
279            client: self.client.clone(),
280        }
281    }
282}
283
284/// Parse RNDC configuration from rndc.conf file
285///
286/// # Arguments
287/// * `path` - Path to rndc.conf file (typically /etc/bind/rndc.conf or /etc/rndc.conf)
288///
289/// # Returns
290/// RndcConfig with server, algorithm, and secret
291///
292/// # Errors
293/// Returns an error if the file cannot be read or parsed
294pub fn parse_rndc_conf(path: &str) -> Result<RndcConfig> {
295    use crate::rndc_conf_parser::parse_rndc_conf_file;
296    use std::path::Path;
297
298    info!("Parsing rndc.conf from {}", path);
299
300    // Use new parser
301    let conf_file = parse_rndc_conf_file(Path::new(path))
302        .map_err(|e| anyhow::anyhow!("Failed to parse rndc.conf: {}", e))?;
303
304    // Select the key. Prefer an explicit `default-key`; otherwise pick
305    // deterministically and refuse to guess when the choice is ambiguous (A18) —
306    // the previous `keys().next()` returned an arbitrary HashMap entry, so with
307    // multiple keys the client could authenticate with an unintended one.
308    let default_key_name = match conf_file.options.default_key.clone() {
309        Some(name) => Some(name),
310        None => {
311            if conf_file.keys.len() > 1 {
312                return Err(anyhow::anyhow!(
313                    "rndc.conf defines {} keys but no `default-key`; specify one explicitly",
314                    conf_file.keys.len()
315                ));
316            }
317            let mut names: Vec<&String> = conf_file.keys.keys().collect();
318            names.sort();
319            names.first().map(|name| (*name).clone())
320        }
321    };
322
323    let key_block = if let Some(ref key_name) = default_key_name {
324        conf_file
325            .keys
326            .get(key_name)
327            .ok_or_else(|| anyhow::anyhow!("Default key '{}' not found", key_name))?
328    } else {
329        return Err(anyhow::anyhow!("No keys found in configuration"));
330    };
331
332    // Reject an empty secret (A19): a key block with a missing/blank secret would
333    // otherwise flow into RndcClient with no credential, masking a
334    // misconfiguration that must fail fast.
335    if key_block.secret.trim().is_empty() {
336        return Err(anyhow::anyhow!(
337            "key '{}' has an empty secret",
338            default_key_name.as_deref().unwrap_or("unnamed")
339        ));
340    }
341
342    // Extract server address from options or use default
343    let server = conf_file
344        .options
345        .default_server
346        .clone()
347        .unwrap_or_else(|| "127.0.0.1".to_string());
348
349    // Add port if not present
350    let server = if server.contains(':') {
351        server
352    } else {
353        let port = conf_file.options.default_port.unwrap_or(DEFAULT_RNDC_PORT);
354        format!("{}:{}", server, port)
355    };
356
357    info!(
358        "Parsed rndc configuration: server={}, algorithm={}, key={}",
359        server,
360        key_block.algorithm,
361        default_key_name.unwrap_or_else(|| "unnamed".to_string())
362    );
363
364    Ok(RndcConfig {
365        server,
366        algorithm: key_block.algorithm.clone(),
367        secret: key_block.secret.clone(),
368    })
369}