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 || error.is_some() {
258                // First iteration - no previous to compare. Also skip the diff
259                // on an errored iteration: a transient resolver failure yields
260                // an empty record set that would otherwise look like every
261                // record was removed (and every record re-added on recovery),
262                // inflating total_changes with phantom events.
263                (false, Vec::new(), Vec::new())
264            } else {
265                diff_record_values(&current_values, &previous_values)
266            };
267
268            if changed {
269                total_changes += 1;
270            }
271
272            // Capture before `error` is moved into the iteration struct.
273            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            // Call progress callback
287            if let Some(ref cb) = callback {
288                // Only call if not changes_only mode, or if this is first iteration or changed
289                if !config.changes_only || iteration_num == 1 || changed {
290                    cb(&iteration);
291                }
292            }
293
294            iterations.push(iteration);
295            // Store case-folded keys for the next iteration's comparison. Skip
296            // on an errored iteration so the last known-good observation is
297            // preserved: the next successful iteration is then compared against
298            // real prior values rather than an empty set (which would fabricate
299            // a full re-addition).
300            if error_is_none {
301                previous_values = current_values
302                    .iter()
303                    .map(|v| v.to_ascii_lowercase())
304                    .collect();
305            }
306
307            // Sleep before next iteration (unless this is the last one)
308            if i < config.iterations - 1 {
309                let sleep_duration = Duration::from_secs(config.interval_secs);
310
311                // Use interruptible sleep
312                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    /// Simple follow without callback or cancellation
347    #[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
360/// Diffs the current iteration's record values against the previous
361/// iteration's case-folded key set. Returns `(changed, added, removed)` where
362/// `added`/`removed` preserve the original casing for display, but membership
363/// is decided on the lowercased key — so a record that reappears with only a
364/// case difference (0x20 query-name randomization) is not flagged as a change.
365fn 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    // Added: present now (by key) but absent from the previous key set.
375    let mut added: Vec<String> = current_values
376        .iter()
377        .filter(|v| !previous_keys.contains(&v.to_ascii_lowercase()))
378        .cloned()
379        .collect();
380    // Removed: previous keys no longer present. The original casing is not
381    // retained across iterations, so emit the lowercased key.
382    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    /// Builds a case-folded key set the same way `follow()` stores
401    /// `previous_values`, for use in the diff helper tests.
402    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    /// Records that differ only in case between iterations (e.g. a resolver that
415    /// applies 0x20 query-name randomization to NS answers) must NOT be reported
416    /// as a change. The comparison key is case-folded.
417    #[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(&current, &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    /// A genuine value change is still detected, and `added`/`removed` carry the
431    /// original casing for display.
432    #[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(&current, &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]
458    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
459    async fn test_follow_single_iteration() {
460        let follower = DnsFollower::new();
461        let config = FollowConfig::new(1, 0.0).unwrap();
462
463        let result = follower
464            .follow_simple("example.com", RecordType::A, None, config)
465            .await;
466
467        assert!(result.is_ok());
468        let result = result.unwrap();
469        assert_eq!(result.completed_iterations(), 1);
470        assert!(!result.interrupted);
471    }
472
473    #[test]
474    fn follow_config_rejects_zero_iterations() {
475        assert!(FollowConfig::new(0, 1.0).is_err());
476    }
477
478    #[test]
479    fn follow_config_rejects_infinite_interval() {
480        assert!(FollowConfig::new(10, f64::INFINITY).is_err());
481        assert!(FollowConfig::new(10, f64::NEG_INFINITY).is_err());
482    }
483
484    #[test]
485    fn follow_config_rejects_nan_interval() {
486        assert!(FollowConfig::new(10, f64::NAN).is_err());
487    }
488
489    #[test]
490    fn follow_config_rejects_negative_interval() {
491        assert!(FollowConfig::new(10, -1.0).is_err());
492    }
493
494    #[test]
495    fn follow_config_rejects_interval_above_cap() {
496        assert!(FollowConfig::new(10, 60.1).is_err());
497    }
498
499    #[test]
500    fn follow_config_accepts_valid() {
501        assert!(FollowConfig::new(10, 1.5).is_ok());
502        assert!(FollowConfig::new(1, 0.0).is_ok());
503        assert!(FollowConfig::new(1, 60.0).is_ok());
504    }
505
506    #[test]
507    fn follow_config_floors_subsecond_interval_for_multi_iteration() {
508        // A sub-second interval truncates to 0s; with many iterations that is
509        // a back-to-back live-DNS query flood. Multi-iteration follows must
510        // be floored to at least 1s between queries.
511        let config = FollowConfig::new(10_000, 0.001).unwrap();
512        assert!(
513            config.interval_secs >= 1,
514            "multi-iteration interval must be floored to >= 1s, got {}",
515            config.interval_secs
516        );
517    }
518
519    #[test]
520    fn follow_config_allows_zero_interval_for_single_iteration() {
521        // A single-shot follow does no looping, so a 0s interval is harmless
522        // and must not be forced to 1s.
523        let config = FollowConfig::new(1, 0.0).unwrap();
524        assert_eq!(config.interval_secs, 0);
525    }
526
527    #[tokio::test]
528    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
529    async fn follow_honors_cancel() {
530        use tokio::sync::watch;
531
532        let (tx, rx) = watch::channel(false);
533        // 100 iterations with 30s intervals would take ~50 minutes.
534        let config = FollowConfig::new(100, 0.5).unwrap();
535        let follower = DnsFollower::new();
536
537        let handle = tokio::spawn(async move {
538            follower
539                .follow("example.com", RecordType::A, None, config, None, Some(rx))
540                .await
541        });
542
543        // Give the follow a tick to start and get into its first sleep.
544        tokio::time::sleep(Duration::from_millis(200)).await;
545        tx.send(true).unwrap();
546
547        let joined = tokio::time::timeout(Duration::from_secs(10), handle)
548            .await
549            .expect("follow should return promptly after cancel");
550        let result = joined.expect("join").expect("follow result");
551        assert!(result.interrupted, "follow should be interrupted");
552        assert!(
553            result.completed_iterations() < 100,
554            "should not complete all iterations"
555        );
556    }
557}