Skip to main content

seer_core/dns/
propagation.rs

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/// A DNS server used for propagation checking.
13#[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
32/// Returns the default list of global DNS servers for propagation checking.
33pub fn default_dns_servers() -> Vec<DnsServer> {
34    vec![
35        // North America
36        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        // Europe
47        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        // Asia Pacific
53        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        // Latin America
59        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        // Africa
75        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        // Middle East
81        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/// Result from querying a single DNS server during propagation check.
95#[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/// Aggregated result of DNS propagation check across multiple global servers.
105#[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/// Checks DNS propagation across multiple global DNS servers.
128#[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    /// Outer deadline for the entire propagation check across all servers.
164    /// Individual server queries have their own per-query timeout via the resolver;
165    /// this guards against the aggregate wall-clock time exceeding a safe limit.
166    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        // Calculate propagation and find consensus
197        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        // Verify regions are covered
273        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    // Build sorted value sets once per server result
400    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    // Count occurrences of each value set
410    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    // Find the most common value set (consensus)
416    let Some((consensus_values, consensus_count)) =
417        value_counts.into_iter().max_by_key(|(_, count)| *count)
418    else {
419        // Should never happen since successful is non-empty, but handle gracefully
420        return (
421            0.0,
422            vec![],
423            vec!["No propagation data to analyze".to_string()],
424        );
425    };
426
427    // Calculate propagation percentage based on ALL servers checked (not just
428    // responding ones) so unreachable servers count as non-propagated.
429    let propagation_percentage = (consensus_count as f64 / results.len() as f64) * 100.0;
430
431    // Find inconsistencies (reuse pre-computed sorted value sets)
432    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    // Add failed servers to inconsistencies
454    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    // For record types where empty result is valid, adjust messaging
463    if consensus_values.is_empty()
464        && record_type != RecordType::A
465        && record_type != RecordType::AAAA
466    {
467        // No records is a valid state for optional record types
468    }
469
470    (
471        propagation_percentage,
472        consensus_values.clone(),
473        inconsistencies,
474    )
475}