1use std::collections::HashSet;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use tokio::sync::watch;
8use tracing::{debug, instrument};
9
10use super::records::{DnsRecord, RecordType};
11use super::resolver::DnsResolver;
12use crate::error::{Result, SeerError};
13
14const MAX_FOLLOW_ITERATIONS: usize = 10_000;
18
19#[derive(Debug, Clone)]
21pub struct FollowConfig {
22 pub iterations: usize,
24 pub interval_secs: u64,
26 pub changes_only: bool,
28}
29
30impl Default for FollowConfig {
31 fn default() -> Self {
32 Self {
33 iterations: 10,
34 interval_secs: 60,
35 changes_only: false,
36 }
37 }
38}
39
40impl FollowConfig {
41 pub fn new(iterations: usize, interval_minutes: f64) -> Result<Self> {
49 if iterations == 0 {
50 return Err(SeerError::InvalidInput(
51 "iterations must be at least 1".into(),
52 ));
53 }
54 if iterations > MAX_FOLLOW_ITERATIONS {
55 return Err(SeerError::InvalidInput(format!(
56 "iterations must be at most {MAX_FOLLOW_ITERATIONS}"
57 )));
58 }
59 if !interval_minutes.is_finite() {
60 return Err(SeerError::InvalidInput(
61 "interval_minutes must be a finite number".into(),
62 ));
63 }
64 if interval_minutes < 0.0 {
65 return Err(SeerError::InvalidInput(
66 "interval_minutes must be non-negative".into(),
67 ));
68 }
69 if interval_minutes > 60.0 {
70 return Err(SeerError::InvalidInput(
71 "interval_minutes must be at most 60".into(),
72 ));
73 }
74 let mut interval_secs = (interval_minutes * 60.0) as u64;
80 if iterations > 1 {
81 interval_secs = interval_secs.max(1);
82 }
83 Ok(Self {
84 iterations,
85 interval_secs,
86 changes_only: false,
87 })
88 }
89
90 pub fn with_changes_only(mut self, changes_only: bool) -> Self {
91 self.changes_only = changes_only;
92 self
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct FollowIteration {
99 pub iteration: usize,
101 pub total_iterations: usize,
103 pub timestamp: DateTime<Utc>,
105 pub records: Vec<DnsRecord>,
107 pub changed: bool,
109 pub added: Vec<String>,
111 pub removed: Vec<String>,
113 pub error: Option<String>,
115}
116
117impl FollowIteration {
118 pub fn success(&self) -> bool {
119 self.error.is_none()
120 }
121
122 pub fn record_count(&self) -> usize {
123 self.records.len()
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct FollowResult {
130 pub domain: String,
132 pub record_type: RecordType,
134 pub nameserver: Option<String>,
136 pub iterations_requested: usize,
138 pub interval_secs: u64,
139 pub iterations: Vec<FollowIteration>,
141 pub interrupted: bool,
143 pub total_changes: usize,
145 pub started_at: DateTime<Utc>,
147 pub ended_at: DateTime<Utc>,
149}
150
151impl FollowResult {
152 pub fn completed_iterations(&self) -> usize {
153 self.iterations.len()
154 }
155
156 pub fn successful_iterations(&self) -> usize {
157 self.iterations.iter().filter(|i| i.success()).count()
158 }
159
160 pub fn failed_iterations(&self) -> usize {
161 self.iterations.iter().filter(|i| !i.success()).count()
162 }
163}
164
165pub type FollowProgressCallback = Arc<dyn Fn(&FollowIteration) + Send + Sync>;
167
168#[derive(Clone)]
170pub struct DnsFollower {
171 resolver: DnsResolver,
172}
173
174impl Default for DnsFollower {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl DnsFollower {
181 pub fn new() -> Self {
182 Self {
183 resolver: DnsResolver::new(),
184 }
185 }
186
187 pub fn with_resolver(resolver: DnsResolver) -> Self {
188 Self { resolver }
189 }
190
191 #[instrument(skip(self, config, callback, cancel_rx))]
193 pub async fn follow(
194 &self,
195 domain: &str,
196 record_type: RecordType,
197 nameserver: Option<&str>,
198 config: FollowConfig,
199 callback: Option<FollowProgressCallback>,
200 cancel_rx: Option<watch::Receiver<bool>>,
201 ) -> Result<FollowResult> {
202 let domain = crate::validation::normalize_domain(domain)?;
203 let started_at = Utc::now();
204 let mut iterations: Vec<FollowIteration> = Vec::with_capacity(config.iterations);
205 let mut previous_values: HashSet<String> = HashSet::new();
206 let mut total_changes = 0;
207 let mut interrupted = false;
208
209 debug!(
210 domain = %domain,
211 record_type = %record_type,
212 iterations = config.iterations,
213 interval_secs = config.interval_secs,
214 "Starting DNS follow"
215 );
216
217 for i in 0..config.iterations {
218 if let Some(ref rx) = cancel_rx {
220 if *rx.borrow() {
221 debug!("Follow operation cancelled");
222 interrupted = true;
223 break;
224 }
225 }
226
227 let timestamp = Utc::now();
228 let iteration_num = i + 1;
229
230 let (records, error) = match self
232 .resolver
233 .resolve(&domain, record_type, nameserver)
234 .await
235 {
236 Ok(records) => (records, None),
237 Err(e) => {
238 debug!(domain = %domain, error = %e, "DNS follow query failed");
239 (Vec::new(), Some(e.sanitized_message()))
241 }
242 };
243
244 let current_values: Vec<String> = records.iter().map(|r| r.data.to_string()).collect();
246
247 let (changed, added, removed) = if i == 0 {
252 (false, Vec::new(), Vec::new())
254 } else {
255 diff_record_values(¤t_values, &previous_values)
256 };
257
258 if changed {
259 total_changes += 1;
260 }
261
262 let iteration = FollowIteration {
263 iteration: iteration_num,
264 total_iterations: config.iterations,
265 timestamp,
266 records,
267 changed,
268 added,
269 removed,
270 error,
271 };
272
273 if let Some(ref cb) = callback {
275 if !config.changes_only || iteration_num == 1 || changed {
277 cb(&iteration);
278 }
279 }
280
281 iterations.push(iteration);
282 previous_values = current_values
284 .iter()
285 .map(|v| v.to_ascii_lowercase())
286 .collect();
287
288 if i < config.iterations - 1 {
290 let sleep_duration = Duration::from_secs(config.interval_secs);
291
292 if let Some(ref rx) = cancel_rx {
294 let mut rx_clone = rx.clone();
295 tokio::select! {
296 _ = tokio::time::sleep(sleep_duration) => {}
297 _ = rx_clone.changed() => {
298 if *rx_clone.borrow() {
299 debug!("Follow operation cancelled during sleep");
300 interrupted = true;
301 break;
302 }
303 }
304 }
305 } else {
306 tokio::time::sleep(sleep_duration).await;
307 }
308 }
309 }
310
311 let ended_at = Utc::now();
312
313 Ok(FollowResult {
314 domain: domain.to_string(),
315 record_type,
316 nameserver: nameserver.map(|s| s.to_string()),
317 iterations_requested: config.iterations,
318 interval_secs: config.interval_secs,
319 iterations,
320 interrupted,
321 total_changes,
322 started_at,
323 ended_at,
324 })
325 }
326
327 #[instrument(skip(self, config), fields(domain = %domain, record_type = ?record_type))]
329 pub async fn follow_simple(
330 &self,
331 domain: &str,
332 record_type: RecordType,
333 nameserver: Option<&str>,
334 config: FollowConfig,
335 ) -> Result<FollowResult> {
336 self.follow(domain, record_type, nameserver, config, None, None)
337 .await
338 }
339}
340
341fn diff_record_values(
347 current_values: &[String],
348 previous_keys: &HashSet<String>,
349) -> (bool, Vec<String>, Vec<String>) {
350 let current_keys: HashSet<String> = current_values
351 .iter()
352 .map(|v| v.to_ascii_lowercase())
353 .collect();
354
355 let mut added: Vec<String> = current_values
357 .iter()
358 .filter(|v| !previous_keys.contains(&v.to_ascii_lowercase()))
359 .cloned()
360 .collect();
361 let mut removed: Vec<String> = previous_keys
364 .iter()
365 .filter(|k| !current_keys.contains(*k))
366 .cloned()
367 .collect();
368 added.sort();
369 added.dedup();
370 removed.sort();
371 removed.dedup();
372
373 let changed = !added.is_empty() || !removed.is_empty();
374 (changed, added, removed)
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn lowercased_set<const N: usize>(values: [&str; N]) -> HashSet<String> {
384 values.iter().map(|v| v.to_ascii_lowercase()).collect()
385 }
386
387 #[tokio::test]
388 async fn test_follow_config_default() {
389 let config = FollowConfig::default();
390 assert_eq!(config.iterations, 10);
391 assert_eq!(config.interval_secs, 60);
392 assert!(!config.changes_only);
393 }
394
395 #[test]
399 fn diff_values_ignores_case_only_differences() {
400 let previous = lowercased_set(["NS1.EXAMPLE.COM.", "ns2.example.com."]);
401 let current = vec![
402 "ns1.example.com.".to_string(),
403 "NS2.EXAMPLE.COM.".to_string(),
404 ];
405 let (changed, added, removed) = diff_record_values(¤t, &previous);
406 assert!(!changed, "case-only differences must not count as a change");
407 assert!(added.is_empty(), "no added values: {added:?}");
408 assert!(removed.is_empty(), "no removed values: {removed:?}");
409 }
410
411 #[test]
414 fn diff_values_detects_real_change_preserving_case() {
415 let previous = lowercased_set(["ns1.example.com."]);
416 let current = vec!["NS2.Example.Com.".to_string()];
417 let (changed, added, removed) = diff_record_values(¤t, &previous);
418 assert!(changed, "a different nameserver is a real change");
419 assert_eq!(added, vec!["NS2.Example.Com.".to_string()]);
420 assert_eq!(removed, vec!["ns1.example.com.".to_string()]);
421 }
422
423 #[test]
424 fn follow_config_rejects_unbounded_iterations() {
425 assert!(FollowConfig::new(MAX_FOLLOW_ITERATIONS, 1.0).is_ok());
426 let err = FollowConfig::new(MAX_FOLLOW_ITERATIONS + 1, 1.0).unwrap_err();
427 assert!(matches!(err, SeerError::InvalidInput(_)));
428 assert!(FollowConfig::new(usize::MAX, 1.0).is_err());
429 }
430
431 #[tokio::test]
432 async fn test_follow_config_new() {
433 let config = FollowConfig::new(5, 0.5).unwrap();
434 assert_eq!(config.iterations, 5);
435 assert_eq!(config.interval_secs, 30);
436 }
437
438 #[tokio::test]
439 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
440 async fn test_follow_single_iteration() {
441 let follower = DnsFollower::new();
442 let config = FollowConfig::new(1, 0.0).unwrap();
443
444 let result = follower
445 .follow_simple("example.com", RecordType::A, None, config)
446 .await;
447
448 assert!(result.is_ok());
449 let result = result.unwrap();
450 assert_eq!(result.completed_iterations(), 1);
451 assert!(!result.interrupted);
452 }
453
454 #[test]
455 fn follow_config_rejects_zero_iterations() {
456 assert!(FollowConfig::new(0, 1.0).is_err());
457 }
458
459 #[test]
460 fn follow_config_rejects_infinite_interval() {
461 assert!(FollowConfig::new(10, f64::INFINITY).is_err());
462 assert!(FollowConfig::new(10, f64::NEG_INFINITY).is_err());
463 }
464
465 #[test]
466 fn follow_config_rejects_nan_interval() {
467 assert!(FollowConfig::new(10, f64::NAN).is_err());
468 }
469
470 #[test]
471 fn follow_config_rejects_negative_interval() {
472 assert!(FollowConfig::new(10, -1.0).is_err());
473 }
474
475 #[test]
476 fn follow_config_rejects_interval_above_cap() {
477 assert!(FollowConfig::new(10, 60.1).is_err());
478 }
479
480 #[test]
481 fn follow_config_accepts_valid() {
482 assert!(FollowConfig::new(10, 1.5).is_ok());
483 assert!(FollowConfig::new(1, 0.0).is_ok());
484 assert!(FollowConfig::new(1, 60.0).is_ok());
485 }
486
487 #[test]
488 fn follow_config_floors_subsecond_interval_for_multi_iteration() {
489 let config = FollowConfig::new(10_000, 0.001).unwrap();
493 assert!(
494 config.interval_secs >= 1,
495 "multi-iteration interval must be floored to >= 1s, got {}",
496 config.interval_secs
497 );
498 }
499
500 #[test]
501 fn follow_config_allows_zero_interval_for_single_iteration() {
502 let config = FollowConfig::new(1, 0.0).unwrap();
505 assert_eq!(config.interval_secs, 0);
506 }
507
508 #[tokio::test]
509 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
510 async fn follow_honors_cancel() {
511 use tokio::sync::watch;
512
513 let (tx, rx) = watch::channel(false);
514 let config = FollowConfig::new(100, 0.5).unwrap();
516 let follower = DnsFollower::new();
517
518 let handle = tokio::spawn(async move {
519 follower
520 .follow("example.com", RecordType::A, None, config, None, Some(rx))
521 .await
522 });
523
524 tokio::time::sleep(Duration::from_millis(200)).await;
526 tx.send(true).unwrap();
527
528 let joined = tokio::time::timeout(Duration::from_secs(10), handle)
529 .await
530 .expect("follow should return promptly after cancel");
531 let result = joined.expect("join").expect("follow result");
532 assert!(result.interrupted, "follow should be interrupted");
533 assert!(
534 result.completed_iterations() < 100,
535 "should not complete all iterations"
536 );
537 }
538}