1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use futures::future::join_all;
5use serde::{Deserialize, Serialize};
6use tracing::{debug, instrument, warn};
7
8use super::records::{DnsRecord, RecordType};
9use super::resolver::DnsResolver;
10use crate::error::{Result, SeerError};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DnsServer {
15 pub name: String,
16 pub ip: String,
17 pub location: String,
18 pub provider: String,
19}
20
21impl DnsServer {
22 pub fn new(name: &str, ip: &str, location: &str, provider: &str) -> Self {
23 Self {
24 name: name.to_string(),
25 ip: ip.to_string(),
26 location: location.to_string(),
27 provider: provider.to_string(),
28 }
29 }
30}
31
32pub fn default_dns_servers() -> Vec<DnsServer> {
34 vec![
35 DnsServer::new("Google", "8.8.8.8", "North America", "Google"),
37 DnsServer::new("Cloudflare", "1.1.1.1", "North America", "Cloudflare"),
38 DnsServer::new(
39 "OpenDNS",
40 "208.67.222.222",
41 "North America",
42 "Cisco OpenDNS",
43 ),
44 DnsServer::new("Quad9", "9.9.9.9", "North America", "Quad9"),
45 DnsServer::new("Level3", "4.2.2.1", "North America", "Lumen"),
46 DnsServer::new("DNS.Watch", "84.200.69.80", "Europe", "DNS.Watch"),
48 DnsServer::new("Mullvad", "194.242.2.2", "Europe", "Mullvad"),
49 DnsServer::new("dns0.eu", "193.110.81.0", "Europe", "dns0.eu"),
50 DnsServer::new("Yandex", "77.88.8.8", "Europe", "Yandex"),
51 DnsServer::new("UncensoredDNS", "91.239.100.100", "Europe", "UncensoredDNS"),
52 DnsServer::new("AliDNS", "223.5.5.5", "Asia Pacific", "Alibaba"),
54 DnsServer::new("114DNS", "114.114.114.114", "Asia Pacific", "114DNS"),
55 DnsServer::new("Tencent DNSPod", "119.29.29.29", "Asia Pacific", "Tencent"),
56 DnsServer::new("TWNIC", "101.101.101.101", "Asia Pacific", "TWNIC"),
57 DnsServer::new("HiNet", "168.95.1.1", "Asia Pacific", "Chunghwa Telecom"),
58 DnsServer::new("Claro Brasil", "200.248.178.54", "Latin America", "Claro"),
60 DnsServer::new(
61 "Telefonica Brasil",
62 "200.176.2.10",
63 "Latin America",
64 "Telefonica",
65 ),
66 DnsServer::new("Antel Uruguay", "200.40.30.245", "Latin America", "Antel"),
67 DnsServer::new("Telmex Mexico", "200.33.146.217", "Latin America", "Telmex"),
68 DnsServer::new(
69 "CenturyLink LATAM",
70 "200.75.51.132",
71 "Latin America",
72 "CenturyLink",
73 ),
74 DnsServer::new("Liquid Telecom", "41.63.64.74", "Africa", "Liquid Telecom"),
76 DnsServer::new("SEACOM", "196.216.2.1", "Africa", "SEACOM"),
77 DnsServer::new("Safaricom Kenya", "196.201.214.40", "Africa", "Safaricom"),
78 DnsServer::new("MTN South Africa", "196.11.180.20", "Africa", "MTN"),
79 DnsServer::new("Telecom Egypt", "196.205.152.10", "Africa", "Telecom Egypt"),
80 DnsServer::new("Etisalat UAE", "213.42.20.20", "Middle East", "Etisalat"),
82 DnsServer::new("STC Saudi", "212.118.129.106", "Middle East", "STC"),
83 DnsServer::new("Bezeq Israel", "192.115.106.81", "Middle East", "Bezeq"),
84 DnsServer::new(
85 "Turk Telekom",
86 "195.175.39.39",
87 "Middle East",
88 "Turk Telekom",
89 ),
90 DnsServer::new("Ooredoo Qatar", "212.77.192.10", "Middle East", "Ooredoo"),
91 ]
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ServerResult {
97 pub server: DnsServer,
98 pub records: Vec<DnsRecord>,
99 pub response_time_ms: u64,
100 pub success: bool,
101 pub error: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PropagationResult {
107 pub domain: String,
108 pub record_type: RecordType,
109 pub servers_checked: usize,
110 pub servers_responding: usize,
111 pub propagation_percentage: f64,
112 pub results: Vec<ServerResult>,
113 pub consensus_values: Vec<String>,
114 pub inconsistencies: Vec<String>,
115}
116
117impl PropagationResult {
118 pub fn is_fully_propagated(&self) -> bool {
119 self.propagation_percentage >= 100.0
120 }
121
122 pub fn has_inconsistencies(&self) -> bool {
123 !self.inconsistencies.is_empty()
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct PropagationChecker {
130 resolver: DnsResolver,
131 servers: Vec<DnsServer>,
132}
133
134impl Default for PropagationChecker {
135 fn default() -> Self {
136 Self::new()
137 }
138}
139
140impl PropagationChecker {
141 pub fn new() -> Self {
142 Self {
143 resolver: DnsResolver::new().with_timeout(Duration::from_secs(5)),
144 servers: default_dns_servers(),
145 }
146 }
147
148 pub fn with_servers(mut self, servers: Vec<DnsServer>) -> Self {
149 self.servers = servers;
150 self
151 }
152
153 pub fn add_server(mut self, server: DnsServer) -> Self {
154 self.servers.push(server);
155 self
156 }
157
158 pub fn with_timeout(mut self, timeout: Duration) -> Self {
159 self.resolver = DnsResolver::new().with_timeout(timeout);
160 self
161 }
162
163 const PROPAGATION_TIMEOUT: Duration = Duration::from_secs(15);
167
168 #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
169 pub async fn check(&self, domain: &str, record_type: RecordType) -> Result<PropagationResult> {
170 debug!(servers = self.servers.len(), "Starting propagation check");
171
172 let futures: Vec<_> = self
173 .servers
174 .iter()
175 .map(|server| self.query_server(domain, record_type, server.clone()))
176 .collect();
177
178 let results = tokio::time::timeout(Self::PROPAGATION_TIMEOUT, join_all(futures))
179 .await
180 .map_err(|_| {
181 warn!(
182 domain = %domain,
183 timeout_secs = Self::PROPAGATION_TIMEOUT.as_secs(),
184 "Propagation check timed out"
185 );
186 SeerError::Timeout(format!(
187 "propagation check for {} timed out after {}s",
188 domain,
189 Self::PROPAGATION_TIMEOUT.as_secs()
190 ))
191 })?;
192
193 let servers_checked = results.len();
194 let servers_responding = results.iter().filter(|r| r.success).count();
195
196 let (propagation_percentage, consensus_values, inconsistencies) =
198 analyze_results(&results, record_type);
199
200 Ok(PropagationResult {
201 domain: domain.to_string(),
202 record_type,
203 servers_checked,
204 servers_responding,
205 propagation_percentage,
206 results,
207 consensus_values,
208 inconsistencies,
209 })
210 }
211
212 async fn query_server(
213 &self,
214 domain: &str,
215 record_type: RecordType,
216 server: DnsServer,
217 ) -> ServerResult {
218 let start = Instant::now();
219
220 match self
221 .resolver
222 .resolve(domain, record_type, Some(&server.ip))
223 .await
224 {
225 Ok(records) => {
226 let response_time_ms = start.elapsed().as_millis() as u64;
227 debug!(
228 server = %server.name,
229 records = records.len(),
230 time_ms = response_time_ms,
231 "Server responded"
232 );
233 ServerResult {
234 server,
235 records,
236 response_time_ms,
237 success: true,
238 error: None,
239 }
240 }
241 Err(e) => {
242 let response_time_ms = start.elapsed().as_millis() as u64;
243 debug!(
244 server = %server.name,
245 error = %e,
246 "Server query failed"
247 );
248 ServerResult {
249 server,
250 records: vec![],
251 response_time_ms,
252 success: false,
253 error: Some(e.to_string()),
254 }
255 }
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn test_default_dns_servers() {
266 let servers = default_dns_servers();
267 assert!(
268 servers.len() >= 20,
269 "Should have at least 20 global DNS servers"
270 );
271
272 let locations: Vec<&str> = servers.iter().map(|s| s.location.as_str()).collect();
274 assert!(locations.contains(&"North America"));
275 assert!(locations.contains(&"Europe"));
276 assert!(locations.contains(&"Asia Pacific"));
277 assert!(locations.contains(&"Latin America"));
278 assert!(locations.contains(&"Africa"));
279 assert!(locations.contains(&"Middle East"));
280 }
281
282 #[test]
283 fn test_propagation_result_methods() {
284 let result = PropagationResult {
285 domain: "example.com".to_string(),
286 record_type: RecordType::A,
287 servers_checked: 10,
288 servers_responding: 10,
289 propagation_percentage: 100.0,
290 results: vec![],
291 consensus_values: vec!["1.2.3.4".to_string()],
292 inconsistencies: vec![],
293 };
294 assert!(result.is_fully_propagated());
295 assert!(!result.has_inconsistencies());
296 }
297
298 #[test]
299 fn test_propagation_result_with_inconsistencies() {
300 let result = PropagationResult {
301 domain: "example.com".to_string(),
302 record_type: RecordType::A,
303 servers_checked: 10,
304 servers_responding: 8,
305 propagation_percentage: 75.0,
306 results: vec![],
307 consensus_values: vec!["1.2.3.4".to_string()],
308 inconsistencies: vec!["Server X has different value".to_string()],
309 };
310 assert!(!result.is_fully_propagated());
311 assert!(result.has_inconsistencies());
312 }
313
314 #[test]
315 fn test_dns_server_new() {
316 let server = DnsServer::new("Test", "1.2.3.4", "Test Region", "Test Provider");
317 assert_eq!(server.name, "Test");
318 assert_eq!(server.ip, "1.2.3.4");
319 assert_eq!(server.location, "Test Region");
320 assert_eq!(server.provider, "Test Provider");
321 }
322
323 #[test]
324 fn test_analyze_empty_results() {
325 let results: Vec<ServerResult> = vec![];
326 let (pct, consensus, issues) = analyze_results(&results, RecordType::A);
327 assert_eq!(pct, 0.0);
328 assert!(consensus.is_empty());
329 assert!(!issues.is_empty());
330 }
331
332 #[test]
333 fn test_analyze_consistent_results() {
334 let server = DnsServer::new("Test", "1.1.1.1", "Test", "Test");
335 let results = vec![
336 ServerResult {
337 server: server.clone(),
338 records: vec![DnsRecord {
339 name: "example.com".to_string(),
340 record_type: RecordType::A,
341 ttl: 300,
342 data: crate::dns::RecordData::A {
343 address: "1.2.3.4".to_string(),
344 },
345 }],
346 response_time_ms: 10,
347 success: true,
348 error: None,
349 },
350 ServerResult {
351 server: server.clone(),
352 records: vec![DnsRecord {
353 name: "example.com".to_string(),
354 record_type: RecordType::A,
355 ttl: 300,
356 data: crate::dns::RecordData::A {
357 address: "1.2.3.4".to_string(),
358 },
359 }],
360 response_time_ms: 15,
361 success: true,
362 error: None,
363 },
364 ];
365 let (pct, consensus, issues) = analyze_results(&results, RecordType::A);
366 assert_eq!(pct, 100.0);
367 assert_eq!(consensus, vec!["1.2.3.4"]);
368 assert!(issues.is_empty());
369 }
370
371 #[test]
372 fn test_propagation_result_serialization() {
373 let result = PropagationResult {
374 domain: "test.com".to_string(),
375 record_type: RecordType::A,
376 servers_checked: 5,
377 servers_responding: 5,
378 propagation_percentage: 100.0,
379 results: vec![],
380 consensus_values: vec!["1.2.3.4".to_string()],
381 inconsistencies: vec![],
382 };
383 let json = serde_json::to_string(&result).unwrap();
384 assert!(json.contains("test.com"));
385 assert!(json.contains("100"));
386 }
387}
388
389fn analyze_results(
390 results: &[ServerResult],
391 record_type: RecordType,
392) -> (f64, Vec<String>, Vec<String>) {
393 let successful: Vec<_> = results.iter().filter(|r| r.success).collect();
394
395 if successful.is_empty() {
396 return (0.0, vec![], vec!["No servers responded".to_string()]);
397 }
398
399 let sorted_value_sets: Vec<Vec<String>> = successful
401 .iter()
402 .map(|result| {
403 let mut values: Vec<String> = result.records.iter().map(|r| r.format_short()).collect();
404 values.sort();
405 values
406 })
407 .collect();
408
409 let mut value_counts: HashMap<&Vec<String>, usize> = HashMap::new();
411 for values in &sorted_value_sets {
412 *value_counts.entry(values).or_insert(0) += 1;
413 }
414
415 let Some((consensus_values, consensus_count)) =
417 value_counts.into_iter().max_by_key(|(_, count)| *count)
418 else {
419 return (
421 0.0,
422 vec![],
423 vec!["No propagation data to analyze".to_string()],
424 );
425 };
426
427 let propagation_percentage = (consensus_count as f64 / results.len() as f64) * 100.0;
430
431 let consensus_str = if consensus_values.is_empty() {
433 "NXDOMAIN".to_string()
434 } else {
435 consensus_values.join(", ")
436 };
437
438 let mut inconsistencies = Vec::new();
439 for (result, values) in successful.iter().zip(sorted_value_sets.iter()) {
440 if values != consensus_values {
441 let server_values = if values.is_empty() {
442 "NXDOMAIN".to_string()
443 } else {
444 values.join(", ")
445 };
446 inconsistencies.push(format!(
447 "{} ({}): {} vs consensus: {}",
448 result.server.name, result.server.ip, server_values, consensus_str
449 ));
450 }
451 }
452
453 for result in results.iter().filter(|r| !r.success) {
455 let error_msg = result.error.as_deref().unwrap_or("Unknown error");
456 inconsistencies.push(format!(
457 "{} ({}): {}",
458 result.server.name, result.server.ip, error_msg
459 ));
460 }
461
462 if consensus_values.is_empty()
464 && record_type != RecordType::A
465 && record_type != RecordType::AAAA
466 {
467 }
469
470 (
471 propagation_percentage,
472 consensus_values.clone(),
473 inconsistencies,
474 )
475}