1use 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#[derive(Clone)]
29pub struct NsupdateExecutor {
30 tsig_key_name: Option<String>,
32 tsig_algorithm: Option<String>,
34 tsig_secret: Option<String>,
36 server: String,
38 port: u16,
40 use_tcp: bool,
43}
44
45impl NsupdateExecutor {
46 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 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 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 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 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 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 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 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 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 let delete_cmd = if value.is_empty() {
254 format!("update delete {} {}", name, record_type)
256 } else {
257 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 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 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
325fn 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 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}