use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tracing::{debug, instrument};
use super::records::{DnsRecord, RecordType};
use super::resolver::DnsResolver;
use crate::error::{Result, SeerError};
pub const MAX_FOLLOW_ITERATIONS: usize = 10_000;
pub const MAX_FOLLOW_INTERVAL_SECS: u64 = 3600;
#[derive(Debug, Clone)]
pub struct FollowConfig {
pub iterations: usize,
pub interval_secs: u64,
pub changes_only: bool,
}
impl Default for FollowConfig {
fn default() -> Self {
Self {
iterations: 10,
interval_secs: 60,
changes_only: false,
}
}
}
impl FollowConfig {
pub fn new(iterations: usize, interval_minutes: f64) -> Result<Self> {
if iterations == 0 {
return Err(SeerError::InvalidInput(
"iterations must be at least 1".into(),
));
}
if iterations > MAX_FOLLOW_ITERATIONS {
return Err(SeerError::InvalidInput(format!(
"iterations must be at most {MAX_FOLLOW_ITERATIONS}"
)));
}
if !interval_minutes.is_finite() {
return Err(SeerError::InvalidInput(
"interval_minutes must be a finite number".into(),
));
}
if interval_minutes < 0.0 {
return Err(SeerError::InvalidInput(
"interval_minutes must be non-negative".into(),
));
}
if interval_minutes > MAX_FOLLOW_INTERVAL_SECS as f64 / 60.0 {
return Err(SeerError::InvalidInput(
"interval_minutes must be at most 60".into(),
));
}
let mut interval_secs = (interval_minutes * 60.0).round() as u64;
if iterations > 1 {
interval_secs = interval_secs.max(1);
}
Ok(Self {
iterations,
interval_secs,
changes_only: false,
})
}
pub fn with_changes_only(mut self, changes_only: bool) -> Self {
self.changes_only = changes_only;
self
}
fn validate(&self) -> Result<()> {
if self.iterations == 0 || self.iterations > MAX_FOLLOW_ITERATIONS {
return Err(SeerError::InvalidInput(format!(
"iterations must be between 1 and {MAX_FOLLOW_ITERATIONS}"
)));
}
if self.interval_secs > MAX_FOLLOW_INTERVAL_SECS {
return Err(SeerError::InvalidInput(format!(
"interval must be at most {MAX_FOLLOW_INTERVAL_SECS} seconds"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowIteration {
pub iteration: usize,
pub total_iterations: usize,
pub timestamp: DateTime<Utc>,
pub records: Vec<DnsRecord>,
pub changed: bool,
pub added: Vec<String>,
pub removed: Vec<String>,
pub error: Option<String>,
}
impl FollowIteration {
pub fn success(&self) -> bool {
self.error.is_none()
}
pub fn record_count(&self) -> usize {
self.records.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowResult {
pub domain: String,
pub record_type: RecordType,
pub nameserver: Option<String>,
pub iterations_requested: usize,
pub interval_secs: u64,
pub iterations: Vec<FollowIteration>,
pub interrupted: bool,
pub total_changes: usize,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
}
impl FollowResult {
pub fn completed_iterations(&self) -> usize {
self.iterations.len()
}
}
pub type FollowProgressCallback = Arc<dyn Fn(&FollowIteration) + Send + Sync>;
#[derive(Clone)]
pub struct DnsFollower {
resolver: DnsResolver,
}
impl Default for DnsFollower {
fn default() -> Self {
Self::new()
}
}
impl DnsFollower {
pub fn new() -> Self {
Self {
resolver: DnsResolver::new(),
}
}
pub fn with_resolver(resolver: DnsResolver) -> Self {
Self { resolver }
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::with_resolver(DnsResolver::from_config(config))
}
#[instrument(skip(self, config, callback, cancel_rx))]
pub async fn follow(
&self,
domain: &str,
record_type: RecordType,
nameserver: Option<&str>,
config: FollowConfig,
callback: Option<FollowProgressCallback>,
mut cancel_rx: Option<watch::Receiver<bool>>,
) -> Result<FollowResult> {
config.validate()?;
let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
let started_at = Utc::now();
let mut iterations: Vec<FollowIteration> = Vec::with_capacity(config.iterations);
let mut baseline: Option<Observation> = None;
let mut total_changes = 0;
let mut interrupted = false;
debug!(
domain = %domain,
record_type = %record_type,
iterations = config.iterations,
interval_secs = config.interval_secs,
"Starting DNS follow"
);
for i in 0..config.iterations {
if let Some(ref rx) = cancel_rx {
if *rx.borrow() {
debug!("Follow operation cancelled");
interrupted = true;
break;
}
}
let timestamp = Utc::now();
let iteration_num = i + 1;
let (records, error) = match self
.resolver
.resolve(&domain, record_type, nameserver)
.await
{
Ok(records) => (records, None),
Err(e) => {
debug!(domain = %domain, error = %e, "DNS follow query failed");
(Vec::new(), Some(e.sanitized_message()))
}
};
let current = observe(&records);
let (changed, added, removed) = match (&baseline, &error) {
(Some(previous), None) => diff_observations(¤t, previous),
_ => (false, Vec::new(), Vec::new()),
};
if changed {
total_changes += 1;
}
let error_is_none = error.is_none();
let iteration = FollowIteration {
iteration: iteration_num,
total_iterations: config.iterations,
timestamp,
records,
changed,
added,
removed,
error,
};
if let Some(ref cb) = callback {
if !config.changes_only || iteration_num == 1 || changed {
cb(&iteration);
}
}
iterations.push(iteration);
if error_is_none {
baseline = Some(current);
}
if i < config.iterations - 1 {
let sleep_duration = Duration::from_secs(config.interval_secs);
if let Some(ref mut rx) = cancel_rx {
if cancelled_during(rx, sleep_duration).await {
debug!("Follow operation cancelled during sleep");
interrupted = true;
break;
}
} else {
tokio::time::sleep(sleep_duration).await;
}
}
}
let ended_at = Utc::now();
Ok(FollowResult {
domain,
record_type,
nameserver: nameserver.map(|s| s.to_string()),
iterations_requested: config.iterations,
interval_secs: config.interval_secs,
iterations,
interrupted,
total_changes,
started_at,
ended_at,
})
}
}
async fn cancelled_during(rx: &mut watch::Receiver<bool>, duration: Duration) -> bool {
let cancel = async {
if rx.wait_for(|cancelled| *cancelled).await.is_err() {
std::future::pending::<()>().await;
}
};
tokio::select! {
_ = tokio::time::sleep(duration) => false,
_ = cancel => true,
}
}
type Observation = BTreeMap<String, String>;
fn observe(records: &[DnsRecord]) -> Observation {
records
.iter()
.map(|r| (r.data.comparison_key(), r.data.to_string()))
.collect()
}
fn diff_observations(
current: &Observation,
previous: &Observation,
) -> (bool, Vec<String>, Vec<String>) {
let mut added: Vec<String> = current
.iter()
.filter(|(key, _)| !previous.contains_key(*key))
.map(|(_, value)| value.clone())
.collect();
let mut removed: Vec<String> = previous
.iter()
.filter(|(key, _)| !current.contains_key(*key))
.map(|(_, value)| value.clone())
.collect();
added.sort();
added.dedup();
removed.sort();
removed.dedup();
let changed = !added.is_empty() || !removed.is_empty();
(changed, added, removed)
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::records::RecordData;
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
let follower = DnsFollower::from_config(&config);
assert_eq!(follower.resolver.timeout(), Duration::from_secs(9));
}
fn record(data: RecordData) -> DnsRecord {
DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::NS,
ttl: 300,
data,
}
}
fn ns_observation<const N: usize>(names: [&str; N]) -> Observation {
let records: Vec<DnsRecord> = names
.iter()
.map(|n| {
record(RecordData::NS {
nameserver: n.to_string(),
})
})
.collect();
observe(&records)
}
#[tokio::test]
async fn test_follow_config_default() {
let config = FollowConfig::default();
assert_eq!(config.iterations, 10);
assert_eq!(config.interval_secs, 60);
assert!(!config.changes_only);
}
#[test]
fn diff_values_ignores_case_only_differences() {
let previous = ns_observation(["NS1.EXAMPLE.COM.", "ns2.example.com."]);
let current = ns_observation(["ns1.example.com.", "NS2.EXAMPLE.COM."]);
let (changed, added, removed) = diff_observations(¤t, &previous);
assert!(!changed, "case-only differences must not count as a change");
assert!(added.is_empty(), "no added values: {added:?}");
assert!(removed.is_empty(), "no removed values: {removed:?}");
}
#[test]
fn diff_values_detects_real_change_preserving_case() {
let previous = ns_observation(["NS1.Example.com."]);
let current = ns_observation(["NS2.Example.Com."]);
let (changed, added, removed) = diff_observations(¤t, &previous);
assert!(changed, "a different nameserver is a real change");
assert_eq!(added, vec!["NS2.Example.Com.".to_string()]);
assert_eq!(removed, vec!["NS1.Example.com.".to_string()]);
}
#[test]
fn diff_values_reports_case_change_in_txt() {
let txt = |t: &str| observe(&[record(RecordData::txt(vec![t.to_string()]))]);
let (changed, added, removed) = diff_observations(&txt("token=abc"), &txt("token=AbC"));
assert!(changed);
assert_eq!(added, vec!["\"token=abc\"".to_string()]);
assert_eq!(removed, vec!["\"token=AbC\"".to_string()]);
}
#[test]
fn follow_config_rounds_interval_to_nearest_second() {
assert_eq!(FollowConfig::new(2, 2.05).unwrap().interval_secs, 123);
assert_eq!(FollowConfig::new(2, 0.5).unwrap().interval_secs, 30);
}
#[tokio::test]
async fn follow_rejects_out_of_range_literal_config() {
let follower = DnsFollower::new();
for config in [
FollowConfig {
iterations: usize::MAX,
interval_secs: 1,
changes_only: false,
},
FollowConfig {
iterations: 0,
interval_secs: 1,
changes_only: false,
},
FollowConfig {
iterations: 2,
interval_secs: MAX_FOLLOW_INTERVAL_SECS + 1,
changes_only: false,
},
] {
let err = follower
.follow("example.com", RecordType::A, None, config, None, None)
.await
.expect_err("out-of-range config must be rejected before any query");
assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
}
}
#[tokio::test]
async fn follow_first_iteration_error_is_not_a_baseline() {
use std::sync::atomic::{AtomicBool, Ordering};
use hickory_resolver::proto::op::ResponseCode;
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::{RData as HickoryRData, RecordType as WireType};
use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns_fn, MockReply};
let healthy = Arc::new(AtomicBool::new(false));
let port = spawn_mock_dns_fn({
let healthy = Arc::clone(&healthy);
move |_, qtype| {
if healthy.load(Ordering::SeqCst) && qtype == WireType::A {
MockReply::Answer(vec![HickoryRData::A(wire::A(std::net::Ipv4Addr::new(
192, 0, 2, 7,
)))])
} else {
MockReply::Rcode(ResponseCode::ServFail)
}
}
})
.await;
let callback: FollowProgressCallback = {
let healthy = Arc::clone(&healthy);
Arc::new(move |_: &FollowIteration| healthy.store(true, Ordering::SeqCst))
};
let config = FollowConfig {
iterations: 3,
interval_secs: 0,
changes_only: false,
};
let result = DnsFollower::with_resolver(mock_dns_resolver(port))
.follow(
"seer.test",
RecordType::A,
Some("127.0.0.1"),
config,
Some(callback),
None,
)
.await
.expect("follow against the mock fixture");
assert!(!result.iterations[0].success(), "iteration 1 must error");
let second = &result.iterations[1];
assert!(second.success(), "{second:?}");
assert!(
!second.changed && second.added.is_empty() && second.removed.is_empty(),
"the first success establishes the baseline, it is not a change: {second:?}"
);
assert!(!result.iterations[2].changed);
assert_eq!(result.total_changes, 0);
}
#[tokio::test]
async fn follow_uses_per_name_normalization() {
use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
let port = spawn_mock_dns(MockMode::Zone).await;
let follower = DnsFollower::with_resolver(mock_dns_resolver(port));
let one_shot = || FollowConfig::new(1, 0.0).expect("valid config");
let result = follower
.follow(
"www.seer.test",
RecordType::CNAME,
Some("127.0.0.1"),
one_shot(),
None,
None,
)
.await
.expect("follow www");
assert_eq!(result.domain, "www.seer.test");
assert_eq!(result.iterations[0].record_count(), 1);
let result = follower
.follow(
"2606:4700:4700::1111",
RecordType::PTR,
Some("127.0.0.1"),
one_shot(),
None,
None,
)
.await
.expect("IPv6 PTR literal must be accepted");
assert_eq!(result.domain, "2606:4700:4700::1111");
assert_eq!(result.iterations[0].record_count(), 1);
}
#[test]
fn follow_config_rejects_unbounded_iterations() {
assert!(FollowConfig::new(MAX_FOLLOW_ITERATIONS, 1.0).is_ok());
let err = FollowConfig::new(MAX_FOLLOW_ITERATIONS + 1, 1.0).unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)));
assert!(FollowConfig::new(usize::MAX, 1.0).is_err());
}
#[tokio::test]
async fn test_follow_config_new() {
let config = FollowConfig::new(5, 0.5).unwrap();
assert_eq!(config.iterations, 5);
assert_eq!(config.interval_secs, 30);
}
#[tokio::test]
async fn follow_loop_detects_change_against_mock_dns() {
use std::sync::Mutex;
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::RData as HickoryRData;
use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns_sequence};
let a = |last_octet: u8| {
HickoryRData::A(wire::A(std::net::Ipv4Addr::new(192, 0, 2, last_octet)))
};
let port = spawn_mock_dns_sequence(vec![vec![a(1)], vec![a(2)], vec![a(2)]]).await;
let config = FollowConfig {
iterations: 3,
interval_secs: 0,
changes_only: false,
};
let seen: Arc<Mutex<Vec<(usize, bool)>>> = Arc::new(Mutex::new(Vec::new()));
let callback: FollowProgressCallback = {
let seen = Arc::clone(&seen);
Arc::new(move |it: &FollowIteration| {
seen.lock()
.expect("callback mutex")
.push((it.iteration, it.changed));
})
};
let follower = DnsFollower::with_resolver(mock_dns_resolver(port));
let result = follower
.follow(
"seer.test",
RecordType::A,
Some("127.0.0.1"),
config,
Some(callback),
None,
)
.await
.expect("follow against the mock fixture must succeed");
assert_eq!(result.completed_iterations(), 3);
assert!(result.iterations.iter().all(|i| i.success()));
assert!(!result.interrupted);
assert_eq!(result.domain, "seer.test");
assert_eq!(result.record_type, RecordType::A);
let first = &result.iterations[0];
assert_eq!(first.record_count(), 1);
assert_eq!(first.records[0].data.to_string(), "192.0.2.1");
assert!(!first.changed, "first iteration is never a change");
assert!(first.added.is_empty() && first.removed.is_empty());
let second = &result.iterations[1];
assert!(second.changed, "record change must be detected");
assert_eq!(second.added, vec!["192.0.2.2".to_string()]);
assert_eq!(second.removed, vec!["192.0.2.1".to_string()]);
let third = &result.iterations[2];
assert!(!third.changed, "unchanged records must not be a change");
assert!(third.added.is_empty() && third.removed.is_empty());
assert_eq!(result.total_changes, 1);
let seen = seen.lock().expect("callback mutex");
assert_eq!(*seen, vec![(1, false), (2, true), (3, false)]);
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn test_follow_single_iteration() {
let follower = DnsFollower::new();
let config = FollowConfig::new(1, 0.0).unwrap();
let result = follower
.follow("example.com", RecordType::A, None, config, None, None)
.await;
assert!(result.is_ok());
let result = result.unwrap();
assert_eq!(result.completed_iterations(), 1);
assert!(!result.interrupted);
}
#[test]
fn follow_config_rejects_zero_iterations() {
assert!(FollowConfig::new(0, 1.0).is_err());
}
#[test]
fn follow_config_rejects_infinite_interval() {
assert!(FollowConfig::new(10, f64::INFINITY).is_err());
assert!(FollowConfig::new(10, f64::NEG_INFINITY).is_err());
}
#[test]
fn follow_config_rejects_nan_interval() {
assert!(FollowConfig::new(10, f64::NAN).is_err());
}
#[test]
fn follow_config_rejects_negative_interval() {
assert!(FollowConfig::new(10, -1.0).is_err());
}
#[test]
fn follow_config_rejects_interval_above_cap() {
assert!(FollowConfig::new(10, 60.1).is_err());
}
#[test]
fn follow_config_accepts_valid() {
assert!(FollowConfig::new(10, 1.5).is_ok());
assert!(FollowConfig::new(1, 0.0).is_ok());
assert!(FollowConfig::new(1, 60.0).is_ok());
}
#[test]
fn follow_config_floors_subsecond_interval_for_multi_iteration() {
let config = FollowConfig::new(10_000, 0.001).unwrap();
assert!(
config.interval_secs >= 1,
"multi-iteration interval must be floored to >= 1s, got {}",
config.interval_secs
);
}
#[test]
fn follow_config_allows_zero_interval_for_single_iteration() {
let config = FollowConfig::new(1, 0.0).unwrap();
assert_eq!(config.interval_secs, 0);
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn follow_honors_cancel() {
use tokio::sync::watch;
let (tx, rx) = watch::channel(false);
let config = FollowConfig::new(100, 0.5).unwrap();
let follower = DnsFollower::new();
let handle = tokio::spawn(async move {
follower
.follow("example.com", RecordType::A, None, config, None, Some(rx))
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
tx.send(true).unwrap();
let joined = tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("follow should return promptly after cancel");
let result = joined.expect("join").expect("follow result");
assert!(result.interrupted, "follow should be interrupted");
assert!(
result.completed_iterations() < 100,
"should not complete all iterations"
);
}
#[tokio::test(start_paused = true)]
async fn cancel_sleep_runs_full_length_when_sender_dropped() {
let (tx, mut rx) = watch::channel(false);
drop(tx);
let start = tokio::time::Instant::now();
assert!(!cancelled_during(&mut rx, Duration::from_secs(30)).await);
assert_eq!(start.elapsed(), Duration::from_secs(30));
}
#[tokio::test(start_paused = true)]
async fn cancel_sleep_only_true_cancels() {
let (tx, mut rx) = watch::channel(false);
tx.send(false).expect("receiver alive");
let start = tokio::time::Instant::now();
assert!(!cancelled_during(&mut rx, Duration::from_secs(5)).await);
assert_eq!(start.elapsed(), Duration::from_secs(5));
tx.send(true).expect("receiver alive");
let start = tokio::time::Instant::now();
assert!(cancelled_during(&mut rx, Duration::from_secs(5)).await);
assert_eq!(start.elapsed(), Duration::ZERO);
}
#[tokio::test(start_paused = true)]
async fn cancel_sleep_wakes_on_cancel_mid_sleep() {
let (tx, mut rx) = watch::channel(false);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
tx.send(true).expect("receiver alive");
});
let start = tokio::time::Instant::now();
assert!(cancelled_during(&mut rx, Duration::from_secs(60)).await);
assert_eq!(start.elapsed(), Duration::from_secs(2));
}
}