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}