1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use futures::future::join_all;
5use once_cell::sync::Lazy;
6use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument, warn};
8
9use super::records::{DnsRecord, RecordType};
10use super::resolver::DnsResolver;
11use crate::error::{Result, SeerError};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DnsServer {
16 pub name: String,
17 pub ip: String,
18 pub location: String,
19 pub provider: String,
20}
21
22impl DnsServer {
23 pub fn new(name: &str, ip: &str, location: &str, provider: &str) -> Self {
24 Self {
25 name: name.to_string(),
26 ip: ip.to_string(),
27 location: location.to_string(),
28 provider: provider.to_string(),
29 }
30 }
31}
32
33static DEFAULT_DNS_SERVERS: Lazy<Vec<DnsServer>> = Lazy::new(|| {
37 vec![
38 DnsServer::new("Google", "8.8.8.8", "North America", "Google"),
40 DnsServer::new("Cloudflare", "1.1.1.1", "North America", "Cloudflare"),
41 DnsServer::new(
42 "OpenDNS",
43 "208.67.222.222",
44 "North America",
45 "Cisco OpenDNS",
46 ),
47 DnsServer::new("Quad9", "9.9.9.9", "North America", "Quad9"),
48 DnsServer::new("Level3", "4.2.2.1", "North America", "Lumen"),
49 DnsServer::new("DNS.Watch", "84.200.69.80", "Europe", "DNS.Watch"),
51 DnsServer::new("Mullvad", "194.242.2.2", "Europe", "Mullvad"),
52 DnsServer::new("dns0.eu", "193.110.81.0", "Europe", "dns0.eu"),
53 DnsServer::new("Yandex", "77.88.8.8", "Europe", "Yandex"),
54 DnsServer::new("UncensoredDNS", "91.239.100.100", "Europe", "UncensoredDNS"),
55 DnsServer::new("AliDNS", "223.5.5.5", "Asia Pacific", "Alibaba"),
57 DnsServer::new("114DNS", "114.114.114.114", "Asia Pacific", "114DNS"),
58 DnsServer::new("Tencent DNSPod", "119.29.29.29", "Asia Pacific", "Tencent"),
59 DnsServer::new("TWNIC", "101.101.101.101", "Asia Pacific", "TWNIC"),
60 DnsServer::new("HiNet", "168.95.1.1", "Asia Pacific", "Chunghwa Telecom"),
61 DnsServer::new("Claro Brasil", "200.248.178.54", "Latin America", "Claro"),
63 DnsServer::new(
64 "Telefonica Brasil",
65 "200.176.2.10",
66 "Latin America",
67 "Telefonica",
68 ),
69 DnsServer::new("Antel Uruguay", "200.40.30.245", "Latin America", "Antel"),
70 DnsServer::new("Telmex Mexico", "200.33.146.217", "Latin America", "Telmex"),
71 DnsServer::new(
72 "CenturyLink LATAM",
73 "200.75.51.132",
74 "Latin America",
75 "CenturyLink",
76 ),
77 DnsServer::new("Liquid Telecom", "41.63.64.74", "Africa", "Liquid Telecom"),
79 DnsServer::new("SEACOM", "196.216.2.1", "Africa", "SEACOM"),
80 DnsServer::new("Safaricom Kenya", "196.201.214.40", "Africa", "Safaricom"),
81 DnsServer::new("MTN South Africa", "196.11.180.20", "Africa", "MTN"),
82 DnsServer::new("Telecom Egypt", "196.205.152.10", "Africa", "Telecom Egypt"),
83 DnsServer::new("Etisalat UAE", "213.42.20.20", "Middle East", "Etisalat"),
85 DnsServer::new("STC Saudi", "212.118.129.106", "Middle East", "STC"),
86 DnsServer::new("Bezeq Israel", "192.115.106.81", "Middle East", "Bezeq"),
87 DnsServer::new(
88 "Turk Telekom",
89 "195.175.39.39",
90 "Middle East",
91 "Turk Telekom",
92 ),
93 DnsServer::new("Ooredoo Qatar", "212.77.192.10", "Middle East", "Ooredoo"),
94 ]
95});
96
97pub fn default_dns_servers() -> &'static [DnsServer] {
102 &DEFAULT_DNS_SERVERS
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ServerResult {
108 pub server: DnsServer,
109 pub records: Vec<DnsRecord>,
110 pub response_time_ms: u64,
111 pub success: bool,
112 pub error: Option<String>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct UnreachableServer {
123 pub name: String,
124 pub ip: String,
125 pub error: Option<String>,
126}
127
128fn default_dnssec_validated() -> bool {
129 false
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct PropagationResult {
135 pub domain: String,
136 pub record_type: RecordType,
137 pub servers_checked: usize,
138 pub servers_responding: usize,
139 pub propagation_percentage: f64,
140 pub results: Vec<ServerResult>,
141 pub consensus_values: Vec<String>,
142 pub inconsistencies: Vec<String>,
146 #[serde(default)]
149 pub unreachable_servers: Vec<UnreachableServer>,
150 #[serde(default = "default_dnssec_validated")]
157 pub dnssec_validated: bool,
158}
159
160impl PropagationResult {
161 pub fn is_fully_propagated(&self) -> bool {
162 self.propagation_percentage >= 100.0
163 }
164
165 pub fn has_inconsistencies(&self) -> bool {
170 !self.inconsistencies.is_empty()
171 }
172
173 pub fn has_unreachable_servers(&self) -> bool {
175 !self.unreachable_servers.is_empty()
176 }
177}
178
179#[derive(Debug, Clone)]
181pub struct PropagationChecker {
182 resolver: DnsResolver,
183 servers: Vec<DnsServer>,
184}
185
186impl Default for PropagationChecker {
187 fn default() -> Self {
188 Self::new()
189 }
190}
191
192impl PropagationChecker {
193 pub fn new() -> Self {
194 Self {
195 resolver: DnsResolver::new().with_timeout(Duration::from_secs(5)),
196 servers: default_dns_servers().to_vec(),
197 }
198 }
199
200 pub fn with_servers(mut self, servers: Vec<DnsServer>) -> Self {
201 self.servers = servers;
202 self
203 }
204
205 pub fn add_server(mut self, server: DnsServer) -> Self {
206 self.servers.push(server);
207 self
208 }
209
210 pub fn with_timeout(mut self, timeout: Duration) -> Self {
211 self.resolver = DnsResolver::new().with_timeout(timeout);
212 self
213 }
214
215 const PROPAGATION_TIMEOUT: Duration = Duration::from_secs(15);
219
220 #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
221 pub async fn check(&self, domain: &str, record_type: RecordType) -> Result<PropagationResult> {
222 debug!(servers = self.servers.len(), "Starting propagation check");
223
224 let futures: Vec<_> = self
225 .servers
226 .iter()
227 .map(|server| self.query_server(domain, record_type, server.clone()))
228 .collect();
229
230 let results = tokio::time::timeout(Self::PROPAGATION_TIMEOUT, join_all(futures))
231 .await
232 .map_err(|_| {
233 warn!(
234 domain = %domain,
235 timeout_secs = Self::PROPAGATION_TIMEOUT.as_secs(),
236 "Propagation check timed out"
237 );
238 SeerError::Timeout(format!(
239 "propagation check for {} timed out after {}s",
240 domain,
241 Self::PROPAGATION_TIMEOUT.as_secs()
242 ))
243 })?;
244
245 let servers_checked = results.len();
246 let servers_responding = results.iter().filter(|r| r.success).count();
247
248 let (propagation_percentage, consensus_values, inconsistencies, unreachable_servers) =
250 analyze_results(&results, record_type);
251
252 Ok(PropagationResult {
253 domain: domain.to_string(),
254 record_type,
255 servers_checked,
256 servers_responding,
257 propagation_percentage,
258 results,
259 consensus_values,
260 inconsistencies,
261 unreachable_servers,
262 dnssec_validated: false,
266 })
267 }
268
269 async fn query_server(
270 &self,
271 domain: &str,
272 record_type: RecordType,
273 server: DnsServer,
274 ) -> ServerResult {
275 let start = Instant::now();
276
277 match self
278 .resolver
279 .resolve(domain, record_type, Some(&server.ip))
280 .await
281 {
282 Ok(records) => {
283 let response_time_ms = start.elapsed().as_millis() as u64;
284 debug!(
285 server = %server.name,
286 records = records.len(),
287 time_ms = response_time_ms,
288 "Server responded"
289 );
290 ServerResult {
291 server,
292 records,
293 response_time_ms,
294 success: true,
295 error: None,
296 }
297 }
298 Err(e) => {
299 let response_time_ms = start.elapsed().as_millis() as u64;
300 debug!(
301 server = %server.name,
302 error = %e,
303 "Server query failed"
304 );
305 ServerResult {
306 server,
307 records: vec![],
308 response_time_ms,
309 success: false,
310 error: Some(e.to_string()),
311 }
312 }
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_default_dns_servers() {
323 let servers = default_dns_servers();
324 assert!(
325 servers.len() >= 20,
326 "Should have at least 20 global DNS servers"
327 );
328
329 let locations: Vec<&str> = servers.iter().map(|s| s.location.as_str()).collect();
331 assert!(locations.contains(&"North America"));
332 assert!(locations.contains(&"Europe"));
333 assert!(locations.contains(&"Asia Pacific"));
334 assert!(locations.contains(&"Latin America"));
335 assert!(locations.contains(&"Africa"));
336 assert!(locations.contains(&"Middle East"));
337 }
338
339 #[test]
340 fn test_propagation_result_methods() {
341 let result = PropagationResult {
342 domain: "example.com".to_string(),
343 record_type: RecordType::A,
344 servers_checked: 10,
345 servers_responding: 10,
346 propagation_percentage: 100.0,
347 results: vec![],
348 consensus_values: vec!["1.2.3.4".to_string()],
349 inconsistencies: vec![],
350 unreachable_servers: vec![],
351 dnssec_validated: false,
352 };
353 assert!(result.is_fully_propagated());
354 assert!(!result.has_inconsistencies());
355 assert!(!result.has_unreachable_servers());
356 }
357
358 #[test]
359 fn test_propagation_result_with_inconsistencies() {
360 let result = PropagationResult {
361 domain: "example.com".to_string(),
362 record_type: RecordType::A,
363 servers_checked: 10,
364 servers_responding: 8,
365 propagation_percentage: 75.0,
366 results: vec![],
367 consensus_values: vec!["1.2.3.4".to_string()],
368 inconsistencies: vec!["Server X has different value".to_string()],
369 unreachable_servers: vec![],
370 dnssec_validated: false,
371 };
372 assert!(!result.is_fully_propagated());
373 assert!(result.has_inconsistencies());
374 }
375
376 #[test]
377 fn has_inconsistencies_is_false_when_only_timeouts() {
378 let result = PropagationResult {
382 domain: "example.com".to_string(),
383 record_type: RecordType::A,
384 servers_checked: 29,
385 servers_responding: 28,
386 propagation_percentage: (28.0 / 29.0) * 100.0,
387 results: vec![],
388 consensus_values: vec!["1.2.3.4".to_string()],
389 inconsistencies: vec![],
390 unreachable_servers: vec![UnreachableServer {
391 name: "Flaky DNS".to_string(),
392 ip: "203.0.113.1".to_string(),
393 error: Some("timed out".to_string()),
394 }],
395 dnssec_validated: false,
396 };
397 assert!(!result.has_inconsistencies());
398 assert!(result.has_unreachable_servers());
399 }
400
401 #[test]
402 fn has_inconsistencies_is_true_when_answers_differ() {
403 let result = PropagationResult {
404 domain: "example.com".to_string(),
405 record_type: RecordType::A,
406 servers_checked: 10,
407 servers_responding: 10,
408 propagation_percentage: 90.0,
409 results: vec![],
410 consensus_values: vec!["1.2.3.4".to_string()],
411 inconsistencies: vec![
412 "Server Y (203.0.113.2): 5.6.7.8 vs consensus: 1.2.3.4".to_string()
413 ],
414 unreachable_servers: vec![],
415 dnssec_validated: false,
416 };
417 assert!(result.has_inconsistencies());
418 assert!(!result.has_unreachable_servers());
419 }
420
421 #[test]
422 fn analyze_results_routes_failed_servers_to_unreachable() {
423 let ok_server = DnsServer::new("OK", "1.1.1.1", "NA", "OK");
424 let bad_server = DnsServer::new("Bad", "203.0.113.1", "NA", "Bad");
425
426 let results = vec![
427 ServerResult {
428 server: ok_server.clone(),
429 records: vec![DnsRecord {
430 name: "example.com".to_string(),
431 record_type: RecordType::A,
432 ttl: 300,
433 data: crate::dns::RecordData::A {
434 address: "1.2.3.4".to_string(),
435 },
436 }],
437 response_time_ms: 10,
438 success: true,
439 error: None,
440 },
441 ServerResult {
442 server: bad_server.clone(),
443 records: vec![],
444 response_time_ms: 5000,
445 success: false,
446 error: Some("timed out".to_string()),
447 },
448 ];
449
450 let (_pct, _consensus, inconsistencies, unreachable) =
451 analyze_results(&results, RecordType::A);
452
453 assert!(
454 inconsistencies.is_empty(),
455 "timeout must not produce an inconsistency, got: {:?}",
456 inconsistencies
457 );
458 assert_eq!(unreachable.len(), 1);
459 assert_eq!(unreachable[0].name, "Bad");
460 assert_eq!(unreachable[0].error.as_deref(), Some("timed out"));
461 }
462
463 #[test]
464 fn test_dns_server_new() {
465 let server = DnsServer::new("Test", "1.2.3.4", "Test Region", "Test Provider");
466 assert_eq!(server.name, "Test");
467 assert_eq!(server.ip, "1.2.3.4");
468 assert_eq!(server.location, "Test Region");
469 assert_eq!(server.provider, "Test Provider");
470 }
471
472 #[test]
473 fn test_analyze_empty_results() {
474 let results: Vec<ServerResult> = vec![];
475 let (pct, consensus, issues, unreachable) = analyze_results(&results, RecordType::A);
476 assert_eq!(pct, 0.0);
477 assert!(consensus.is_empty());
478 assert!(!issues.is_empty());
479 assert!(unreachable.is_empty());
480 }
481
482 #[test]
483 fn test_analyze_consistent_results() {
484 let server = DnsServer::new("Test", "1.1.1.1", "Test", "Test");
485 let results = vec![
486 ServerResult {
487 server: server.clone(),
488 records: vec![DnsRecord {
489 name: "example.com".to_string(),
490 record_type: RecordType::A,
491 ttl: 300,
492 data: crate::dns::RecordData::A {
493 address: "1.2.3.4".to_string(),
494 },
495 }],
496 response_time_ms: 10,
497 success: true,
498 error: None,
499 },
500 ServerResult {
501 server: server.clone(),
502 records: vec![DnsRecord {
503 name: "example.com".to_string(),
504 record_type: RecordType::A,
505 ttl: 300,
506 data: crate::dns::RecordData::A {
507 address: "1.2.3.4".to_string(),
508 },
509 }],
510 response_time_ms: 15,
511 success: true,
512 error: None,
513 },
514 ];
515 let (pct, consensus, issues, unreachable) = analyze_results(&results, RecordType::A);
516 assert_eq!(pct, 100.0);
517 assert_eq!(consensus, vec!["1.2.3.4"]);
518 assert!(issues.is_empty());
519 assert!(unreachable.is_empty());
520 }
521
522 #[test]
523 fn test_propagation_result_serialization() {
524 let result = PropagationResult {
525 domain: "test.com".to_string(),
526 record_type: RecordType::A,
527 servers_checked: 5,
528 servers_responding: 5,
529 propagation_percentage: 100.0,
530 results: vec![],
531 consensus_values: vec!["1.2.3.4".to_string()],
532 inconsistencies: vec![],
533 unreachable_servers: vec![],
534 dnssec_validated: false,
535 };
536 let json = serde_json::to_string(&result).unwrap();
537 assert!(json.contains("test.com"));
538 assert!(json.contains("100"));
539 assert!(json.contains("unreachable_servers"));
540 assert!(json.contains("dnssec_validated"));
541 }
542}
543
544fn analyze_results(
545 results: &[ServerResult],
546 record_type: RecordType,
547) -> (f64, Vec<String>, Vec<String>, Vec<UnreachableServer>) {
548 let unreachable_servers: Vec<UnreachableServer> = results
551 .iter()
552 .filter(|r| !r.success)
553 .map(|r| UnreachableServer {
554 name: r.server.name.clone(),
555 ip: r.server.ip.clone(),
556 error: r.error.clone(),
557 })
558 .collect();
559
560 let successful: Vec<_> = results.iter().filter(|r| r.success).collect();
561
562 if successful.is_empty() {
563 return (
564 0.0,
565 vec![],
566 vec!["No servers responded".to_string()],
567 unreachable_servers,
568 );
569 }
570
571 let sorted_value_sets: Vec<Vec<String>> = successful
573 .iter()
574 .map(|result| {
575 let mut values: Vec<String> = result.records.iter().map(|r| r.format_short()).collect();
576 values.sort();
577 values
578 })
579 .collect();
580
581 let mut value_counts: HashMap<&Vec<String>, usize> = HashMap::new();
583 for values in &sorted_value_sets {
584 *value_counts.entry(values).or_insert(0) += 1;
585 }
586
587 let Some((consensus_values, consensus_count)) =
589 value_counts.into_iter().max_by_key(|(_, count)| *count)
590 else {
591 return (
593 0.0,
594 vec![],
595 vec!["No propagation data to analyze".to_string()],
596 unreachable_servers,
597 );
598 };
599
600 let propagation_percentage = (consensus_count as f64 / results.len() as f64) * 100.0;
603
604 let consensus_str = if consensus_values.is_empty() {
606 "NXDOMAIN".to_string()
607 } else {
608 consensus_values.join(", ")
609 };
610
611 let mut inconsistencies = Vec::new();
612 for (result, values) in successful.iter().zip(sorted_value_sets.iter()) {
613 if values != consensus_values {
614 let server_values = if values.is_empty() {
615 "NXDOMAIN".to_string()
616 } else {
617 values.join(", ")
618 };
619 inconsistencies.push(format!(
620 "{} ({}): {} vs consensus: {}",
621 result.server.name, result.server.ip, server_values, consensus_str
622 ));
623 }
624 }
625
626 if consensus_values.is_empty()
632 && record_type != RecordType::A
633 && record_type != RecordType::AAAA
634 {
635 }
637
638 (
639 propagation_percentage,
640 consensus_values.clone(),
641 inconsistencies,
642 unreachable_servers,
643 )
644}