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.
17const MAX_FOLLOW_ITERATIONS: usize = 10_000;
18
19/// Configuration for DNS follow operation
20#[derive(Debug, Clone)]
21pub struct FollowConfig {
22    /// Number of checks to perform
23    pub iterations: usize,
24    /// Interval between checks in seconds
25    pub interval_secs: u64,
26    /// Only output when records change
27    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    /// Construct a new `FollowConfig`.
42    ///
43    /// Validates:
44    /// - `iterations` must be >= 1
45    /// - `interval_minutes` must be finite (not NaN / infinity)
46    /// - `interval_minutes` must be non-negative
47    /// - `interval_minutes` must be at most 60
48    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        // A sub-second interval truncates to 0 seconds. For a multi-iteration
75        // follow that means back-to-back live DNS queries with no spacing — a
76        // self-inflicted query flood. Floor to 1s whenever more than one
77        // iteration will run; a single-shot follow (iterations == 1) does no
78        // looping and may keep a 0s interval.
79        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/// Result of a single follow iteration
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct FollowIteration {
99    /// Iteration number (1-based)
100    pub iteration: usize,
101    /// Total number of iterations
102    pub total_iterations: usize,
103    /// Timestamp of the check
104    pub timestamp: DateTime<Utc>,
105    /// Records found (or empty if error/NXDOMAIN)
106    pub records: Vec<DnsRecord>,
107    /// Whether records changed from previous iteration
108    pub changed: bool,
109    /// Values added since previous iteration
110    pub added: Vec<String>,
111    /// Values removed since previous iteration
112    pub removed: Vec<String>,
113    /// Error message if the check failed
114    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/// Complete result of a follow operation
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct FollowResult {
130    /// Domain that was monitored
131    pub domain: String,
132    /// Record type that was monitored
133    pub record_type: RecordType,
134    /// Nameserver used (if custom)
135    pub nameserver: Option<String>,
136    /// Configuration used
137    pub iterations_requested: usize,
138    pub interval_secs: u64,
139    /// All iteration results
140    pub iterations: Vec<FollowIteration>,
141    /// Whether the operation was interrupted
142    pub interrupted: bool,
143    /// Total number of changes detected
144    pub total_changes: usize,
145    /// Start time
146    pub started_at: DateTime<Utc>,
147    /// End time
148    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
165/// Callback type for real-time progress updates
166pub type FollowProgressCallback = Arc<dyn Fn(&FollowIteration) + Send + Sync>;
167
168/// DNS Follower - monitors DNS records over time
169#[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    /// Follow DNS records over time
192    #[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            // Check for cancellation
219            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            // Perform DNS lookup
231            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                    // Sanitized for external return; full detail logged above.
240                    (Vec::new(), Some(e.sanitized_message()))
241                }
242            };
243
244            // Extract record values (original casing) for this iteration.
245            let current_values: Vec<String> = records.iter().map(|r| r.data.to_string()).collect();
246
247            // Compare with previous iteration. The diff is case-insensitive so
248            // a resolver applying 0x20 query-name randomization (returning the
249            // same record with different casing) is not reported as a spurious
250            // change; `added`/`removed` still carry the original casing.
251            let (changed, added, removed) = if i == 0 {
252                // First iteration - no previous to compare
253                (false, Vec::new(), Vec::new())
254            } else {
255                diff_record_values(&current_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            // Call progress callback
274            if let Some(ref cb) = callback {
275                // Only call if not changes_only mode, or if this is first iteration or changed
276                if !config.changes_only || iteration_num == 1 || changed {
277                    cb(&iteration);
278                }
279            }
280
281            iterations.push(iteration);
282            // Store case-folded keys for the next iteration's comparison.
283            previous_values = current_values
284                .iter()
285                .map(|v| v.to_ascii_lowercase())
286                .collect();
287
288            // Sleep before next iteration (unless this is the last one)
289            if i < config.iterations - 1 {
290                let sleep_duration = Duration::from_secs(config.interval_secs);
291
292                // Use interruptible sleep
293                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    /// Simple follow without callback or cancellation
328    #[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
341/// Diffs the current iteration's record values against the previous
342/// iteration's case-folded key set. Returns `(changed, added, removed)` where
343/// `added`/`removed` preserve the original casing for display, but membership
344/// is decided on the lowercased key — so a record that reappears with only a
345/// case difference (0x20 query-name randomization) is not flagged as a change.
346fn 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    // Added: present now (by key) but absent from the previous key set.
356    let mut added: Vec<String> = current_values
357        .iter()
358        .filter(|v| !previous_keys.contains(&v.to_ascii_lowercase()))
359        .cloned()
360        .collect();
361    // Removed: previous keys no longer present. The original casing is not
362    // retained across iterations, so emit the lowercased key.
363    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    /// Builds a case-folded key set the same way `follow()` stores
382    /// `previous_values`, for use in the diff helper tests.
383    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    /// Records that differ only in case between iterations (e.g. a resolver that
396    /// applies 0x20 query-name randomization to NS answers) must NOT be reported
397    /// as a change. The comparison key is case-folded.
398    #[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(&current, &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    /// A genuine value change is still detected, and `added`/`removed` carry the
412    /// original casing for display.
413    #[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(&current, &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        // A sub-second interval truncates to 0s; with many iterations that is
490        // a back-to-back live-DNS query flood. Multi-iteration follows must
491        // be floored to at least 1s between queries.
492        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        // A single-shot follow does no looping, so a 0s interval is harmless
503        // and must not be forced to 1s.
504        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        // 100 iterations with 30s intervals would take ~50 minutes.
515        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        // Give the follow a tick to start and get into its first sleep.
525        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}