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 /// Reload the server configuration (`rndc reconfig`).
276 ///
277 /// Loads new or changed configuration without re-transferring or
278 /// bouncing unaffected zones. Required after [`Self::modzone`] stores a
279 /// `dnssec-policy`: verified on BIND 9.18.50 (ADR-0001), modzone
280 /// persists the policy to the NZD without applying it to the running
281 /// zone — only a reconfig (or full reload) activates it.
282 ///
283 /// # Errors
284 /// Returns an error if the RNDC command fails.
285 pub async fn reconfig(&self) -> Result<String> {
286 self.execute("reconfig").await
287 }
288
289 /// Get a zone's DNSSEC signing state (`rndc dnssec -status <zone>`).
290 ///
291 /// The raw output is parsed with [`crate::dnssec::parse_dnssec_status`].
292 /// A zone without a policy returns the literal text
293 /// `Zone does not have dnssec-policy` rather than an error.
294 ///
295 /// # Arguments
296 /// * `zone_name` - Name of the zone (e.g., "example.com")
297 ///
298 /// # Errors
299 /// Returns an error for an invalid zone name or a failed RNDC command.
300 pub async fn dnssec_status(&self, zone_name: &str) -> Result<String> {
301 validate_rndc_zone_name(zone_name)?;
302 let command = format!("dnssec -status {}", zone_name);
303 self.execute(&command).await
304 }
305
306 /// Report a DS state change at the parent zone to named
307 /// (`rndc dnssec -checkds -key <tag> <published|withdrawn> <zone>`).
308 ///
309 /// Used when no parental agents are configured: named will not advance
310 /// a key's DS-dependent state transitions (including the graceful
311 /// unsigning managed by the built-in `insecure` policy) until it is told
312 /// the DS was published or withdrawn at the parent.
313 ///
314 /// # Arguments
315 /// * `zone_name` - Name of the zone (e.g., "example.com")
316 /// * `key_tag` - Key tag of the DNSKEY whose DS changed at the parent
317 /// * `state` - Whether the DS is now published or withdrawn
318 ///
319 /// # Errors
320 /// Returns an error for an invalid zone name or a failed RNDC command.
321 pub async fn dnssec_checkds(
322 &self,
323 zone_name: &str,
324 key_tag: u16,
325 state: crate::dnssec::CheckdsState,
326 ) -> Result<String> {
327 validate_rndc_zone_name(zone_name)?;
328 let command = format!(
329 "dnssec -checkds -key {} {} {}",
330 key_tag,
331 state.as_str(),
332 zone_name
333 );
334 self.execute(&command).await
335 }
336}
337
338impl Clone for RndcExecutor {
339 fn clone(&self) -> Self {
340 Self {
341 client: self.client.clone(),
342 }
343 }
344}
345
346/// Parse RNDC configuration from rndc.conf file
347///
348/// # Arguments
349/// * `path` - Path to rndc.conf file (typically /etc/bind/rndc.conf or /etc/rndc.conf)
350///
351/// # Returns
352/// RndcConfig with server, algorithm, and secret
353///
354/// # Errors
355/// Returns an error if the file cannot be read or parsed
356pub fn parse_rndc_conf(path: &str) -> Result<RndcConfig> {
357 use crate::rndc_conf_parser::parse_rndc_conf_file;
358 use std::path::Path;
359
360 info!("Parsing rndc.conf from {}", path);
361
362 // Use new parser
363 let conf_file = parse_rndc_conf_file(Path::new(path))
364 .map_err(|e| anyhow::anyhow!("Failed to parse rndc.conf: {}", e))?;
365
366 // Select the key. Prefer an explicit `default-key`; otherwise pick
367 // deterministically and refuse to guess when the choice is ambiguous (A18) —
368 // the previous `keys().next()` returned an arbitrary HashMap entry, so with
369 // multiple keys the client could authenticate with an unintended one.
370 let default_key_name = match conf_file.options.default_key.clone() {
371 Some(name) => Some(name),
372 None => {
373 if conf_file.keys.len() > 1 {
374 return Err(anyhow::anyhow!(
375 "rndc.conf defines {} keys but no `default-key`; specify one explicitly",
376 conf_file.keys.len()
377 ));
378 }
379 let mut names: Vec<&String> = conf_file.keys.keys().collect();
380 names.sort();
381 names.first().map(|name| (*name).clone())
382 }
383 };
384
385 let key_block = if let Some(ref key_name) = default_key_name {
386 conf_file
387 .keys
388 .get(key_name)
389 .ok_or_else(|| anyhow::anyhow!("Default key '{}' not found", key_name))?
390 } else {
391 return Err(anyhow::anyhow!("No keys found in configuration"));
392 };
393
394 // Reject an empty secret (A19): a key block with a missing/blank secret would
395 // otherwise flow into RndcClient with no credential, masking a
396 // misconfiguration that must fail fast.
397 if key_block.secret.trim().is_empty() {
398 return Err(anyhow::anyhow!(
399 "key '{}' has an empty secret",
400 default_key_name.as_deref().unwrap_or("unnamed")
401 ));
402 }
403
404 // Extract server address from options or use default
405 let server = conf_file
406 .options
407 .default_server
408 .clone()
409 .unwrap_or_else(|| "127.0.0.1".to_string());
410
411 // Add port if not present
412 let server = if server.contains(':') {
413 server
414 } else {
415 let port = conf_file.options.default_port.unwrap_or(DEFAULT_RNDC_PORT);
416 format!("{}:{}", server, port)
417 };
418
419 info!(
420 "Parsed rndc configuration: server={}, algorithm={}, key={}",
421 server,
422 key_block.algorithm,
423 default_key_name.unwrap_or_else(|| "unnamed".to_string())
424 );
425
426 Ok(RndcConfig {
427 server,
428 algorithm: key_block.algorithm.clone(),
429 secret: key_block.secret.clone(),
430 })
431}