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::process::Stdio;
18use std::time::Instant;
19use tokio::io::AsyncWriteExt;
20use tracing::{debug, error, info};
21
22use crate::metrics;
23
24/// nsupdate command executor
25///
26/// Manages dynamic DNS updates via the nsupdate command-line tool.
27/// Supports TSIG authentication for secure updates.
28#[derive(Clone)]
29pub struct NsupdateExecutor {
30    /// Optional TSIG key name for authentication
31    tsig_key_name: Option<String>,
32    /// Optional TSIG algorithm (e.g., "HMAC-SHA256")
33    tsig_algorithm: Option<String>,
34    /// Optional TSIG secret (base64-encoded)
35    tsig_secret: Option<String>,
36    /// DNS server address
37    server: String,
38    /// DNS server port
39    port: u16,
40}
41
42impl NsupdateExecutor {
43    /// Create a new nsupdate executor
44    ///
45    /// # Arguments
46    ///
47    /// * `server` - DNS server address (e.g., "127.0.0.1")
48    /// * `port` - DNS server port (typically 53)
49    /// * `tsig_key_name` - Optional TSIG key name
50    /// * `tsig_algorithm` - Optional TSIG algorithm
51    /// * `tsig_secret` - Optional TSIG secret (base64-encoded)
52    ///
53    /// # Example
54    ///
55    /// ```ignore
56    /// use bindcar::nsupdate::NsupdateExecutor;
57    ///
58    /// let executor = NsupdateExecutor::new(
59    ///     "127.0.0.1".to_string(),
60    ///     53,
61    ///     Some("update-key".to_string()),
62    ///     Some("HMAC-SHA256".to_string()),
63    ///     Some("dGVzdC1zZWNyZXQ=".to_string()),
64    /// )?;
65    /// ```
66    pub fn new(
67        server: String,
68        port: u16,
69        tsig_key_name: Option<String>,
70        tsig_algorithm: Option<String>,
71        tsig_secret: Option<String>,
72    ) -> Result<Self> {
73        info!(
74            "Creating nsupdate executor for {}:{} with TSIG: {}",
75            server,
76            port,
77            tsig_key_name.is_some()
78        );
79
80        Ok(Self {
81            tsig_key_name,
82            tsig_algorithm,
83            tsig_secret,
84            server,
85            port,
86        })
87    }
88
89    /// Execute nsupdate commands
90    ///
91    /// # Arguments
92    ///
93    /// * `commands` - nsupdate commands as a string (one command per line)
94    ///
95    /// # Returns
96    ///
97    /// Success or error message from nsupdate
98    async fn execute(&self, commands: &str) -> Result<String> {
99        let start = Instant::now();
100
101        debug!("Executing nsupdate commands:\n{}", commands);
102
103        let mut cmd = tokio::process::Command::new("nsupdate");
104
105        // Add TSIG authentication if configured
106        if let (Some(ref key_name), Some(ref algorithm), Some(ref secret)) =
107            (&self.tsig_key_name, &self.tsig_algorithm, &self.tsig_secret)
108        {
109            // nsupdate -y format: algorithm:keyname:secret
110            let auth = format!("{}:{}:{}", algorithm, key_name, secret);
111            cmd.arg("-y").arg(&auth);
112            debug!("Using TSIG authentication with key: {}", key_name);
113        }
114
115        cmd.stdin(Stdio::piped())
116            .stdout(Stdio::piped())
117            .stderr(Stdio::piped());
118
119        let mut child = cmd.spawn().context("Failed to spawn nsupdate process")?;
120
121        // Write commands to stdin
122        if let Some(mut stdin) = child.stdin.take() {
123            stdin
124                .write_all(commands.as_bytes())
125                .await
126                .context("Failed to write to nsupdate stdin")?;
127            stdin.flush().await.context("Failed to flush stdin")?;
128        }
129
130        // Wait for completion and capture output
131        let output = child
132            .wait_with_output()
133            .await
134            .context("Failed to wait for nsupdate")?;
135
136        let duration = start.elapsed().as_secs_f64();
137
138        // Handle errors
139        if !output.status.success() {
140            let stderr = String::from_utf8_lossy(&output.stderr);
141            let error_msg = parse_nsupdate_error(&stderr);
142            error!("nsupdate failed: {}", error_msg);
143            metrics::record_nsupdate_command("update", false, duration);
144            return Err(anyhow::anyhow!("nsupdate failed: {}", error_msg));
145        }
146
147        metrics::record_nsupdate_command("update", true, duration);
148
149        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
150        debug!("nsupdate completed successfully in {:.3}s", duration);
151
152        Ok(stdout)
153    }
154
155    /// Add a DNS record
156    ///
157    /// # Arguments
158    ///
159    /// * `zone` - Zone name (e.g., "example.com")
160    /// * `name` - Record name (FQDN, e.g., "www.example.com.")
161    /// * `ttl` - Time-to-live in seconds
162    /// * `record_type` - Record type (e.g., "A", "AAAA", "CNAME")
163    /// * `value` - Record value (e.g., "192.0.2.1")
164    ///
165    /// # Example
166    ///
167    /// ```ignore
168    /// executor.add_record(
169    ///     "example.com",
170    ///     "www.example.com.",
171    ///     3600,
172    ///     "A",
173    ///     "192.0.2.1"
174    /// ).await?;
175    /// ```
176    pub async fn add_record(
177        &self,
178        zone: &str,
179        name: &str,
180        ttl: u32,
181        record_type: &str,
182        value: &str,
183    ) -> Result<String> {
184        info!(
185            "Adding {} record: {} -> {} (TTL: {})",
186            record_type, name, value, ttl
187        );
188
189        let commands = format!(
190            "server {} {}\nzone {}\nupdate add {} {} IN {} {}\nsend\n",
191            self.server, self.port, zone, name, ttl, record_type, value
192        );
193
194        self.execute(&commands).await
195    }
196
197    /// Remove a DNS record
198    ///
199    /// # Arguments
200    ///
201    /// * `zone` - Zone name (e.g., "example.com")
202    /// * `name` - Record name (FQDN, e.g., "www.example.com.")
203    /// * `record_type` - Record type (e.g., "A")
204    /// * `value` - Record value to remove (e.g., "192.0.2.1"). If empty, removes all records of this type.
205    ///
206    /// # Example
207    ///
208    /// ```ignore
209    /// // Remove specific record
210    /// executor.remove_record(
211    ///     "example.com",
212    ///     "www.example.com.",
213    ///     "A",
214    ///     "192.0.2.1"
215    /// ).await?;
216    ///
217    /// // Remove all A records for www
218    /// executor.remove_record(
219    ///     "example.com",
220    ///     "www.example.com.",
221    ///     "A",
222    ///     ""
223    /// ).await?;
224    /// ```
225    pub async fn remove_record(
226        &self,
227        zone: &str,
228        name: &str,
229        record_type: &str,
230        value: &str,
231    ) -> Result<String> {
232        info!(
233            "Removing {} record: {} {} {}",
234            record_type,
235            name,
236            if value.is_empty() { "(all)" } else { value },
237            ""
238        );
239
240        // Build delete command - with or without value
241        let delete_cmd = if value.is_empty() {
242            // Remove all records of this type
243            format!("update delete {} {}", name, record_type)
244        } else {
245            // Remove specific record
246            format!("update delete {} {} {}", name, record_type, value)
247        };
248
249        let commands = format!(
250            "server {} {}\nzone {}\n{}\nsend\n",
251            self.server, self.port, zone, delete_cmd
252        );
253
254        self.execute(&commands).await
255    }
256
257    /// Update a DNS record (atomic delete + add)
258    ///
259    /// # Arguments
260    ///
261    /// * `zone` - Zone name (e.g., "example.com")
262    /// * `name` - Record name (FQDN, e.g., "www.example.com.")
263    /// * `ttl` - New time-to-live in seconds
264    /// * `record_type` - Record type (e.g., "A")
265    /// * `old_value` - Current record value (e.g., "192.0.2.1")
266    /// * `new_value` - New record value (e.g., "192.0.2.2")
267    ///
268    /// # Example
269    ///
270    /// ```ignore
271    /// executor.update_record(
272    ///     "example.com",
273    ///     "www.example.com.",
274    ///     3600,
275    ///     "A",
276    ///     "192.0.2.1",
277    ///     "192.0.2.2"
278    /// ).await?;
279    /// ```
280    pub async fn update_record(
281        &self,
282        zone: &str,
283        name: &str,
284        ttl: u32,
285        record_type: &str,
286        old_value: &str,
287        new_value: &str,
288    ) -> Result<String> {
289        info!(
290            "Updating {} record: {} from {} to {} (TTL: {})",
291            record_type, name, old_value, new_value, ttl
292        );
293
294        // Atomic update: delete old, add new in single transaction
295        let commands = format!(
296            "server {} {}\nzone {}\nupdate delete {} {} {}\nupdate add {} {} IN {} {}\nsend\n",
297            self.server,
298            self.port,
299            zone,
300            name,
301            record_type,
302            old_value,
303            name,
304            ttl,
305            record_type,
306            new_value
307        );
308
309        self.execute(&commands).await
310    }
311}
312
313/// Parse nsupdate error messages into human-readable format
314///
315/// Maps common nsupdate error codes to helpful messages
316fn parse_nsupdate_error(stderr: &str) -> String {
317    if stderr.contains("REFUSED") {
318        "Zone refused the update (check allow-update configuration)".to_string()
319    } else if stderr.contains("NOTAUTH") {
320        "Not authorized (check TSIG key configuration)".to_string()
321    } else if stderr.contains("SERVFAIL") {
322        "Server failure (check BIND9 logs)".to_string()
323    } else if stderr.contains("NOTZONE") {
324        "Zone not found on server".to_string()
325    } else if stderr.contains("FORMERR") {
326        "Format error (check record syntax)".to_string()
327    } else if stderr.contains("NXDOMAIN") {
328        "Domain name does not exist".to_string()
329    } else {
330        // Return raw stderr if no specific error matched
331        stderr.trim().to_string()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_parse_nsupdate_error_refused() {
341        let stderr = "update failed: REFUSED\n";
342        assert_eq!(
343            parse_nsupdate_error(stderr),
344            "Zone refused the update (check allow-update configuration)"
345        );
346    }
347
348    #[test]
349    fn test_parse_nsupdate_error_notauth() {
350        let stderr = "update failed: NOTAUTH\n";
351        assert_eq!(
352            parse_nsupdate_error(stderr),
353            "Not authorized (check TSIG key configuration)"
354        );
355    }
356
357    #[test]
358    fn test_parse_nsupdate_error_servfail() {
359        let stderr = "update failed: SERVFAIL\n";
360        assert_eq!(
361            parse_nsupdate_error(stderr),
362            "Server failure (check BIND9 logs)"
363        );
364    }
365
366    #[test]
367    fn test_parse_nsupdate_error_notzone() {
368        let stderr = "update failed: NOTZONE\n";
369        assert_eq!(parse_nsupdate_error(stderr), "Zone not found on server");
370    }
371
372    #[test]
373    fn test_parse_nsupdate_error_unknown() {
374        let stderr = "some other error\n";
375        assert_eq!(parse_nsupdate_error(stderr), "some other error");
376    }
377
378    #[test]
379    fn test_new_executor_with_tsig() {
380        let executor = NsupdateExecutor::new(
381            "127.0.0.1".to_string(),
382            53,
383            Some("test-key".to_string()),
384            Some("HMAC-SHA256".to_string()),
385            Some("dGVzdA==".to_string()),
386        );
387        assert!(executor.is_ok());
388    }
389
390    #[test]
391    fn test_new_executor_without_tsig() {
392        let executor = NsupdateExecutor::new("127.0.0.1".to_string(), 53, None, None, None);
393        assert!(executor.is_ok());
394    }
395}