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};
7
8use super::records::{DnsRecord, RecordType};
9use super::resolver::DnsResolver;
10use crate::error::Result;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DnsServer {
14    pub name: String,
15    pub ip: String,
16    pub location: String,
17    pub provider: String,
18}
19
20impl DnsServer {
21    pub fn new(name: &str, ip: &str, location: &str, provider: &str) -> Self {
22        Self {
23            name: name.to_string(),
24            ip: ip.to_string(),
25            location: location.to_string(),
26            provider: provider.to_string(),
27        }
28    }
29}
30
31pub fn default_dns_servers() -> Vec<DnsServer> {
32    vec![
33        // North America
34        DnsServer::new("Google", "8.8.8.8", "North America", "Google"),
35        DnsServer::new("Cloudflare", "1.1.1.1", "North America", "Cloudflare"),
36        DnsServer::new("OpenDNS", "208.67.222.222", "North America", "Cisco OpenDNS"),
37        DnsServer::new("Quad9", "9.9.9.9", "North America", "Quad9"),
38        DnsServer::new("Level3", "4.2.2.1", "North America", "Lumen"),
39        // Europe
40        DnsServer::new("DNS.Watch", "84.200.69.80", "Europe", "DNS.Watch"),
41        DnsServer::new("Mullvad", "194.242.2.2", "Europe", "Mullvad"),
42        DnsServer::new("dns0.eu", "193.110.81.0", "Europe", "dns0.eu"),
43        DnsServer::new("Yandex", "77.88.8.8", "Europe", "Yandex"),
44        DnsServer::new("UncensoredDNS", "91.239.100.100", "Europe", "UncensoredDNS"),
45        // Asia Pacific
46        DnsServer::new("AliDNS", "223.5.5.5", "Asia Pacific", "Alibaba"),
47        DnsServer::new("114DNS", "114.114.114.114", "Asia Pacific", "114DNS"),
48        DnsServer::new("Tencent DNSPod", "119.29.29.29", "Asia Pacific", "Tencent"),
49        DnsServer::new("TWNIC", "101.101.101.101", "Asia Pacific", "TWNIC"),
50        DnsServer::new("HiNet", "168.95.1.1", "Asia Pacific", "Chunghwa Telecom"),
51        // Latin America
52        DnsServer::new("Claro Brasil", "200.248.178.54", "Latin America", "Claro"),
53        DnsServer::new("Telefonica Brasil", "200.176.2.10", "Latin America", "Telefonica"),
54        DnsServer::new("Antel Uruguay", "200.40.30.245", "Latin America", "Antel"),
55        DnsServer::new("Telmex Mexico", "200.33.146.217", "Latin America", "Telmex"),
56        DnsServer::new("CenturyLink LATAM", "200.75.51.132", "Latin America", "CenturyLink"),
57        // Africa
58        DnsServer::new("Liquid Telecom", "41.63.64.74", "Africa", "Liquid Telecom"),
59        DnsServer::new("SEACOM", "196.216.2.1", "Africa", "SEACOM"),
60        DnsServer::new("Safaricom Kenya", "196.201.214.40", "Africa", "Safaricom"),
61        DnsServer::new("MTN South Africa", "196.11.180.20", "Africa", "MTN"),
62        DnsServer::new("Telecom Egypt", "196.205.152.10", "Africa", "Telecom Egypt"),
63        // Middle East
64        DnsServer::new("Etisalat UAE", "213.42.20.20", "Middle East", "Etisalat"),
65        DnsServer::new("STC Saudi", "212.118.129.106", "Middle East", "STC"),
66        DnsServer::new("Bezeq Israel", "192.115.106.81", "Middle East", "Bezeq"),
67        DnsServer::new("Turk Telekom", "195.175.39.39", "Middle East", "Turk Telekom"),
68        DnsServer::new("Ooredoo Qatar", "212.77.192.10", "Middle East", "Ooredoo"),
69    ]
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ServerResult {
74    pub server: DnsServer,
75    pub records: Vec<DnsRecord>,
76    pub response_time_ms: u64,
77    pub success: bool,
78    pub error: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PropagationResult {
83    pub domain: String,
84    pub record_type: RecordType,
85    pub servers_checked: usize,
86    pub servers_responding: usize,
87    pub propagation_percentage: f64,
88    pub results: Vec<ServerResult>,
89    pub consensus_values: Vec<String>,
90    pub inconsistencies: Vec<String>,
91}
92
93impl PropagationResult {
94    pub fn is_fully_propagated(&self) -> bool {
95        self.propagation_percentage >= 100.0
96    }
97
98    pub fn has_inconsistencies(&self) -> bool {
99        !self.inconsistencies.is_empty()
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct PropagationChecker {
105    resolver: DnsResolver,
106    servers: Vec<DnsServer>,
107}
108
109impl Default for PropagationChecker {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl PropagationChecker {
116    pub fn new() -> Self {
117        Self {
118            resolver: DnsResolver::new().with_timeout(Duration::from_secs(5)),
119            servers: default_dns_servers(),
120        }
121    }
122
123    pub fn with_servers(mut self, servers: Vec<DnsServer>) -> Self {
124        self.servers = servers;
125        self
126    }
127
128    pub fn add_server(mut self, server: DnsServer) -> Self {
129        self.servers.push(server);
130        self
131    }
132
133    pub fn with_timeout(mut self, timeout: Duration) -> Self {
134        self.resolver = DnsResolver::new().with_timeout(timeout);
135        self
136    }
137
138    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
139    pub async fn check(
140        &self,
141        domain: &str,
142        record_type: RecordType,
143    ) -> Result<PropagationResult> {
144        debug!(servers = self.servers.len(), "Starting propagation check");
145
146        let futures: Vec<_> = self
147            .servers
148            .iter()
149            .map(|server| self.query_server(domain, record_type, server.clone()))
150            .collect();
151
152        let results = join_all(futures).await;
153
154        let servers_checked = results.len();
155        let servers_responding = results.iter().filter(|r| r.success).count();
156
157        // Calculate propagation and find consensus
158        let (propagation_percentage, consensus_values, inconsistencies) =
159            analyze_results(&results, record_type);
160
161        Ok(PropagationResult {
162            domain: domain.to_string(),
163            record_type,
164            servers_checked,
165            servers_responding,
166            propagation_percentage,
167            results,
168            consensus_values,
169            inconsistencies,
170        })
171    }
172
173    async fn query_server(
174        &self,
175        domain: &str,
176        record_type: RecordType,
177        server: DnsServer,
178    ) -> ServerResult {
179        let start = Instant::now();
180
181        match self
182            .resolver
183            .resolve(domain, record_type, Some(&server.ip))
184            .await
185        {
186            Ok(records) => {
187                let response_time_ms = start.elapsed().as_millis() as u64;
188                debug!(
189                    server = %server.name,
190                    records = records.len(),
191                    time_ms = response_time_ms,
192                    "Server responded"
193                );
194                ServerResult {
195                    server,
196                    records,
197                    response_time_ms,
198                    success: true,
199                    error: None,
200                }
201            }
202            Err(e) => {
203                let response_time_ms = start.elapsed().as_millis() as u64;
204                debug!(
205                    server = %server.name,
206                    error = %e,
207                    "Server query failed"
208                );
209                ServerResult {
210                    server,
211                    records: vec![],
212                    response_time_ms,
213                    success: false,
214                    error: Some(e.to_string()),
215                }
216            }
217        }
218    }
219}
220
221fn analyze_results(
222    results: &[ServerResult],
223    record_type: RecordType,
224) -> (f64, Vec<String>, Vec<String>) {
225    let successful: Vec<_> = results.iter().filter(|r| r.success).collect();
226
227    if successful.is_empty() {
228        return (0.0, vec![], vec!["No servers responded".to_string()]);
229    }
230
231    // Count occurrences of each value set
232    let mut value_counts: HashMap<Vec<String>, usize> = HashMap::new();
233
234    for result in &successful {
235        let mut values: Vec<String> = result
236            .records
237            .iter()
238            .map(|r| r.format_short())
239            .collect();
240        values.sort();
241        *value_counts.entry(values).or_insert(0) += 1;
242    }
243
244    // Find the most common value set (consensus)
245    let (consensus_values, consensus_count) = value_counts
246        .iter()
247        .max_by_key(|(_, count)| *count)
248        .map(|(values, count)| (values.clone(), *count))
249        .unwrap_or((vec![], 0));
250
251    // Calculate propagation percentage based on consensus
252    let propagation_percentage = if successful.is_empty() {
253        0.0
254    } else {
255        (consensus_count as f64 / successful.len() as f64) * 100.0
256    };
257
258    // Find inconsistencies
259    let mut inconsistencies = Vec::new();
260    for result in &successful {
261        let mut values: Vec<String> = result
262            .records
263            .iter()
264            .map(|r| r.format_short())
265            .collect();
266        values.sort();
267
268        if values != consensus_values {
269            let inconsistency = format!(
270                "{} ({}): {} vs consensus: {}",
271                result.server.name,
272                result.server.ip,
273                if values.is_empty() {
274                    "NXDOMAIN".to_string()
275                } else {
276                    values.join(", ")
277                },
278                if consensus_values.is_empty() {
279                    "NXDOMAIN".to_string()
280                } else {
281                    consensus_values.join(", ")
282                }
283            );
284            inconsistencies.push(inconsistency);
285        }
286    }
287
288    // Add failed servers to inconsistencies
289    for result in results.iter().filter(|r| !r.success) {
290        let error_msg = result.error.as_deref().unwrap_or("Unknown error");
291        inconsistencies.push(format!(
292            "{} ({}): {}",
293            result.server.name, result.server.ip, error_msg
294        ));
295    }
296
297    // For record types where empty result is valid, adjust messaging
298    if consensus_values.is_empty() && record_type != RecordType::A && record_type != RecordType::AAAA {
299        // No records is a valid state for optional record types
300    }
301
302    (propagation_percentage, consensus_values, inconsistencies)
303}