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
14pub const MAX_FOLLOW_ITERATIONS: usize = 10_000;
18
19pub const MAX_FOLLOW_INTERVAL_SECS: u64 = 3600;
24
25#[derive(Debug, Clone)]
27pub struct FollowConfig {
28 pub iterations: usize,
30 pub interval_secs: u64,
32 pub changes_only: bool,
34}
35
36impl Default for FollowConfig {
37 fn default() -> Self {
38 Self {
39 iterations: 10,
40 interval_secs: 60,
41 changes_only: false,
42 }
43 }
44}
45
46impl FollowConfig {
47 pub fn new(iterations: usize, interval_minutes: f64) -> Result<Self> {
55 if iterations == 0 {
56 return Err(SeerError::InvalidInput(
57 "iterations must be at least 1".into(),
58 ));
59 }
60 if iterations > MAX_FOLLOW_ITERATIONS {
61 return Err(SeerError::InvalidInput(format!(
62 "iterations must be at most {MAX_FOLLOW_ITERATIONS}"
63 )));
64 }
65 if !interval_minutes.is_finite() {
66 return Err(SeerError::InvalidInput(
67 "interval_minutes must be a finite number".into(),
68 ));
69 }
70 if interval_minutes < 0.0 {
71 return Err(SeerError::InvalidInput(
72 "interval_minutes must be non-negative".into(),
73 ));
74 }
75 if interval_minutes > MAX_FOLLOW_INTERVAL_SECS as f64 / 60.0 {
76 return Err(SeerError::InvalidInput(
77 "interval_minutes must be at most 60".into(),
78 ));
79 }
80 let mut interval_secs = (interval_minutes * 60.0) as u64;
86 if iterations > 1 {
87 interval_secs = interval_secs.max(1);
88 }
89 Ok(Self {
90 iterations,
91 interval_secs,
92 changes_only: false,
93 })
94 }
95
96 pub fn with_changes_only(mut self, changes_only: bool) -> Self {
97 self.changes_only = changes_only;
98 self
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FollowIteration {
105 pub iteration: usize,
107 pub total_iterations: usize,
109 pub timestamp: DateTime<Utc>,
111 pub records: Vec<DnsRecord>,
113 pub changed: bool,
115 pub added: Vec<String>,
117 pub removed: Vec<String>,
119 pub error: Option<String>,
121}
122
123impl FollowIteration {
124 pub fn success(&self) -> bool {
125 self.error.is_none()
126 }
127
128 pub fn record_count(&self) -> usize {
129 self.records.len()
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct FollowResult {
136 pub domain: String,
138 pub record_type: RecordType,
140 pub nameserver: Option<String>,
142 pub iterations_requested: usize,
144 pub interval_secs: u64,
145 pub iterations: Vec<FollowIteration>,
147 pub interrupted: bool,
149 pub total_changes: usize,
151 pub started_at: DateTime<Utc>,
153 pub ended_at: DateTime<Utc>,
155}
156
157impl FollowResult {
158 pub fn completed_iterations(&self) -> usize {
159 self.iterations.len()
160 }
161
162 pub fn successful_iterations(&self) -> usize {
163 self.iterations.iter().filter(|i| i.success()).count()
164 }
165
166 pub fn failed_iterations(&self) -> usize {
167 self.iterations.iter().filter(|i| !i.success()).count()
168 }
169}
170
171pub type FollowProgressCallback = Arc<dyn Fn(&FollowIteration) + Send + Sync>;
173
174#[derive(Clone)]
176pub struct DnsFollower {
177 resolver: DnsResolver,
178}
179
180impl Default for DnsFollower {
181 fn default() -> Self {
182 Self::new()
183 }
184}
185
186impl DnsFollower {
187 pub fn new() -> Self {
188 Self {
189 resolver: DnsResolver::new(),
190 }
191 }
192
193 pub fn with_resolver(resolver: DnsResolver) -> Self {
194 Self { resolver }
195 }
196
197 #[instrument(skip(self, config, callback, cancel_rx))]
199 pub async fn follow(
200 &self,
201 domain: &str,
202 record_type: RecordType,
203 nameserver: Option<&str>,
204 config: FollowConfig,
205 callback: Option<FollowProgressCallback>,
206 cancel_rx: Option<watch::Receiver<bool>>,
207 ) -> Result<FollowResult> {
208 let domain = crate::validation::normalize_domain(domain)?;
209 let started_at = Utc::now();
210 let mut iterations: Vec<FollowIteration> = Vec::with_capacity(config.iterations);
211 let mut previous_values: HashSet<String> = HashSet::new();
212 let mut total_changes = 0;
213 let mut interrupted = false;
214
215 debug!(
216 domain = %domain,
217 record_type = %record_type,
218 iterations = config.iterations,
219 interval_secs = config.interval_secs,
220 "Starting DNS follow"
221 );
222
223 for i in 0..config.iterations {
224 if let Some(ref rx) = cancel_rx {
226 if *rx.borrow() {
227 debug!("Follow operation cancelled");
228 interrupted = true;
229 break;
230 }
231 }
232
233 let timestamp = Utc::now();
234 let iteration_num = i + 1;
235
236 let (records, error) = match self
238 .resolver
239 .resolve(&domain, record_type, nameserver)
240 .await
241 {
242 Ok(records) => (records, None),
243 Err(e) => {
244 debug!(domain = %domain, error = %e, "DNS follow query failed");
245 (Vec::new(), Some(e.sanitized_message()))
247 }
248 };
249
250 let current_values: Vec<String> = records.iter().map(|r| r.data.to_string()).collect();
252
253 let (changed, added, removed) = if i == 0 || error.is_some() {
258 (false, Vec::new(), Vec::new())
264 } else {
265 diff_record_values(¤t_values, &previous_values)
266 };
267
268 if changed {
269 total_changes += 1;
270 }
271
272 let error_is_none = error.is_none();
274
275 let iteration = FollowIteration {
276 iteration: iteration_num,
277 total_iterations: config.iterations,
278 timestamp,
279 records,
280 changed,
281 added,
282 removed,
283 error,
284 };
285
286 if let Some(ref cb) = callback {
288 if !config.changes_only || iteration_num == 1 || changed {
290 cb(&iteration);
291 }
292 }
293
294 iterations.push(iteration);
295 if error_is_none {
301 previous_values = current_values
302 .iter()
303 .map(|v| v.to_ascii_lowercase())
304 .collect();
305 }
306
307 if i < config.iterations - 1 {
309 let sleep_duration = Duration::from_secs(config.interval_secs);
310
311 if let Some(ref rx) = cancel_rx {
313 let mut rx_clone = rx.clone();
314 tokio::select! {
315 _ = tokio::time::sleep(sleep_duration) => {}
316 _ = rx_clone.changed() => {
317 if *rx_clone.borrow() {
318 debug!("Follow operation cancelled during sleep");
319 interrupted = true;
320 break;
321 }
322 }
323 }
324 } else {
325 tokio::time::sleep(sleep_duration).await;
326 }
327 }
328 }
329
330 let ended_at = Utc::now();
331
332 Ok(FollowResult {
333 domain: domain.to_string(),
334 record_type,
335 nameserver: nameserver.map(|s| s.to_string()),
336 iterations_requested: config.iterations,
337 interval_secs: config.interval_secs,
338 iterations,
339 interrupted,
340 total_changes,
341 started_at,
342 ended_at,
343 })
344 }
345
346 #[instrument(skip(self, config), fields(domain = %domain, record_type = ?record_type))]
348 pub async fn follow_simple(
349 &self,
350 domain: &str,
351 record_type: RecordType,
352 nameserver: Option<&str>,
353 config: FollowConfig,
354 ) -> Result<FollowResult> {
355 self.follow(domain, record_type, nameserver, config, None, None)
356 .await
357 }
358}
359
360fn diff_record_values(
366 current_values: &[String],
367 previous_keys: &HashSet<String>,
368) -> (bool, Vec<String>, Vec<String>) {
369 let current_keys: HashSet<String> = current_values
370 .iter()
371 .map(|v| v.to_ascii_lowercase())
372 .collect();
373
374 let mut added: Vec<String> = current_values
376 .iter()
377 .filter(|v| !previous_keys.contains(&v.to_ascii_lowercase()))
378 .cloned()
379 .collect();
380 let mut removed: Vec<String> = previous_keys
383 .iter()
384 .filter(|k| !current_keys.contains(*k))
385 .cloned()
386 .collect();
387 added.sort();
388 added.dedup();
389 removed.sort();
390 removed.dedup();
391
392 let changed = !added.is_empty() || !removed.is_empty();
393 (changed, added, removed)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn lowercased_set<const N: usize>(values: [&str; N]) -> HashSet<String> {
403 values.iter().map(|v| v.to_ascii_lowercase()).collect()
404 }
405
406 #[tokio::test]
407 async fn test_follow_config_default() {
408 let config = FollowConfig::default();
409 assert_eq!(config.iterations, 10);
410 assert_eq!(config.interval_secs, 60);
411 assert!(!config.changes_only);
412 }
413
414 #[test]
418 fn diff_values_ignores_case_only_differences() {
419 let previous = lowercased_set(["NS1.EXAMPLE.COM.", "ns2.example.com."]);
420 let current = vec![
421 "ns1.example.com.".to_string(),
422 "NS2.EXAMPLE.COM.".to_string(),
423 ];
424 let (changed, added, removed) = diff_record_values(¤t, &previous);
425 assert!(!changed, "case-only differences must not count as a change");
426 assert!(added.is_empty(), "no added values: {added:?}");
427 assert!(removed.is_empty(), "no removed values: {removed:?}");
428 }
429
430 #[test]
433 fn diff_values_detects_real_change_preserving_case() {
434 let previous = lowercased_set(["ns1.example.com."]);
435 let current = vec!["NS2.Example.Com.".to_string()];
436 let (changed, added, removed) = diff_record_values(¤t, &previous);
437 assert!(changed, "a different nameserver is a real change");
438 assert_eq!(added, vec!["NS2.Example.Com.".to_string()]);
439 assert_eq!(removed, vec!["ns1.example.com.".to_string()]);
440 }
441
442 #[test]
443 fn follow_config_rejects_unbounded_iterations() {
444 assert!(FollowConfig::new(MAX_FOLLOW_ITERATIONS, 1.0).is_ok());
445 let err = FollowConfig::new(MAX_FOLLOW_ITERATIONS + 1, 1.0).unwrap_err();
446 assert!(matches!(err, SeerError::InvalidInput(_)));
447 assert!(FollowConfig::new(usize::MAX, 1.0).is_err());
448 }
449
450 #[tokio::test]
451 async fn test_follow_config_new() {
452 let config = FollowConfig::new(5, 0.5).unwrap();
453 assert_eq!(config.iterations, 5);
454 assert_eq!(config.interval_secs, 30);
455 }
456
457 #[tokio::test]
463 async fn follow_loop_detects_change_against_mock_dns() {
464 use std::sync::Mutex;
465
466 use hickory_resolver::proto::rr::rdata as wire;
467 use hickory_resolver::proto::rr::RData as HickoryRData;
468
469 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns_sequence};
470
471 let a = |last_octet: u8| {
472 HickoryRData::A(wire::A(std::net::Ipv4Addr::new(192, 0, 2, last_octet)))
473 };
474 let port = spawn_mock_dns_sequence(vec![vec![a(1)], vec![a(2)], vec![a(2)]]).await;
477
478 let config = FollowConfig {
482 iterations: 3,
483 interval_secs: 0,
484 changes_only: false,
485 };
486
487 let seen: Arc<Mutex<Vec<(usize, bool)>>> = Arc::new(Mutex::new(Vec::new()));
488 let callback: FollowProgressCallback = {
489 let seen = Arc::clone(&seen);
490 Arc::new(move |it: &FollowIteration| {
491 seen.lock()
492 .expect("callback mutex")
493 .push((it.iteration, it.changed));
494 })
495 };
496
497 let follower = DnsFollower::with_resolver(mock_dns_resolver(port));
498 let result = follower
499 .follow(
500 "seer.test",
501 RecordType::A,
502 Some("127.0.0.1"),
503 config,
504 Some(callback),
505 None,
506 )
507 .await
508 .expect("follow against the mock fixture must succeed");
509
510 assert_eq!(result.completed_iterations(), 3);
512 assert_eq!(result.successful_iterations(), 3);
513 assert!(!result.interrupted);
514 assert_eq!(result.domain, "seer.test");
515 assert_eq!(result.record_type, RecordType::A);
516
517 let first = &result.iterations[0];
519 assert_eq!(first.record_count(), 1);
520 assert_eq!(first.records[0].data.to_string(), "192.0.2.1");
521 assert!(!first.changed, "first iteration is never a change");
522 assert!(first.added.is_empty() && first.removed.is_empty());
523
524 let second = &result.iterations[1];
526 assert!(second.changed, "record change must be detected");
527 assert_eq!(second.added, vec!["192.0.2.2".to_string()]);
528 assert_eq!(second.removed, vec!["192.0.2.1".to_string()]);
529
530 let third = &result.iterations[2];
532 assert!(!third.changed, "unchanged records must not be a change");
533 assert!(third.added.is_empty() && third.removed.is_empty());
534
535 assert_eq!(result.total_changes, 1);
536
537 let seen = seen.lock().expect("callback mutex");
539 assert_eq!(*seen, vec![(1, false), (2, true), (3, false)]);
540 }
541
542 #[tokio::test]
543 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
544 async fn test_follow_single_iteration() {
545 let follower = DnsFollower::new();
546 let config = FollowConfig::new(1, 0.0).unwrap();
547
548 let result = follower
549 .follow_simple("example.com", RecordType::A, None, config)
550 .await;
551
552 assert!(result.is_ok());
553 let result = result.unwrap();
554 assert_eq!(result.completed_iterations(), 1);
555 assert!(!result.interrupted);
556 }
557
558 #[test]
559 fn follow_config_rejects_zero_iterations() {
560 assert!(FollowConfig::new(0, 1.0).is_err());
561 }
562
563 #[test]
564 fn follow_config_rejects_infinite_interval() {
565 assert!(FollowConfig::new(10, f64::INFINITY).is_err());
566 assert!(FollowConfig::new(10, f64::NEG_INFINITY).is_err());
567 }
568
569 #[test]
570 fn follow_config_rejects_nan_interval() {
571 assert!(FollowConfig::new(10, f64::NAN).is_err());
572 }
573
574 #[test]
575 fn follow_config_rejects_negative_interval() {
576 assert!(FollowConfig::new(10, -1.0).is_err());
577 }
578
579 #[test]
580 fn follow_config_rejects_interval_above_cap() {
581 assert!(FollowConfig::new(10, 60.1).is_err());
582 }
583
584 #[test]
585 fn follow_config_accepts_valid() {
586 assert!(FollowConfig::new(10, 1.5).is_ok());
587 assert!(FollowConfig::new(1, 0.0).is_ok());
588 assert!(FollowConfig::new(1, 60.0).is_ok());
589 }
590
591 #[test]
592 fn follow_config_floors_subsecond_interval_for_multi_iteration() {
593 let config = FollowConfig::new(10_000, 0.001).unwrap();
597 assert!(
598 config.interval_secs >= 1,
599 "multi-iteration interval must be floored to >= 1s, got {}",
600 config.interval_secs
601 );
602 }
603
604 #[test]
605 fn follow_config_allows_zero_interval_for_single_iteration() {
606 let config = FollowConfig::new(1, 0.0).unwrap();
609 assert_eq!(config.interval_secs, 0);
610 }
611
612 #[tokio::test]
613 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
614 async fn follow_honors_cancel() {
615 use tokio::sync::watch;
616
617 let (tx, rx) = watch::channel(false);
618 let config = FollowConfig::new(100, 0.5).unwrap();
620 let follower = DnsFollower::new();
621
622 let handle = tokio::spawn(async move {
623 follower
624 .follow("example.com", RecordType::A, None, config, None, Some(rx))
625 .await
626 });
627
628 tokio::time::sleep(Duration::from_millis(200)).await;
630 tx.send(true).unwrap();
631
632 let joined = tokio::time::timeout(Duration::from_secs(10), handle)
633 .await
634 .expect("follow should return promptly after cancel");
635 let result = joined.expect("join").expect("follow result");
636 assert!(result.interrupted, "follow should be interrupted");
637 assert!(
638 result.completed_iterations() < 100,
639 "should not complete all iterations"
640 );
641 }
642}