seer_core/dns/propagation/
checker.rs1use 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
18const MAX_CONCURRENT_NS_LOOKUPS: usize = 50;
21
22#[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 const PROPAGATION_TIMEOUT: Duration = Duration::from_secs(15);
62
63 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 let domain = crate::validation::normalize_domain(domain)?;
79
80 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 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_validated: false,
162 nameserver_details,
163 })
164 }
165
166 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 let unique_vec: Vec<String> = unique.into_iter().collect();
202 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 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 error: Some(e.sanitized_message()),
310 }
311 }
312 }
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[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 assert_eq!(result.servers_checked, 0);
336 assert_eq!(result.servers_responding, 0);
337 }
338
339 #[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 #[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}