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 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/// A DNS server used for propagation checking.
14#[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
33/// Built-in list of global DNS servers for propagation checking.
34/// Constructed once on first access; callers that need ownership call
35/// `default_dns_servers().to_vec()`.
36static DEFAULT_DNS_SERVERS: Lazy<Vec<DnsServer>> = Lazy::new(|| {
37    vec![
38        // North America
39        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        // Europe
50        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        // Asia Pacific
56        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        // Latin America
62        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        // Africa
78        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        // Middle East
84        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
97/// Returns the default list of global DNS servers for propagation checking.
98/// The list is built once and handed out as a borrow. Callers needing an
99/// owned `Vec` (e.g. `PropagationChecker` which allows mutation) can call
100/// `.to_vec()` on the returned slice.
101pub fn default_dns_servers() -> &'static [DnsServer] {
102    &DEFAULT_DNS_SERVERS
103}
104
105/// Result from querying a single DNS server during propagation check.
106#[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/// Record of a server that failed to respond during a propagation check.
116///
117/// Distinct from `inconsistencies` — unreachable servers returned no answer at
118/// all (timeout, network error, refused), whereas inconsistencies represent
119/// servers that successfully responded with an answer that differs from the
120/// consensus.
121#[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/// Aggregated result of DNS propagation check across multiple global servers.
133#[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    /// Servers that responded successfully but with an answer that differs
143    /// from the consensus. A non-empty value means the domain has genuinely
144    /// divergent answers in flight.
145    pub inconsistencies: Vec<String>,
146    /// Servers that could not be reached (timeouts, network errors, refusals).
147    /// These are NOT inconsistencies — they are missing data points.
148    #[serde(default)]
149    pub unreachable_servers: Vec<UnreachableServer>,
150    /// Whether the DNS responses in this result were DNSSEC-validated.
151    ///
152    /// Currently always `false`: Seer's resolver does not perform DNSSEC
153    /// validation, and UDP DNS responses are trivially spoofable. Callers
154    /// and formatters should surface this to avoid giving a false sense of
155    /// authenticity.
156    #[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    /// Returns true only when one or more servers returned an answer that
166    /// disagrees with the consensus. Servers that timed out or otherwise
167    /// failed to respond do NOT flip this to true — they are reported via
168    /// `unreachable_servers` / `has_unreachable_servers()` instead.
169    pub fn has_inconsistencies(&self) -> bool {
170        !self.inconsistencies.is_empty()
171    }
172
173    /// Returns true when one or more servers failed to respond.
174    pub fn has_unreachable_servers(&self) -> bool {
175        !self.unreachable_servers.is_empty()
176    }
177}
178
179/// Checks DNS propagation across multiple global DNS servers.
180#[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    /// Outer deadline for the entire propagation check across all servers.
216    /// Individual server queries have their own per-query timeout via the resolver;
217    /// this guards against the aggregate wall-clock time exceeding a safe limit.
218    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        // Calculate propagation and find consensus
249        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 validation is not currently performed by the resolver.
263            // This field exists so callers / formatters can disclose the
264            // lack of authentication to users.
265            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        // Verify regions are covered
330        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        // 28 agreeing servers + 1 unreachable server should NOT report an
379        // inconsistency — the unreachable server is a missing data point, not
380        // a conflicting answer.
381        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    // Collect unreachable servers up front so they are reported regardless of
549    // whether any server succeeded.
550    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    // Build sorted value sets once per server result
572    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    // Count occurrences of each value set
582    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    // Find the most common value set (consensus)
588    let Some((consensus_values, consensus_count)) =
589        value_counts.into_iter().max_by_key(|(_, count)| *count)
590    else {
591        // Should never happen since successful is non-empty, but handle gracefully
592        return (
593            0.0,
594            vec![],
595            vec!["No propagation data to analyze".to_string()],
596            unreachable_servers,
597        );
598    };
599
600    // Calculate propagation percentage based on ALL servers checked (not just
601    // responding ones) so unreachable servers count as non-propagated.
602    let propagation_percentage = (consensus_count as f64 / results.len() as f64) * 100.0;
603
604    // Find inconsistencies (reuse pre-computed sorted value sets)
605    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    // Note: failed/unreachable servers are NOT merged into `inconsistencies`.
627    // They are reported separately via the `unreachable_servers` return value
628    // so that `has_inconsistencies()` reflects only genuine answer conflicts.
629
630    // For record types where empty result is valid, adjust messaging
631    if consensus_values.is_empty()
632        && record_type != RecordType::A
633        && record_type != RecordType::AAAA
634    {
635        // No records is a valid state for optional record types
636    }
637
638    (
639        propagation_percentage,
640        consensus_values.clone(),
641        inconsistencies,
642        unreachable_servers,
643    )
644}