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, SeerError};
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/// Checks DNS propagation across multiple global DNS servers.
23#[derive(Debug, Clone)]
24pub struct PropagationChecker {
25    resolver: DnsResolver,
26    servers: Vec<DnsServer>,
27}
28
29impl Default for PropagationChecker {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl PropagationChecker {
36    pub fn new() -> Self {
37        Self {
38            resolver: DnsResolver::new().with_timeout(Duration::from_secs(5)),
39            servers: default_dns_servers().to_vec(),
40        }
41    }
42
43    pub fn with_servers(mut self, servers: Vec<DnsServer>) -> Self {
44        self.servers = servers;
45        self
46    }
47
48    pub fn add_server(mut self, server: DnsServer) -> Self {
49        self.servers.push(server);
50        self
51    }
52
53    pub fn with_timeout(mut self, timeout: Duration) -> Self {
54        self.resolver = DnsResolver::new().with_timeout(timeout);
55        self
56    }
57
58    /// Outer deadline for the entire propagation check across all servers.
59    /// Individual server queries have their own per-query timeout via the resolver;
60    /// this guards against the aggregate wall-clock time exceeding a safe limit.
61    const PROPAGATION_TIMEOUT: Duration = Duration::from_secs(15);
62
63    /// Hard cap on the post-check nameserver-IP enrichment step for NS lookups.
64    /// If it expires, propagation results are returned without IP annotations
65    /// rather than failing the whole call — enrichment is best-effort.
66    /// Bumped from the single-vantage version (5s) because per-vantage
67    /// resolution fans out 29 servers × N nameservers; even fully parallel,
68    /// the slowest single A/AAAA query gates completion and DNS-over-WAN to
69    /// distant resolvers can exceed the per-query timeout.
70    const NS_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(8);
71
72    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
73    pub async fn check(&self, domain: &str, record_type: RecordType) -> Result<PropagationResult> {
74        // Normalize once up front so the stored `domain` field agrees with what
75        // every per-server query actually resolves, instead of echoing the raw
76        // input and re-normalizing ~29 times lazily inside the resolver. (The
77        // DNSSEC checker already normalizes this way.)
78        let domain = crate::validation::normalize_domain(domain)?;
79
80        // An SRV query needs a `_service._proto.name` query name. A bare domain
81        // is a deterministic input error: without this guard it fails identically
82        // on every one of the ~29 servers, and the aggregate result (0% / all
83        // unreachable) is indistinguishable from a real network-wide outage.
84        // Reject it once, up front, before any fan-out.
85        if record_type == RecordType::SRV
86            && crate::dns::resolver::parse_srv_query(&domain).is_none()
87        {
88            return Err(crate::dns::resolver::srv_format_error());
89        }
90
91        debug!(servers = self.servers.len(), "Starting propagation check");
92
93        let futures: Vec<_> = self
94            .servers
95            .iter()
96            .map(|server| self.query_server(&domain, record_type, server.clone()))
97            .collect();
98
99        let results = tokio::time::timeout(Self::PROPAGATION_TIMEOUT, join_all(futures))
100            .await
101            .map_err(|_| {
102                warn!(
103                    domain = %domain,
104                    timeout_secs = Self::PROPAGATION_TIMEOUT.as_secs(),
105                    "Propagation check timed out"
106                );
107                SeerError::Timeout(format!(
108                    "propagation check for {} timed out after {}s",
109                    domain,
110                    Self::PROPAGATION_TIMEOUT.as_secs()
111                ))
112            })?;
113
114        let servers_checked = results.len();
115        let servers_responding = results.iter().filter(|r| r.success).count();
116
117        let outcome = analyze_results(&results, record_type);
118
119        // For NS lookups, ask each responding propagation server what A/AAAA
120        // it returns for every nameserver hostname observed in the NS answers.
121        // This is the per-vantage view that surfaces glue-record propagation
122        // lag — a regional recursor still serving the previous IP shows up as
123        // a `NameserverIpInconsistency`. Bounded by NS_RESOLUTION_TIMEOUT so a
124        // slow secondary lookup cannot extend total wall-clock beyond the
125        // documented bound; on timeout we surface results without IP
126        // annotations rather than failing the call.
127        let nameserver_details = if record_type == RecordType::NS {
128            match tokio::time::timeout(
129                Self::NS_RESOLUTION_TIMEOUT,
130                self.resolve_nameserver_details(&results),
131            )
132            .await
133            {
134                Ok(details) => details,
135                Err(_) => {
136                    warn!(
137                        domain = %domain,
138                        timeout_secs = Self::NS_RESOLUTION_TIMEOUT.as_secs(),
139                        "Per-vantage nameserver IP enrichment timed out; returning results without IP annotations"
140                    );
141                    None
142                }
143            }
144        } else {
145            None
146        };
147
148        Ok(PropagationResult {
149            domain: domain.to_string(),
150            record_type,
151            servers_checked,
152            servers_responding,
153            propagation_percentage: outcome.propagation_percentage,
154            results,
155            consensus_values: outcome.consensus_values,
156            inconsistencies: outcome.inconsistencies,
157            unreachable_servers: outcome.unreachable_servers,
158            // DNSSEC validation is not currently performed by the resolver.
159            // This field exists so callers / formatters can disclose the
160            // lack of authentication to users.
161            dnssec_validated: false,
162            nameserver_details,
163        })
164    }
165
166    /// Per-vantage resolution: for every unique nameserver hostname returned
167    /// across all NS answers, ask each successfully-responding propagation
168    /// server (via its own IP) for that hostname's A/AAAA addresses.
169    /// Returns `None` when there are no NS records to enrich; otherwise
170    /// returns `Some(NameserverDetails { consensus, per_vantage, inconsistencies })`.
171    ///
172    /// Failed A/AAAA lookups from a given vantage produce an empty list —
173    /// empty matches an NXDOMAIN/NODATA response, and either way the resolver
174    /// couldn't provide an IP. If that empty differs from the consensus it
175    /// surfaces as a `NameserverIpInconsistency`.
176    ///
177    /// Hostnames are lowercased for dedup so case-variant responses from
178    /// different upstream resolvers do not trigger redundant lookups.
179    /// Formatters must lowercase the record value before looking up the map.
180    async fn resolve_nameserver_details(
181        &self,
182        results: &[ServerResult],
183    ) -> Option<NameserverDetails> {
184        let unique: HashSet<String> = results
185            .iter()
186            .flat_map(|sr| sr.records.iter())
187            .filter_map(|r| match &r.data {
188                RecordData::NS { nameserver } => Some(nameserver.to_ascii_lowercase()),
189                _ => None,
190            })
191            .collect();
192
193        if unique.is_empty() {
194            return None;
195        }
196
197        // Build a flat (server_ip, nameserver) work list of A+AAAA lookups
198        // and fan out in parallel. Only successful propagation servers — those
199        // that already answered the NS query — are queried; an unreachable
200        // server can't meaningfully report a per-vantage IP either.
201        let unique_vec: Vec<String> = unique.into_iter().collect();
202        // Bound the fan-out: this loop produces `responding_servers × unique_ns`
203        // tasks, each doing 2 lookups. Harmless at the built-in 29-server list,
204        // but a large custom `with_servers` list would otherwise spawn an
205        // unbounded burst of concurrent DNS queries (#61).
206        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_NS_LOOKUPS));
207        let mut tasks = Vec::new();
208        for sr in results.iter().filter(|sr| sr.success) {
209            for ns in &unique_vec {
210                let resolver = self.resolver.clone();
211                let server_ip = sr.server.ip.clone();
212                let ns = ns.clone();
213                let sem = sem.clone();
214                tasks.push(async move {
215                    // Held for the task's lifetime; caps concurrent lookups.
216                    let _permit = sem.acquire().await.ok();
217                    let (a_res, aaaa_res) = tokio::join!(
218                        resolver.resolve(&ns, RecordType::A, Some(&server_ip)),
219                        resolver.resolve(&ns, RecordType::AAAA, Some(&server_ip)),
220                    );
221                    let mut ips: Vec<String> = Vec::new();
222                    if let Ok(records) = a_res {
223                        for r in &records {
224                            if let RecordData::A { address } = &r.data {
225                                ips.push(address.clone());
226                            }
227                        }
228                    }
229                    if let Ok(records) = aaaa_res {
230                        for r in &records {
231                            if let RecordData::AAAA { address } = &r.data {
232                                ips.push(address.clone());
233                            }
234                        }
235                    }
236                    ips.sort();
237                    ips.dedup();
238                    (server_ip, ns, ips)
239                });
240            }
241        }
242
243        let outputs = join_all(tasks).await;
244        let mut per_vantage: PerVantage = HashMap::new();
245        for (server_ip, ns, ips) in outputs {
246            per_vantage.entry(server_ip).or_default().insert(ns, ips);
247        }
248
249        let consensus = build_nameserver_consensus(results, &per_vantage, &unique_vec);
250        let inconsistencies = build_nameserver_inconsistencies(results, &per_vantage, &consensus);
251
252        Some(NameserverDetails {
253            consensus,
254            per_vantage,
255            inconsistencies,
256        })
257    }
258
259    #[cfg(test)]
260    fn empty_for_tests() -> Self {
261        Self {
262            resolver: DnsResolver::new(),
263            servers: Vec::new(),
264        }
265    }
266
267    async fn query_server(
268        &self,
269        domain: &str,
270        record_type: RecordType,
271        server: DnsServer,
272    ) -> ServerResult {
273        let start = Instant::now();
274
275        match self
276            .resolver
277            .resolve(domain, record_type, Some(&server.ip))
278            .await
279        {
280            Ok(records) => {
281                let response_time_ms = start.elapsed().as_millis() as u64;
282                debug!(
283                    server = %server.name,
284                    records = records.len(),
285                    time_ms = response_time_ms,
286                    "Server responded"
287                );
288                ServerResult {
289                    server,
290                    records,
291                    response_time_ms,
292                    success: true,
293                    error: None,
294                }
295            }
296            Err(e) => {
297                let response_time_ms = start.elapsed().as_millis() as u64;
298                debug!(
299                    server = %server.name,
300                    error = %e,
301                    "Server query failed"
302                );
303                ServerResult {
304                    server,
305                    records: vec![],
306                    response_time_ms,
307                    success: false,
308                    // Sanitized for external return; full detail logged above.
309                    error: Some(e.sanitized_message()),
310                }
311            }
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    /// `check()` must normalize the input domain ONCE at the top and store the
321    /// normalized value, rather than echoing back the raw (messy) input. An
322    /// empty server list keeps this hermetic — no live DNS is performed.
323    #[tokio::test]
324    async fn check_stores_normalized_domain() {
325        let checker = PropagationChecker::empty_for_tests();
326        let result = checker
327            .check("HTTPS://WWW.Example.COM/some/path", RecordType::A)
328            .await
329            .expect("check with empty server list should succeed");
330        assert_eq!(
331            result.domain, "example.com",
332            "stored domain must be the normalized form, not the raw input"
333        );
334        // Sanity: with no servers, nothing was queried.
335        assert_eq!(result.servers_checked, 0);
336        assert_eq!(result.servers_responding, 0);
337    }
338
339    /// An SRV query against a bare domain is a deterministic input error, not a
340    /// network condition. `check()` must reject it up front with `InvalidInput`
341    /// rather than fanning out and reporting every server as failed (which is
342    /// indistinguishable from a real outage). Uses the empty-server seam, so the
343    /// rejection must happen before any server fan-out for this to pass.
344    #[tokio::test]
345    async fn check_rejects_srv_against_bare_domain() {
346        let checker = PropagationChecker::empty_for_tests();
347        let err = checker
348            .check("example.com", RecordType::SRV)
349            .await
350            .expect_err("bare-domain SRV must be rejected as an input error");
351        assert!(
352            matches!(err, SeerError::InvalidInput(_)),
353            "expected InvalidInput, got: {err:?}"
354        );
355    }
356
357    /// A properly-formed `_service._proto.name` SRV query must NOT be rejected
358    /// by the upfront guard (it should proceed to the normal fan-out path).
359    #[tokio::test]
360    async fn check_allows_well_formed_srv_query() {
361        let checker = PropagationChecker::empty_for_tests();
362        let result = checker
363            .check("_sip._tcp.example.com", RecordType::SRV)
364            .await
365            .expect("well-formed SRV query must pass the upfront guard");
366        assert_eq!(result.servers_checked, 0);
367    }
368}