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