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}
41
42impl NsupdateExecutor {
43 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 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 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 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 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 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 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 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 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 let delete_cmd = if value.is_empty() {
242 format!("update delete {} {}", name, record_type)
244 } else {
245 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 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 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
313fn 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 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}