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