Skip to main content

seer_core/dns/
follow.rs

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
14/// Upper bound on follow iterations. A non-interactive caller (API / Python
15/// bindings) passing a huge count would otherwise schedule an effectively
16/// unbounded long-running loop; the interval is already capped at 60 minutes.
17pub const MAX_FOLLOW_ITERATIONS: usize = 10_000;
18
19/// Upper bound on the follow interval, in seconds (60 minutes). Exposed so
20/// front-ends (e.g. the TUI) can clamp user input to the same range
21/// `FollowConfig::new` enforces, instead of letting an over-range value fail
22/// validation and silently no-op.
23pub const MAX_FOLLOW_INTERVAL_SECS: u64 = 3600;
24
25/// Configuration for DNS follow operation
26#[derive(Debug, Clone)]
27pub struct FollowConfig {
28    /// Number of checks to perform
29    pub iterations: usize,
30    /// Interval between checks in seconds
31    pub interval_secs: u64,
32    /// Only output when records change
33    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    /// Construct a new `FollowConfig`.
48    ///
49    /// Validates:
50    /// - `iterations` must be >= 1
51    /// - `interval_minutes` must be finite (not NaN / infinity)
52    /// - `interval_minutes` must be non-negative
53    /// - `interval_minutes` must be at most 60
54    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        // A sub-second interval truncates to 0 seconds. For a multi-iteration
81        // follow that means back-to-back live DNS queries with no spacing — a
82        // self-inflicted query flood. Floor to 1s whenever more than one
83        // iteration will run; a single-shot follow (iterations == 1) does no
84        // looping and may keep a 0s interval.
85        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/// Result of a single follow iteration
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FollowIteration {
105    /// Iteration number (1-based)
106    pub iteration: usize,
107    /// Total number of iterations
108    pub total_iterations: usize,
109    /// Timestamp of the check
110    pub timestamp: DateTime<Utc>,
111    /// Records found (or empty if error/NXDOMAIN)
112    pub records: Vec<DnsRecord>,
113    /// Whether records changed from previous iteration
114    pub changed: bool,
115    /// Values added since previous iteration
116    pub added: Vec<String>,
117    /// Values removed since previous iteration
118    pub removed: Vec<String>,
119    /// Error message if the check failed
120    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/// Complete result of a follow operation
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct FollowResult {
136    /// Domain that was monitored
137    pub domain: String,
138    /// Record type that was monitored
139    pub record_type: RecordType,
140    /// Nameserver used (if custom)
141    pub nameserver: Option<String>,
142    /// Configuration used
143    pub iterations_requested: usize,
144    pub interval_secs: u64,
145    /// All iteration results
146    pub iterations: Vec<FollowIteration>,
147    /// Whether the operation was interrupted
148    pub interrupted: bool,
149    /// Total number of changes detected
150    pub total_changes: usize,
151    /// Start time
152    pub started_at: DateTime<Utc>,
153    /// End time
154    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
171/// Callback type for real-time progress updates
172pub type FollowProgressCallback = Arc<dyn Fn(&FollowIteration) + Send + Sync>;
173
174/// DNS Follower - monitors DNS records over time
175#[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    /// Follow DNS records over time
198    #[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            // Check for cancellation
225            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            // Perform DNS lookup
237            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                    // Sanitized for external return; full detail logged above.
246                    (Vec::new(), Some(e.sanitized_message()))
247                }
248            };
249
250            // Extract record values (original casing) for this iteration.
251            let current_values: Vec<String> = records.iter().map(|r| r.data.to_string()).collect();
252
253            // Compare with previous iteration. The diff is case-insensitive so
254            // a resolver applying 0x20 query-name randomization (returning the
255            // same record with different casing) is not reported as a spurious
256            // change; `added`/`removed` still carry the original casing.
257            let (changed, added, removed) = if i == 0 {
258                // First iteration - no previous to compare
259                (false, Vec::new(), Vec::new())
260            } else {
261                diff_record_values(&current_values, &previous_values)
262            };
263
264            if changed {
265                total_changes += 1;
266            }
267
268            let iteration = FollowIteration {
269                iteration: iteration_num,
270                total_iterations: config.iterations,
271                timestamp,
272                records,
273                changed,
274                added,
275                removed,
276                error,
277            };
278
279            // Call progress callback
280            if let Some(ref cb) = callback {
281                // Only call if not changes_only mode, or if this is first iteration or changed
282                if !config.changes_only || iteration_num == 1 || changed {
283                    cb(&iteration);
284                }
285            }
286
287            iterations.push(iteration);
288            // Store case-folded keys for the next iteration's comparison.
289            previous_values = current_values
290                .iter()
291                .map(|v| v.to_ascii_lowercase())
292                .collect();
293
294            // Sleep before next iteration (unless this is the last one)
295            if i < config.iterations - 1 {
296                let sleep_duration = Duration::from_secs(config.interval_secs);
297
298                // Use interruptible sleep
299                if let Some(ref rx) = cancel_rx {
300                    let mut rx_clone = rx.clone();
301                    tokio::select! {
302                        _ = tokio::time::sleep(sleep_duration) => {}
303                        _ = rx_clone.changed() => {
304                            if *rx_clone.borrow() {
305                                debug!("Follow operation cancelled during sleep");
306                                interrupted = true;
307                                break;
308                            }
309                        }
310                    }
311                } else {
312                    tokio::time::sleep(sleep_duration).await;
313                }
314            }
315        }
316
317        let ended_at = Utc::now();
318
319        Ok(FollowResult {
320            domain: domain.to_string(),
321            record_type,
322            nameserver: nameserver.map(|s| s.to_string()),
323            iterations_requested: config.iterations,
324            interval_secs: config.interval_secs,
325            iterations,
326            interrupted,
327            total_changes,
328            started_at,
329            ended_at,
330        })
331    }
332
333    /// Simple follow without callback or cancellation
334    #[instrument(skip(self, config), fields(domain = %domain, record_type = ?record_type))]
335    pub async fn follow_simple(
336        &self,
337        domain: &str,
338        record_type: RecordType,
339        nameserver: Option<&str>,
340        config: FollowConfig,
341    ) -> Result<FollowResult> {
342        self.follow(domain, record_type, nameserver, config, None, None)
343            .await
344    }
345}
346
347/// Diffs the current iteration's record values against the previous
348/// iteration's case-folded key set. Returns `(changed, added, removed)` where
349/// `added`/`removed` preserve the original casing for display, but membership
350/// is decided on the lowercased key — so a record that reappears with only a
351/// case difference (0x20 query-name randomization) is not flagged as a change.
352fn diff_record_values(
353    current_values: &[String],
354    previous_keys: &HashSet<String>,
355) -> (bool, Vec<String>, Vec<String>) {
356    let current_keys: HashSet<String> = current_values
357        .iter()
358        .map(|v| v.to_ascii_lowercase())
359        .collect();
360
361    // Added: present now (by key) but absent from the previous key set.
362    let mut added: Vec<String> = current_values
363        .iter()
364        .filter(|v| !previous_keys.contains(&v.to_ascii_lowercase()))
365        .cloned()
366        .collect();
367    // Removed: previous keys no longer present. The original casing is not
368    // retained across iterations, so emit the lowercased key.
369    let mut removed: Vec<String> = previous_keys
370        .iter()
371        .filter(|k| !current_keys.contains(*k))
372        .cloned()
373        .collect();
374    added.sort();
375    added.dedup();
376    removed.sort();
377    removed.dedup();
378
379    let changed = !added.is_empty() || !removed.is_empty();
380    (changed, added, removed)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    /// Builds a case-folded key set the same way `follow()` stores
388    /// `previous_values`, for use in the diff helper tests.
389    fn lowercased_set<const N: usize>(values: [&str; N]) -> HashSet<String> {
390        values.iter().map(|v| v.to_ascii_lowercase()).collect()
391    }
392
393    #[tokio::test]
394    async fn test_follow_config_default() {
395        let config = FollowConfig::default();
396        assert_eq!(config.iterations, 10);
397        assert_eq!(config.interval_secs, 60);
398        assert!(!config.changes_only);
399    }
400
401    /// Records that differ only in case between iterations (e.g. a resolver that
402    /// applies 0x20 query-name randomization to NS answers) must NOT be reported
403    /// as a change. The comparison key is case-folded.
404    #[test]
405    fn diff_values_ignores_case_only_differences() {
406        let previous = lowercased_set(["NS1.EXAMPLE.COM.", "ns2.example.com."]);
407        let current = vec![
408            "ns1.example.com.".to_string(),
409            "NS2.EXAMPLE.COM.".to_string(),
410        ];
411        let (changed, added, removed) = diff_record_values(&current, &previous);
412        assert!(!changed, "case-only differences must not count as a change");
413        assert!(added.is_empty(), "no added values: {added:?}");
414        assert!(removed.is_empty(), "no removed values: {removed:?}");
415    }
416
417    /// A genuine value change is still detected, and `added`/`removed` carry the
418    /// original casing for display.
419    #[test]
420    fn diff_values_detects_real_change_preserving_case() {
421        let previous = lowercased_set(["ns1.example.com."]);
422        let current = vec!["NS2.Example.Com.".to_string()];
423        let (changed, added, removed) = diff_record_values(&current, &previous);
424        assert!(changed, "a different nameserver is a real change");
425        assert_eq!(added, vec!["NS2.Example.Com.".to_string()]);
426        assert_eq!(removed, vec!["ns1.example.com.".to_string()]);
427    }
428
429    #[test]
430    fn follow_config_rejects_unbounded_iterations() {
431        assert!(FollowConfig::new(MAX_FOLLOW_ITERATIONS, 1.0).is_ok());
432        let err = FollowConfig::new(MAX_FOLLOW_ITERATIONS + 1, 1.0).unwrap_err();
433        assert!(matches!(err, SeerError::InvalidInput(_)));
434        assert!(FollowConfig::new(usize::MAX, 1.0).is_err());
435    }
436
437    #[tokio::test]
438    async fn test_follow_config_new() {
439        let config = FollowConfig::new(5, 0.5).unwrap();
440        assert_eq!(config.iterations, 5);
441        assert_eq!(config.interval_secs, 30);
442    }
443
444    #[tokio::test]
445    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
446    async fn test_follow_single_iteration() {
447        let follower = DnsFollower::new();
448        let config = FollowConfig::new(1, 0.0).unwrap();
449
450        let result = follower
451            .follow_simple("example.com", RecordType::A, None, config)
452            .await;
453
454        assert!(result.is_ok());
455        let result = result.unwrap();
456        assert_eq!(result.completed_iterations(), 1);
457        assert!(!result.interrupted);
458    }
459
460    #[test]
461    fn follow_config_rejects_zero_iterations() {
462        assert!(FollowConfig::new(0, 1.0).is_err());
463    }
464
465    #[test]
466    fn follow_config_rejects_infinite_interval() {
467        assert!(FollowConfig::new(10, f64::INFINITY).is_err());
468        assert!(FollowConfig::new(10, f64::NEG_INFINITY).is_err());
469    }
470
471    #[test]
472    fn follow_config_rejects_nan_interval() {
473        assert!(FollowConfig::new(10, f64::NAN).is_err());
474    }
475
476    #[test]
477    fn follow_config_rejects_negative_interval() {
478        assert!(FollowConfig::new(10, -1.0).is_err());
479    }
480
481    #[test]
482    fn follow_config_rejects_interval_above_cap() {
483        assert!(FollowConfig::new(10, 60.1).is_err());
484    }
485
486    #[test]
487    fn follow_config_accepts_valid() {
488        assert!(FollowConfig::new(10, 1.5).is_ok());
489        assert!(FollowConfig::new(1, 0.0).is_ok());
490        assert!(FollowConfig::new(1, 60.0).is_ok());
491    }
492
493    #[test]
494    fn follow_config_floors_subsecond_interval_for_multi_iteration() {
495        // A sub-second interval truncates to 0s; with many iterations that is
496        // a back-to-back live-DNS query flood. Multi-iteration follows must
497        // be floored to at least 1s between queries.
498        let config = FollowConfig::new(10_000, 0.001).unwrap();
499        assert!(
500            config.interval_secs >= 1,
501            "multi-iteration interval must be floored to >= 1s, got {}",
502            config.interval_secs
503        );
504    }
505
506    #[test]
507    fn follow_config_allows_zero_interval_for_single_iteration() {
508        // A single-shot follow does no looping, so a 0s interval is harmless
509        // and must not be forced to 1s.
510        let config = FollowConfig::new(1, 0.0).unwrap();
511        assert_eq!(config.interval_secs, 0);
512    }
513
514    #[tokio::test]
515    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
516    async fn follow_honors_cancel() {
517        use tokio::sync::watch;
518
519        let (tx, rx) = watch::channel(false);
520        // 100 iterations with 30s intervals would take ~50 minutes.
521        let config = FollowConfig::new(100, 0.5).unwrap();
522        let follower = DnsFollower::new();
523
524        let handle = tokio::spawn(async move {
525            follower
526                .follow("example.com", RecordType::A, None, config, None, Some(rx))
527                .await
528        });
529
530        // Give the follow a tick to start and get into its first sleep.
531        tokio::time::sleep(Duration::from_millis(200)).await;
532        tx.send(true).unwrap();
533
534        let joined = tokio::time::timeout(Duration::from_secs(10), handle)
535            .await
536            .expect("follow should return promptly after cancel");
537        let result = joined.expect("join").expect("follow result");
538        assert!(result.interrupted, "follow should be interrupted");
539        assert!(
540            result.completed_iterations() < 100,
541            "should not complete all iterations"
542        );
543    }
544}