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        debug!(servers = self.servers.len(), "Starting propagation check");
75
76        let futures: Vec<_> = self
77            .servers
78            .iter()
79            .map(|server| self.query_server(domain, record_type, server.clone()))
80            .collect();
81
82        let results = tokio::time::timeout(Self::PROPAGATION_TIMEOUT, join_all(futures))
83            .await
84            .map_err(|_| {
85                warn!(
86                    domain = %domain,
87                    timeout_secs = Self::PROPAGATION_TIMEOUT.as_secs(),
88                    "Propagation check timed out"
89                );
90                SeerError::Timeout(format!(
91                    "propagation check for {} timed out after {}s",
92                    domain,
93                    Self::PROPAGATION_TIMEOUT.as_secs()
94                ))
95            })?;
96
97        let servers_checked = results.len();
98        let servers_responding = results.iter().filter(|r| r.success).count();
99
100        let outcome = analyze_results(&results, record_type);
101
102        // For NS lookups, ask each responding propagation server what A/AAAA
103        // it returns for every nameserver hostname observed in the NS answers.
104        // This is the per-vantage view that surfaces glue-record propagation
105        // lag — a regional recursor still serving the previous IP shows up as
106        // a `NameserverIpInconsistency`. Bounded by NS_RESOLUTION_TIMEOUT so a
107        // slow secondary lookup cannot extend total wall-clock beyond the
108        // documented bound; on timeout we surface results without IP
109        // annotations rather than failing the call.
110        let nameserver_details = if record_type == RecordType::NS {
111            match tokio::time::timeout(
112                Self::NS_RESOLUTION_TIMEOUT,
113                self.resolve_nameserver_details(&results),
114            )
115            .await
116            {
117                Ok(details) => details,
118                Err(_) => {
119                    warn!(
120                        domain = %domain,
121                        timeout_secs = Self::NS_RESOLUTION_TIMEOUT.as_secs(),
122                        "Per-vantage nameserver IP enrichment timed out; returning results without IP annotations"
123                    );
124                    None
125                }
126            }
127        } else {
128            None
129        };
130
131        Ok(PropagationResult {
132            domain: domain.to_string(),
133            record_type,
134            servers_checked,
135            servers_responding,
136            propagation_percentage: outcome.propagation_percentage,
137            results,
138            consensus_values: outcome.consensus_values,
139            inconsistencies: outcome.inconsistencies,
140            unreachable_servers: outcome.unreachable_servers,
141            // DNSSEC validation is not currently performed by the resolver.
142            // This field exists so callers / formatters can disclose the
143            // lack of authentication to users.
144            dnssec_validated: false,
145            nameserver_details,
146        })
147    }
148
149    /// Per-vantage resolution: for every unique nameserver hostname returned
150    /// across all NS answers, ask each successfully-responding propagation
151    /// server (via its own IP) for that hostname's A/AAAA addresses.
152    /// Returns `None` when there are no NS records to enrich; otherwise
153    /// returns `Some(NameserverDetails { consensus, per_vantage, inconsistencies })`.
154    ///
155    /// Failed A/AAAA lookups from a given vantage produce an empty list —
156    /// empty matches an NXDOMAIN/NODATA response, and either way the resolver
157    /// couldn't provide an IP. If that empty differs from the consensus it
158    /// surfaces as a `NameserverIpInconsistency`.
159    ///
160    /// Hostnames are lowercased for dedup so case-variant responses from
161    /// different upstream resolvers do not trigger redundant lookups.
162    /// Formatters must lowercase the record value before looking up the map.
163    async fn resolve_nameserver_details(
164        &self,
165        results: &[ServerResult],
166    ) -> Option<NameserverDetails> {
167        let unique: HashSet<String> = results
168            .iter()
169            .flat_map(|sr| sr.records.iter())
170            .filter_map(|r| match &r.data {
171                RecordData::NS { nameserver } => Some(nameserver.to_ascii_lowercase()),
172                _ => None,
173            })
174            .collect();
175
176        if unique.is_empty() {
177            return None;
178        }
179
180        // Build a flat (server_ip, nameserver) work list of A+AAAA lookups
181        // and fan out in parallel. Only successful propagation servers — those
182        // that already answered the NS query — are queried; an unreachable
183        // server can't meaningfully report a per-vantage IP either.
184        let unique_vec: Vec<String> = unique.into_iter().collect();
185        // Bound the fan-out: this loop produces `responding_servers × unique_ns`
186        // tasks, each doing 2 lookups. Harmless at the built-in 29-server list,
187        // but a large custom `with_servers` list would otherwise spawn an
188        // unbounded burst of concurrent DNS queries (#61).
189        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_NS_LOOKUPS));
190        let mut tasks = Vec::new();
191        for sr in results.iter().filter(|sr| sr.success) {
192            for ns in &unique_vec {
193                let resolver = self.resolver.clone();
194                let server_ip = sr.server.ip.clone();
195                let ns = ns.clone();
196                let sem = sem.clone();
197                tasks.push(async move {
198                    // Held for the task's lifetime; caps concurrent lookups.
199                    let _permit = sem.acquire().await.ok();
200                    let (a_res, aaaa_res) = tokio::join!(
201                        resolver.resolve(&ns, RecordType::A, Some(&server_ip)),
202                        resolver.resolve(&ns, RecordType::AAAA, Some(&server_ip)),
203                    );
204                    let mut ips: Vec<String> = Vec::new();
205                    if let Ok(records) = a_res {
206                        for r in &records {
207                            if let RecordData::A { address } = &r.data {
208                                ips.push(address.clone());
209                            }
210                        }
211                    }
212                    if let Ok(records) = aaaa_res {
213                        for r in &records {
214                            if let RecordData::AAAA { address } = &r.data {
215                                ips.push(address.clone());
216                            }
217                        }
218                    }
219                    ips.sort();
220                    ips.dedup();
221                    (server_ip, ns, ips)
222                });
223            }
224        }
225
226        let outputs = join_all(tasks).await;
227        let mut per_vantage: PerVantage = HashMap::new();
228        for (server_ip, ns, ips) in outputs {
229            per_vantage.entry(server_ip).or_default().insert(ns, ips);
230        }
231
232        let consensus = build_nameserver_consensus(results, &per_vantage, &unique_vec);
233        let inconsistencies = build_nameserver_inconsistencies(results, &per_vantage, &consensus);
234
235        Some(NameserverDetails {
236            consensus,
237            per_vantage,
238            inconsistencies,
239        })
240    }
241
242    async fn query_server(
243        &self,
244        domain: &str,
245        record_type: RecordType,
246        server: DnsServer,
247    ) -> ServerResult {
248        let start = Instant::now();
249
250        match self
251            .resolver
252            .resolve(domain, record_type, Some(&server.ip))
253            .await
254        {
255            Ok(records) => {
256                let response_time_ms = start.elapsed().as_millis() as u64;
257                debug!(
258                    server = %server.name,
259                    records = records.len(),
260                    time_ms = response_time_ms,
261                    "Server responded"
262                );
263                ServerResult {
264                    server,
265                    records,
266                    response_time_ms,
267                    success: true,
268                    error: None,
269                }
270            }
271            Err(e) => {
272                let response_time_ms = start.elapsed().as_millis() as u64;
273                debug!(
274                    server = %server.name,
275                    error = %e,
276                    "Server query failed"
277                );
278                ServerResult {
279                    server,
280                    records: vec![],
281                    response_time_ms,
282                    success: false,
283                    // Sanitized for external return; full detail logged above.
284                    error: Some(e.sanitized_message()),
285                }
286            }
287        }
288    }
289}