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}