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}