bindcar/nsupdate.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! nsupdate command executor for dynamic DNS updates
5//!
6//! This module provides programmatic access to BIND9's nsupdate utility for
7//! performing dynamic DNS record updates using the DNS UPDATE protocol (RFC 2136).
8//!
9//! # Features
10//!
11//! - TSIG authentication support
12//! - Add, remove, and update individual DNS records
13//! - Async command execution with tokio
14//! - Comprehensive error handling and parsing
15
16use anyhow::{Context, Result};
17use std::ffi::OsString;
18use std::io::Write;
19use std::path::Path;
20use std::process::Stdio;
21use std::time::Instant;
22use tokio::io::AsyncWriteExt;
23use tracing::{debug, error, info};
24
25use crate::metrics;
26
27/// Maximum length of a TSIG key name (matches the DNS name length limit).
28const MAX_TSIG_KEY_NAME_LEN: usize = 253;
29
30/// HMAC algorithms accepted in a TSIG key file (SHA-2 family only).
31/// `hmac-md5` and `hmac-sha1` are rejected, matching the policy the RNDC
32/// control channel enforces (`ACCEPTED_RNDC_ALGORITHMS` in `rndc.rs`).
33const ALLOWED_TSIG_ALGORITHMS: &[&str] =
34 &["hmac-sha224", "hmac-sha256", "hmac-sha384", "hmac-sha512"];
35
36/// nsupdate command executor
37///
38/// Manages dynamic DNS updates via the nsupdate command-line tool.
39/// Supports TSIG authentication for secure updates.
40#[derive(Clone)]
41pub struct NsupdateExecutor {
42 /// Optional TSIG key name for authentication
43 tsig_key_name: Option<String>,
44 /// Optional TSIG algorithm (e.g., "HMAC-SHA256")
45 tsig_algorithm: Option<String>,
46 /// Optional TSIG secret (base64-encoded)
47 tsig_secret: Option<String>,
48 /// DNS server address
49 server: String,
50 /// DNS server port
51 port: u16,
52 /// Force TCP transport (nsupdate -v); required in environments where
53 /// UDP is unreliable, e.g., Docker Desktop on macOS.
54 use_tcp: bool,
55}
56
57impl NsupdateExecutor {
58 /// Create a new nsupdate executor
59 ///
60 /// # Arguments
61 ///
62 /// * `server` - DNS server address (e.g., "127.0.0.1")
63 /// * `port` - DNS server port (typically 53)
64 /// * `tsig_key_name` - Optional TSIG key name
65 /// * `tsig_algorithm` - Optional TSIG algorithm
66 /// * `tsig_secret` - Optional TSIG secret (base64-encoded)
67 ///
68 /// # Example
69 ///
70 /// ```ignore
71 /// use bindcar::nsupdate::NsupdateExecutor;
72 ///
73 /// let executor = NsupdateExecutor::new(
74 /// "127.0.0.1".to_string(),
75 /// 53,
76 /// Some("update-key".to_string()),
77 /// Some("HMAC-SHA256".to_string()),
78 /// Some("dGVzdC1zZWNyZXQ=".to_string()),
79 /// )?;
80 /// ```
81 pub fn new(
82 server: String,
83 port: u16,
84 tsig_key_name: Option<String>,
85 tsig_algorithm: Option<String>,
86 tsig_secret: Option<String>,
87 ) -> Result<Self> {
88 info!(
89 "Creating nsupdate executor for {}:{} with TSIG: {}",
90 server,
91 port,
92 tsig_key_name.is_some()
93 );
94
95 let use_tcp = std::env::var("NSUPDATE_TCP")
96 .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
97 .unwrap_or(false);
98
99 Ok(Self {
100 tsig_key_name,
101 tsig_algorithm,
102 tsig_secret,
103 server,
104 port,
105 use_tcp,
106 })
107 }
108
109 /// Create a private (mode `0600`) temporary key file holding the TSIG key,
110 /// for use with `nsupdate -k`.
111 ///
112 /// Returns `None` when TSIG is not configured. The file is deleted when the
113 /// returned guard is dropped — callers must keep it alive until nsupdate has
114 /// finished reading it.
115 ///
116 /// Passing the key via `-k <file>` instead of `-y algo:name:secret` keeps the
117 /// secret out of the process argument vector, which is world-readable through
118 /// `/proc/<pid>/cmdline` and process listings (B-7).
119 ///
120 /// # Errors
121 /// Returns an error if the key material fails validation or the file cannot
122 /// be created or written.
123 pub(crate) fn create_tsig_key_file(&self) -> Result<Option<tempfile::NamedTempFile>> {
124 let (Some(key_name), Some(algorithm), Some(secret)) = (
125 self.tsig_key_name.as_deref(),
126 self.tsig_algorithm.as_deref(),
127 self.tsig_secret.as_deref(),
128 ) else {
129 return Ok(None);
130 };
131
132 let content = build_tsig_key_file_content(key_name, algorithm, secret)?;
133
134 // NamedTempFile is created with mode 0600 on Unix.
135 let mut keyfile =
136 tempfile::NamedTempFile::new().context("Failed to create temporary TSIG key file")?;
137 keyfile
138 .write_all(content.as_bytes())
139 .context("Failed to write TSIG key file")?;
140 keyfile.flush().context("Failed to flush TSIG key file")?;
141
142 debug!("Using TSIG authentication with key: {}", key_name);
143 Ok(Some(keyfile))
144 }
145
146 /// Execute nsupdate commands
147 ///
148 /// # Arguments
149 ///
150 /// * `commands` - nsupdate commands as a string (one command per line)
151 ///
152 /// # Returns
153 ///
154 /// Success or error message from nsupdate
155 async fn execute(&self, commands: &str) -> Result<String> {
156 let start = Instant::now();
157
158 debug!("Executing nsupdate commands:\n{}", commands);
159
160 // TSIG key goes into a 0600 temp file passed via -k, never into argv
161 // (B-7). The guard keeps the file alive until nsupdate has completed.
162 let keyfile = self.create_tsig_key_file()?;
163
164 let mut cmd = tokio::process::Command::new("nsupdate");
165 cmd.args(build_nsupdate_args(
166 self.use_tcp,
167 keyfile.as_ref().map(tempfile::NamedTempFile::path),
168 ));
169
170 // Scrub the child's environment (A-5). The API process holds
171 // NSUPDATE_SECRET / RNDC_SECRET in its own environment, and a spawned
172 // child inherits the parent environment by default — exposing the
173 // base64 secrets via /proc/<pid>/environ to any co-located principal for
174 // the lifetime of the nsupdate process. nsupdate needs none of those; we
175 // pass only an allowlisted PATH so the binary can still be resolved.
176 cmd.env_clear().envs(minimal_child_env());
177
178 cmd.stdin(Stdio::piped())
179 .stdout(Stdio::piped())
180 .stderr(Stdio::piped());
181
182 let mut child = cmd.spawn().context("Failed to spawn nsupdate process")?;
183
184 // Write commands to stdin
185 if let Some(mut stdin) = child.stdin.take() {
186 stdin
187 .write_all(commands.as_bytes())
188 .await
189 .context("Failed to write to nsupdate stdin")?;
190 stdin.flush().await.context("Failed to flush stdin")?;
191 }
192
193 // Wait for completion and capture output
194 let output = child
195 .wait_with_output()
196 .await
197 .context("Failed to wait for nsupdate")?;
198
199 // nsupdate has exited — remove the key file immediately rather than
200 // waiting for scope end.
201 drop(keyfile);
202
203 let duration = start.elapsed().as_secs_f64();
204
205 // Handle errors
206 if !output.status.success() {
207 let stderr = String::from_utf8_lossy(&output.stderr);
208 let error_msg = parse_nsupdate_error(&stderr);
209 error!("nsupdate failed: {}", error_msg);
210 metrics::record_nsupdate_command("update", false, duration);
211 return Err(anyhow::anyhow!("nsupdate failed: {}", error_msg));
212 }
213
214 metrics::record_nsupdate_command("update", true, duration);
215
216 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
217 debug!("nsupdate completed successfully in {:.3}s", duration);
218
219 Ok(stdout)
220 }
221
222 /// Add a DNS record
223 ///
224 /// # Arguments
225 ///
226 /// * `zone` - Zone name (e.g., "example.com")
227 /// * `name` - Record name (FQDN, e.g., "www.example.com.")
228 /// * `ttl` - Time-to-live in seconds
229 /// * `record_type` - Record type (e.g., "A", "AAAA", "CNAME")
230 /// * `value` - Record value (e.g., "192.0.2.1")
231 ///
232 /// # Example
233 ///
234 /// ```ignore
235 /// executor.add_record(
236 /// "example.com",
237 /// "www.example.com.",
238 /// 3600,
239 /// "A",
240 /// "192.0.2.1"
241 /// ).await?;
242 /// ```
243 pub async fn add_record(
244 &self,
245 zone: &str,
246 name: &str,
247 ttl: u32,
248 record_type: &str,
249 value: &str,
250 ) -> Result<String> {
251 info!(
252 "Adding {} record: {} -> {} (TTL: {})",
253 record_type, name, value, ttl
254 );
255
256 // Defense-in-depth: reject control characters before assembling the
257 // newline-delimited nsupdate command script.
258 reject_injection_chars("zone", zone)?;
259 reject_injection_chars("name", name)?;
260 reject_injection_chars("value", value)?;
261
262 let commands = format!(
263 "server {} {}\nzone {}\nupdate add {} {} IN {} {}\nsend\n",
264 self.server, self.port, zone, name, ttl, record_type, value
265 );
266
267 self.execute(&commands).await
268 }
269
270 /// Remove a DNS record
271 ///
272 /// # Arguments
273 ///
274 /// * `zone` - Zone name (e.g., "example.com")
275 /// * `name` - Record name (FQDN, e.g., "www.example.com.")
276 /// * `record_type` - Record type (e.g., "A")
277 /// * `value` - Record value to remove (e.g., "192.0.2.1"). If empty, removes all records of this type.
278 ///
279 /// # Example
280 ///
281 /// ```ignore
282 /// // Remove specific record
283 /// executor.remove_record(
284 /// "example.com",
285 /// "www.example.com.",
286 /// "A",
287 /// "192.0.2.1"
288 /// ).await?;
289 ///
290 /// // Remove all A records for www
291 /// executor.remove_record(
292 /// "example.com",
293 /// "www.example.com.",
294 /// "A",
295 /// ""
296 /// ).await?;
297 /// ```
298 pub async fn remove_record(
299 &self,
300 zone: &str,
301 name: &str,
302 record_type: &str,
303 value: &str,
304 ) -> Result<String> {
305 info!(
306 "Removing {} record: {} {} {}",
307 record_type,
308 name,
309 if value.is_empty() { "(all)" } else { value },
310 ""
311 );
312
313 // Defense-in-depth: reject control characters before assembling the
314 // newline-delimited nsupdate command script.
315 reject_injection_chars("zone", zone)?;
316 reject_injection_chars("name", name)?;
317 reject_injection_chars("value", value)?;
318
319 // Build delete command - with or without value
320 let delete_cmd = if value.is_empty() {
321 // Remove all records of this type
322 format!("update delete {} {}", name, record_type)
323 } else {
324 // Remove specific record
325 format!("update delete {} {} {}", name, record_type, value)
326 };
327
328 let commands = format!(
329 "server {} {}\nzone {}\n{}\nsend\n",
330 self.server, self.port, zone, delete_cmd
331 );
332
333 self.execute(&commands).await
334 }
335
336 /// Update a DNS record (atomic delete + add)
337 ///
338 /// # Arguments
339 ///
340 /// * `zone` - Zone name (e.g., "example.com")
341 /// * `name` - Record name (FQDN, e.g., "www.example.com.")
342 /// * `ttl` - New time-to-live in seconds
343 /// * `record_type` - Record type (e.g., "A")
344 /// * `old_value` - Current record value (e.g., "192.0.2.1")
345 /// * `new_value` - New record value (e.g., "192.0.2.2")
346 ///
347 /// # Example
348 ///
349 /// ```ignore
350 /// executor.update_record(
351 /// "example.com",
352 /// "www.example.com.",
353 /// 3600,
354 /// "A",
355 /// "192.0.2.1",
356 /// "192.0.2.2"
357 /// ).await?;
358 /// ```
359 pub async fn update_record(
360 &self,
361 zone: &str,
362 name: &str,
363 ttl: u32,
364 record_type: &str,
365 old_value: &str,
366 new_value: &str,
367 ) -> Result<String> {
368 info!(
369 "Updating {} record: {} from {} to {} (TTL: {})",
370 record_type, name, old_value, new_value, ttl
371 );
372
373 // Defense-in-depth: reject control characters before assembling the
374 // newline-delimited nsupdate command script.
375 reject_injection_chars("zone", zone)?;
376 reject_injection_chars("name", name)?;
377 reject_injection_chars("old_value", old_value)?;
378 reject_injection_chars("new_value", new_value)?;
379
380 // Atomic update: delete old, add new in single transaction
381 let commands = format!(
382 "server {} {}\nzone {}\nupdate delete {} {} {}\nupdate add {} {} IN {} {}\nsend\n",
383 self.server,
384 self.port,
385 zone,
386 name,
387 record_type,
388 old_value,
389 name,
390 ttl,
391 record_type,
392 new_value
393 );
394
395 self.execute(&commands).await
396 }
397}
398
399/// Environment variables propagated to the spawned `nsupdate` child.
400///
401/// Deliberately minimal: only `PATH`, so the `nsupdate` binary can still be
402/// resolved. Everything else (notably `NSUPDATE_SECRET` / `RNDC_SECRET`, which
403/// live in the API process environment) is withheld so the child's
404/// `/proc/<pid>/environ` never exposes a secret (A-5).
405const CHILD_ENV_ALLOWLIST: &[&str] = &["PATH"];
406
407/// Build the allowlisted environment for the `nsupdate` child process.
408///
409/// Reads only the variables named in [`CHILD_ENV_ALLOWLIST`] from the parent
410/// environment; any that are unset are simply omitted. Used together with
411/// `Command::env_clear()` so the child starts from an empty environment plus
412/// this allowlist.
413pub(crate) fn minimal_child_env() -> Vec<(OsString, OsString)> {
414 CHILD_ENV_ALLOWLIST
415 .iter()
416 .filter_map(|name| std::env::var_os(name).map(|value| (OsString::from(name), value)))
417 .collect()
418}
419
420/// Build the nsupdate argument vector.
421///
422/// The TSIG key is referenced by file path (`-k <keyfile>`), never embedded in
423/// the arguments — argv is world-readable via `/proc/<pid>/cmdline` (B-7).
424pub(crate) fn build_nsupdate_args(use_tcp: bool, keyfile: Option<&Path>) -> Vec<OsString> {
425 let mut args: Vec<OsString> = Vec::new();
426 if use_tcp {
427 args.push("-v".into());
428 }
429 if let Some(path) = keyfile {
430 args.push("-k".into());
431 args.push(path.as_os_str().to_owned());
432 }
433 args
434}
435
436/// Render a BIND TSIG key file for `nsupdate -k`.
437///
438/// Produces:
439/// ```text
440/// key "<name>" {
441/// algorithm <hmac-...>;
442/// secret "<base64>";
443/// };
444/// ```
445///
446/// All three fields are validated before interpolation since they land in a
447/// quoted BIND configuration literal:
448/// - `key_name` — safe identifier set (`[A-Za-z0-9._-]`, max 253 chars)
449/// - `algorithm`: normalized to lowercase, `hmac-` prefix added if missing,
450/// then checked against the SHA-2-only allowlist (`hmac-md5` and
451/// `hmac-sha1` are rejected)
452/// - `secret` — base64 character set only
453///
454/// # Errors
455/// Returns an error when any field is empty or fails validation.
456pub(crate) fn build_tsig_key_file_content(
457 key_name: &str,
458 algorithm: &str,
459 secret: &str,
460) -> Result<String> {
461 if key_name.is_empty() || key_name.len() > MAX_TSIG_KEY_NAME_LEN {
462 return Err(anyhow::anyhow!(
463 "TSIG key name must be 1-{} characters",
464 MAX_TSIG_KEY_NAME_LEN
465 ));
466 }
467 if let Some(bad) = key_name
468 .chars()
469 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
470 {
471 return Err(anyhow::anyhow!(
472 "TSIG key name contains invalid character: {:?}",
473 bad
474 ));
475 }
476
477 // Accept "SHA256", "hmac-sha256", "HMAC-SHA256", etc.
478 let mut normalized_algorithm = algorithm.to_ascii_lowercase();
479 if !normalized_algorithm.starts_with("hmac-") {
480 normalized_algorithm = format!("hmac-{}", normalized_algorithm);
481 }
482 if !ALLOWED_TSIG_ALGORITHMS.contains(&normalized_algorithm.as_str()) {
483 return Err(anyhow::anyhow!(
484 "Unsupported TSIG algorithm: {} (allowed: {})",
485 algorithm,
486 ALLOWED_TSIG_ALGORITHMS.join(", ")
487 ));
488 }
489
490 if secret.is_empty() {
491 return Err(anyhow::anyhow!("TSIG secret cannot be empty"));
492 }
493 if let Some(bad) = secret
494 .chars()
495 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=')))
496 {
497 return Err(anyhow::anyhow!(
498 "TSIG secret contains non-base64 character: {:?}",
499 bad
500 ));
501 }
502
503 Ok(format!(
504 "key \"{key_name}\" {{\n algorithm {normalized_algorithm};\n secret \"{secret}\";\n}};\n"
505 ))
506}
507
508/// Reject a DNS update field containing control characters.
509///
510/// nsupdate reads its command script as newline-delimited lines from stdin, so a
511/// `\n`, `\r`, or NUL embedded in a zone, name, or value would be interpreted as a
512/// command separator and allow injection of arbitrary update commands (B-2). This
513/// is a defense-in-depth check at the sink; the HTTP handlers in `records.rs` also
514/// reject these characters and return HTTP 400.
515///
516/// # Errors
517/// Returns an error if `value` contains any control character.
518pub(crate) fn reject_injection_chars(field: &str, value: &str) -> Result<()> {
519 if let Some(bad) = value.chars().find(|c| c.is_control()) {
520 return Err(anyhow::anyhow!(
521 "{} contains illegal control character {:?}",
522 field,
523 bad
524 ));
525 }
526
527 Ok(())
528}
529
530/// Parse nsupdate error messages into human-readable format
531///
532/// Maps common nsupdate error codes to helpful messages
533fn parse_nsupdate_error(stderr: &str) -> String {
534 if stderr.contains("REFUSED") {
535 "Zone refused the update (check allow-update configuration)".to_string()
536 } else if stderr.contains("NOTAUTH") {
537 "Not authorized (check TSIG key configuration)".to_string()
538 } else if stderr.contains("SERVFAIL") {
539 "Server failure (check BIND9 logs)".to_string()
540 } else if stderr.contains("NOTZONE") {
541 "Zone not found on server".to_string()
542 } else if stderr.contains("FORMERR") {
543 "Format error (check record syntax)".to_string()
544 } else if stderr.contains("NXDOMAIN") {
545 "Domain name does not exist".to_string()
546 } else {
547 // Return raw stderr if no specific error matched
548 stderr.trim().to_string()
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_parse_nsupdate_error_refused() {
558 let stderr = "update failed: REFUSED\n";
559 assert_eq!(
560 parse_nsupdate_error(stderr),
561 "Zone refused the update (check allow-update configuration)"
562 );
563 }
564
565 #[test]
566 fn test_parse_nsupdate_error_notauth() {
567 let stderr = "update failed: NOTAUTH\n";
568 assert_eq!(
569 parse_nsupdate_error(stderr),
570 "Not authorized (check TSIG key configuration)"
571 );
572 }
573
574 #[test]
575 fn test_parse_nsupdate_error_servfail() {
576 let stderr = "update failed: SERVFAIL\n";
577 assert_eq!(
578 parse_nsupdate_error(stderr),
579 "Server failure (check BIND9 logs)"
580 );
581 }
582
583 #[test]
584 fn test_parse_nsupdate_error_notzone() {
585 let stderr = "update failed: NOTZONE\n";
586 assert_eq!(parse_nsupdate_error(stderr), "Zone not found on server");
587 }
588
589 #[test]
590 fn test_parse_nsupdate_error_unknown() {
591 let stderr = "some other error\n";
592 assert_eq!(parse_nsupdate_error(stderr), "some other error");
593 }
594
595 #[test]
596 fn test_new_executor_with_tsig() {
597 let executor = NsupdateExecutor::new(
598 "127.0.0.1".to_string(),
599 53,
600 Some("test-key".to_string()),
601 Some("HMAC-SHA256".to_string()),
602 Some("dGVzdA==".to_string()),
603 );
604 assert!(executor.is_ok());
605 }
606
607 #[test]
608 fn test_new_executor_without_tsig() {
609 let executor = NsupdateExecutor::new("127.0.0.1".to_string(), 53, None, None, None);
610 assert!(executor.is_ok());
611 }
612}