Skip to main content

seer_core/dns/propagation/
checker.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use futures::future::join_all;
6use tokio::sync::Semaphore;
7use tracing::{debug, instrument, warn};
8
9use super::analysis::{
10    analyze_results, build_nameserver_consensus, build_nameserver_inconsistencies, PerVantage,
11};
12use super::servers::default_dns_servers;
13use super::types::{DnsServer, NameserverDetails, PropagationResult, ServerResult};
14use crate::dns::records::{RecordData, RecordType};
15use crate::dns::resolver::DnsResolver;
16use crate::error::Result;
17
18/// Caps concurrent A/AAAA lookups during nameserver-IP enrichment so a large
19/// custom server list can't trigger an unbounded burst of DNS queries (#61).
20const MAX_CONCURRENT_NS_LOOKUPS: usize = 50;
21
22/// Per-server wall-clock budget derived from the resolver's per-query timeout.
23///
24/// A slow or unreachable vantage point is capped at this budget and reported as
25/// a failed `ServerResult`, so it can never drag the whole fan-out out — nor, as
26/// it once could under a single aggregate deadline, discard every server that
27/// already answered. Allows the resolver its per-query timeout plus one retry
28/// (2×) with a 1s margin so a responsive-but-distant resolver is not cut short.
29fn per_server_budget(query_timeout: Duration) -> Duration {
30    query_timeout
31        .saturating_mul(2)
32        .saturating_add(Duration::from_secs(1))
33}
34
35/// Checks DNS propagation across multiple global DNS servers.
36#[derive(Debug, Clone)]
37pub struct PropagationChecker {
38    resolver: DnsResolver,
39    servers: Vec<DnsServer>,
40    /// The resolver's per-query timeout, mirrored here so the per-server
41    /// wall-clock budget (see [`per_server_budget`]) can scale with it.
42    query_timeout: Duration,
43}
44
45impl Default for PropagationChecker {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl PropagationChecker {
52    pub fn new() -> Self {
53        let query_timeout = Duration::from_secs(5);
54        Self {
55            resolver: DnsResolver::new().with_timeout(query_timeout),
56            servers: default_dns_servers().to_vec(),
57            query_timeout,
58        }
59    }
60
61    pub fn with_servers(mut self, servers: Vec<DnsServer>) -> Self {
62        self.servers = servers;
63        self
64    }
65
66    pub fn add_server(mut self, server: DnsServer) -> Self {
67        self.servers.push(server);
68        self
69    }
70
71    pub fn with_timeout(mut self, timeout: Duration) -> Self {
72        self.resolver = DnsResolver::new().with_timeout(timeout);
73        self.query_timeout = timeout;
74        self
75    }
76
77    /// Hard cap on the post-check nameserver-IP enrichment step for NS lookups.
78    /// If it expires, propagation results are returned without IP annotations
79    /// rather than failing the whole call — enrichment is best-effort.
80    /// Bumped from the single-vantage version (5s) because per-vantage
81    /// resolution fans out 29 servers × N nameservers; even fully parallel,
82    /// the slowest single A/AAAA query gates completion and DNS-over-WAN to
83    /// distant resolvers can exceed the per-query timeout.
84    const NS_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(8);
85
86    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
87    pub async fn check(&self, domain: &str, record_type: RecordType) -> Result<PropagationResult> {
88        // Normalize once up front so the stored `domain` field agrees with what
89        // every per-server query actually resolves, instead of echoing the raw
90        // input and re-normalizing ~29 times lazily inside the resolver. (The
91        // DNSSEC checker already normalizes this way.)
92        let domain = crate::validation::normalize_domain(domain)?;
93
94        // An SRV query needs a `_service._proto.name` query name. A bare domain
95        // is a deterministic input error: without this guard it fails identically
96        // on every one of the ~29 servers, and the aggregate result (0% / all
97        // unreachable) is indistinguishable from a real network-wide outage.
98        // Reject it once, up front, before any fan-out.
99        if record_type == RecordType::SRV
100            && crate::dns::resolver::parse_srv_query(&domain).is_none()
101        {
102            return Err(crate::dns::resolver::srv_format_error());
103        }
104
105        debug!(servers = self.servers.len(), "Starting propagation check");
106
107        // Bound each vantage point independently rather than wrapping the whole
108        // fan-out in one aggregate deadline. A single slow or unreachable
109        // resolver (the default list includes regional ISP recursors that don't
110        // answer external queries) exhausting its per-query retries used to push
111        // the aggregate past the outer deadline and fail the ENTIRE check —
112        // discarding every server that had already answered, so a domain that
113        // resolves instantly on the major resolvers reported a spurious timeout.
114        // With a per-server budget the stragglers come back as failed
115        // `ServerResult`s (routed to `unreachable_servers` by `analyze_results`)
116        // and the servers that answered are always preserved.
117        let budget = per_server_budget(self.query_timeout);
118        let futures: Vec<_> = self
119            .servers
120            .iter()
121            .map(|server| self.query_server_bounded(&domain, record_type, server.clone(), budget))
122            .collect();
123
124        // Every future is individually capped at `budget`, so `join_all`
125        // completes in at most `budget` no matter how many vantage points hang.
126        let results = join_all(futures).await;
127
128        let servers_checked = results.len();
129        let servers_responding = results.iter().filter(|r| r.success).count();
130
131        let outcome = analyze_results(&results, record_type);
132
133        // For NS lookups, ask each responding propagation server what A/AAAA
134        // it returns for every nameserver hostname observed in the NS answers.
135        // This is the per-vantage view that surfaces glue-record propagation
136        // lag — a regional recursor still serving the previous IP shows up as
137        // a `NameserverIpInconsistency`. Bounded by NS_RESOLUTION_TIMEOUT so a
138        // slow secondary lookup cannot extend total wall-clock beyond the
139        // documented bound; on timeout we surface results without IP
140        // annotations rather than failing the call.
141        let nameserver_details = if record_type == RecordType::NS {
142            match tokio::time::timeout(
143                Self::NS_RESOLUTION_TIMEOUT,
144                self.resolve_nameserver_details(&results),
145            )
146            .await
147            {
148                Ok(details) => details,
149                Err(_) => {
150                    warn!(
151                        domain = %domain,
152                        timeout_secs = Self::NS_RESOLUTION_TIMEOUT.as_secs(),
153                        "Per-vantage nameserver IP enrichment timed out; returning results without IP annotations"
154                    );
155                    None
156                }
157            }
158        } else {
159            None
160        };
161
162        Ok(PropagationResult {
163            domain: domain.to_string(),
164            record_type,
165            servers_checked,
166            servers_responding,
167            propagation_percentage: outcome.propagation_percentage,
168            results,
169            consensus_values: outcome.consensus_values,
170            inconsistencies: outcome.inconsistencies,
171            unreachable_servers: outcome.unreachable_servers,
172            // DNSSEC validation is not currently performed by the resolver.
173            // This field exists so callers / formatters can disclose the
174            // lack of authentication to users.
175            dnssec_validated: false,
176            nameserver_details,
177        })
178    }
179
180    /// Per-vantage resolution: for every unique nameserver hostname returned
181    /// across all NS answers, ask each successfully-responding propagation
182    /// server (via its own IP) for that hostname's A/AAAA addresses.
183    /// Returns `None` when there are no NS records to enrich; otherwise
184    /// returns `Some(NameserverDetails { consensus, per_vantage, inconsistencies })`.
185    ///
186    /// Failed A/AAAA lookups from a given vantage produce an empty list —
187    /// empty matches an NXDOMAIN/NODATA response, and either way the resolver
188    /// couldn't provide an IP. If that empty differs from the consensus it
189    /// surfaces as a `NameserverIpInconsistency`.
190    ///
191    /// Hostnames are lowercased for dedup so case-variant responses from
192    /// different upstream resolvers do not trigger redundant lookups.
193    /// Formatters must lowercase the record value before looking up the map.
194    async fn resolve_nameserver_details(
195        &self,
196        results: &[ServerResult],
197    ) -> Option<NameserverDetails> {
198        let unique: HashSet<String> = results
199            .iter()
200            .flat_map(|sr| sr.records.iter())
201            .filter_map(|r| match &r.data {
202                RecordData::NS { nameserver } => Some(nameserver.to_ascii_lowercase()),
203                _ => None,
204            })
205            .collect();
206
207        if unique.is_empty() {
208            return None;
209        }
210
211        // Build a flat (server_ip, nameserver) work list of A+AAAA lookups
212        // and fan out in parallel. Only successful propagation servers — those
213        // that already answered the NS query — are queried; an unreachable
214        // server can't meaningfully report a per-vantage IP either.
215        let unique_vec: Vec<String> = unique.into_iter().collect();
216        // Bound the fan-out: this loop produces `responding_servers × unique_ns`
217        // tasks, each doing 2 lookups. Harmless at the built-in 29-server list,
218        // but a large custom `with_servers` list would otherwise spawn an
219        // unbounded burst of concurrent DNS queries (#61).
220        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_NS_LOOKUPS));
221        let mut tasks = Vec::new();
222        for sr in results.iter().filter(|sr| sr.success) {
223            for ns in &unique_vec {
224                let resolver = self.resolver.clone();
225                let server_ip = sr.server.ip.clone();
226                let ns = ns.clone();
227                let sem = sem.clone();
228                tasks.push(async move {
229                    // Held for the task's lifetime; caps concurrent lookups.
230                    let _permit = sem.acquire().await.ok();
231                    let (a_res, aaaa_res) = tokio::join!(
232                        resolver.resolve(&ns, RecordType::A, Some(&server_ip)),
233                        resolver.resolve(&ns, RecordType::AAAA, Some(&server_ip)),
234                    );
235                    let mut ips: Vec<String> = Vec::new();
236                    if let Ok(records) = a_res {
237                        for r in &records {
238                            if let RecordData::A { address } = &r.data {
239                                ips.push(address.clone());
240                            }
241                        }
242                    }
243                    if let Ok(records) = aaaa_res {
244                        for r in &records {
245                            if let RecordData::AAAA { address } = &r.data {
246                                ips.push(address.clone());
247                            }
248                        }
249                    }
250                    ips.sort();
251                    ips.dedup();
252                    (server_ip, ns, ips)
253                });
254            }
255        }
256
257        let outputs = join_all(tasks).await;
258        let mut per_vantage: PerVantage = HashMap::new();
259        for (server_ip, ns, ips) in outputs {
260            per_vantage.entry(server_ip).or_default().insert(ns, ips);
261        }
262
263        let consensus = build_nameserver_consensus(results, &per_vantage, &unique_vec);
264        let inconsistencies = build_nameserver_inconsistencies(results, &per_vantage, &consensus);
265
266        Some(NameserverDetails {
267            consensus,
268            per_vantage,
269            inconsistencies,
270        })
271    }
272
273    #[cfg(test)]
274    fn empty_for_tests() -> Self {
275        Self {
276            resolver: DnsResolver::new(),
277            servers: Vec::new(),
278            query_timeout: Duration::from_secs(5),
279        }
280    }
281
282    /// [`query_server`](Self::query_server) wrapped in a per-server timeout.
283    ///
284    /// On expiry the vantage point is recorded as a failed `ServerResult`
285    /// (`success: false`) rather than left to hang, so one unreachable resolver
286    /// can neither hold up nor discard the whole propagation result. The
287    /// resolver has its own per-query timeout/retries; this is the outer bound
288    /// that also covers a resolver whose retries would otherwise run long.
289    async fn query_server_bounded(
290        &self,
291        domain: &str,
292        record_type: RecordType,
293        server: DnsServer,
294        budget: Duration,
295    ) -> ServerResult {
296        let start = Instant::now();
297        match tokio::time::timeout(
298            budget,
299            self.query_server(domain, record_type, server.clone()),
300        )
301        .await
302        {
303            Ok(result) => result,
304            Err(_) => {
305                debug!(
306                    server = %server.name,
307                    budget_secs = budget.as_secs(),
308                    "Server exceeded per-server budget; recording as unreachable"
309                );
310                ServerResult {
311                    server,
312                    records: vec![],
313                    response_time_ms: start.elapsed().as_millis() as u64,
314                    success: false,
315                    error: Some("query timed out".to_string()),
316                }
317            }
318        }
319    }
320
321    async fn query_server(
322        &self,
323        domain: &str,
324        record_type: RecordType,
325        server: DnsServer,
326    ) -> ServerResult {
327        let start = Instant::now();
328
329        match self
330            .resolver
331            .resolve(domain, record_type, Some(&server.ip))
332            .await
333        {
334            Ok(records) => {
335                let response_time_ms = start.elapsed().as_millis() as u64;
336                debug!(
337                    server = %server.name,
338                    records = records.len(),
339                    time_ms = response_time_ms,
340                    "Server responded"
341                );
342                ServerResult {
343                    server,
344                    records,
345                    response_time_ms,
346                    success: true,
347                    error: None,
348                }
349            }
350            Err(e) => {
351                let response_time_ms = start.elapsed().as_millis() as u64;
352                debug!(
353                    server = %server.name,
354                    error = %e,
355                    "Server query failed"
356                );
357                ServerResult {
358                    server,
359                    records: vec![],
360                    response_time_ms,
361                    success: false,
362                    // Sanitized for external return; full detail logged above.
363                    error: Some(e.sanitized_message()),
364                }
365            }
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::error::SeerError;
374
375    /// `check()` must normalize the input domain ONCE at the top and store the
376    /// normalized value, rather than echoing back the raw (messy) input. An
377    /// empty server list keeps this hermetic — no live DNS is performed.
378    #[tokio::test]
379    async fn check_stores_normalized_domain() {
380        let checker = PropagationChecker::empty_for_tests();
381        let result = checker
382            .check("HTTPS://WWW.Example.COM/some/path", RecordType::A)
383            .await
384            .expect("check with empty server list should succeed");
385        assert_eq!(
386            result.domain, "example.com",
387            "stored domain must be the normalized form, not the raw input"
388        );
389        // Sanity: with no servers, nothing was queried.
390        assert_eq!(result.servers_checked, 0);
391        assert_eq!(result.servers_responding, 0);
392    }
393
394    /// An SRV query against a bare domain is a deterministic input error, not a
395    /// network condition. `check()` must reject it up front with `InvalidInput`
396    /// rather than fanning out and reporting every server as failed (which is
397    /// indistinguishable from a real outage). Uses the empty-server seam, so the
398    /// rejection must happen before any server fan-out for this to pass.
399    #[tokio::test]
400    async fn check_rejects_srv_against_bare_domain() {
401        let checker = PropagationChecker::empty_for_tests();
402        let err = checker
403            .check("example.com", RecordType::SRV)
404            .await
405            .expect_err("bare-domain SRV must be rejected as an input error");
406        assert!(
407            matches!(err, SeerError::InvalidInput(_)),
408            "expected InvalidInput, got: {err:?}"
409        );
410    }
411
412    /// A properly-formed `_service._proto.name` SRV query must NOT be rejected
413    /// by the upfront guard (it should proceed to the normal fan-out path).
414    #[tokio::test]
415    async fn check_allows_well_formed_srv_query() {
416        let checker = PropagationChecker::empty_for_tests();
417        let result = checker
418            .check("_sip._tcp.example.com", RecordType::SRV)
419            .await
420            .expect("well-formed SRV query must pass the upfront guard");
421        assert_eq!(result.servers_checked, 0);
422    }
423
424    /// The per-server budget must allow the resolver's per-query timeout plus a
425    /// retry (2×) and never collapse to zero, so a responsive-but-distant
426    /// resolver is not cut short.
427    #[test]
428    fn per_server_budget_allows_a_retry_plus_margin() {
429        assert_eq!(
430            per_server_budget(Duration::from_secs(5)),
431            Duration::from_secs(11)
432        );
433        // Scales with a custom (e.g. config-driven) timeout.
434        assert_eq!(
435            per_server_budget(Duration::from_secs(2)),
436            Duration::from_secs(5)
437        );
438        // Degenerate zero timeout still yields a usable, non-zero budget.
439        assert_eq!(
440            per_server_budget(Duration::from_secs(0)),
441            Duration::from_secs(1)
442        );
443    }
444
445    /// A vantage point that fails must NOT sink the whole check: the failing
446    /// server is reported as unreachable and the overall call still returns
447    /// `Ok` with partial results. This is the regression guard for the outer
448    /// all-or-nothing timeout that used to discard every server's data when a
449    /// slow minority ran long. Hermetic: reserved IPs are rejected by the SSRF
450    /// guard before any network I/O, so each server fails fast without a live
451    /// query.
452    #[tokio::test]
453    async fn check_returns_partial_results_when_servers_fail() {
454        let servers = vec![
455            DnsServer::new("Reserved-A", "10.0.0.1", "Test", "Test"),
456            DnsServer::new("Reserved-B", "192.168.1.1", "Test", "Test"),
457        ];
458        let checker = PropagationChecker::new().with_servers(servers);
459        let result = checker
460            .check("example.com", RecordType::A)
461            .await
462            .expect("failing servers must yield Ok partial results, not a whole-check error");
463        assert_eq!(result.servers_checked, 2);
464        assert_eq!(result.servers_responding, 0);
465        assert_eq!(result.unreachable_servers.len(), 2);
466    }
467}