Skip to main content

seer_core/dns/
compare.rs

1use serde::{Deserialize, Serialize};
2use tracing::{debug, instrument};
3
4use crate::dns::{DnsRecord, DnsResolver, RecordType};
5use crate::error::Result;
6
7/// Result of querying DNS records from a single nameserver.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServerResult {
10    pub nameserver: String,
11    pub records: Vec<DnsRecord>,
12    pub error: Option<String>,
13}
14
15/// Comparison of DNS records between two nameservers.
16///
17/// Contains the records from each server, whether they match,
18/// and the set differences (only_in_a, only_in_b, common).
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DnsComparison {
21    pub domain: String,
22    pub record_type: RecordType,
23    pub server_a: ServerResult,
24    pub server_b: ServerResult,
25    pub matches: bool,
26    pub only_in_a: Vec<String>,
27    pub only_in_b: Vec<String>,
28    pub common: Vec<String>,
29}
30
31/// Compares DNS records for a domain across two nameservers.
32///
33/// Queries both servers concurrently and produces a structured
34/// comparison showing common records, differences, and errors.
35pub struct DnsComparator {
36    resolver: DnsResolver,
37}
38
39impl Default for DnsComparator {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl DnsComparator {
46    /// Creates a new DNS comparator with default resolver settings.
47    pub fn new() -> Self {
48        Self {
49            resolver: DnsResolver::new(),
50        }
51    }
52
53    /// Compares DNS records for a domain between two nameservers.
54    ///
55    /// # Arguments
56    /// * `domain` - The domain name to query
57    /// * `record_type` - The type of DNS record to compare (A, AAAA, MX, etc.)
58    /// * `server_a` - IP address of the first nameserver
59    /// * `server_b` - IP address of the second nameserver
60    ///
61    /// # Returns
62    /// A `DnsComparison` showing records from each server, whether they match,
63    /// and which records are unique to each server or shared.
64    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
65    pub async fn compare(
66        &self,
67        domain: &str,
68        record_type: RecordType,
69        server_a: &str,
70        server_b: &str,
71    ) -> Result<DnsComparison> {
72        let domain = crate::validation::normalize_domain(domain)?;
73
74        // Query both servers concurrently
75        let (result_a, result_b) = tokio::join!(
76            self.resolver.resolve(&domain, record_type, Some(server_a)),
77            self.resolver.resolve(&domain, record_type, Some(server_b))
78        );
79
80        let server_a_result = match result_a {
81            Ok(records) => ServerResult {
82                nameserver: server_a.to_string(),
83                records,
84                error: None,
85            },
86            Err(e) => {
87                debug!(server = %server_a, error = %e, "DNS compare query failed");
88                ServerResult {
89                    nameserver: server_a.to_string(),
90                    records: vec![],
91                    // Sanitized for external return; full detail logged above.
92                    error: Some(e.sanitized_message()),
93                }
94            }
95        };
96
97        let server_b_result = match result_b {
98            Ok(records) => ServerResult {
99                nameserver: server_b.to_string(),
100                records,
101                error: None,
102            },
103            Err(e) => {
104                debug!(server = %server_b, error = %e, "DNS compare query failed");
105                ServerResult {
106                    nameserver: server_b.to_string(),
107                    records: vec![],
108                    // Sanitized for external return; full detail logged above.
109                    error: Some(e.sanitized_message()),
110                }
111            }
112        };
113
114        // Compare record values using format_short for comparison
115        let values_a: std::collections::HashSet<String> = server_a_result
116            .records
117            .iter()
118            .map(|r| r.format_short())
119            .collect();
120        let values_b: std::collections::HashSet<String> = server_b_result
121            .records
122            .iter()
123            .map(|r| r.format_short())
124            .collect();
125
126        let mut only_in_a: Vec<String> = values_a.difference(&values_b).cloned().collect();
127        let mut only_in_b: Vec<String> = values_b.difference(&values_a).cloned().collect();
128        let mut common: Vec<String> = values_a.intersection(&values_b).cloned().collect();
129
130        // Sort for deterministic output
131        only_in_a.sort();
132        only_in_b.sort();
133        common.sort();
134
135        let matches = only_in_a.is_empty()
136            && only_in_b.is_empty()
137            && server_a_result.error.is_none()
138            && server_b_result.error.is_none();
139
140        debug!(
141            matches = matches,
142            common = common.len(),
143            only_in_a = only_in_a.len(),
144            only_in_b = only_in_b.len(),
145            "DNS comparison complete"
146        );
147
148        Ok(DnsComparison {
149            domain: domain.to_string(),
150            record_type,
151            server_a: server_a_result,
152            server_b: server_b_result,
153            matches,
154            only_in_a,
155            only_in_b,
156            common,
157        })
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::dns::{RecordData, RecordType};
165
166    #[test]
167    fn test_dns_comparison_serialization() {
168        let comparison = DnsComparison {
169            domain: "example.com".to_string(),
170            record_type: RecordType::A,
171            server_a: ServerResult {
172                nameserver: "8.8.8.8".to_string(),
173                records: vec![DnsRecord {
174                    name: "example.com".to_string(),
175                    record_type: RecordType::A,
176                    ttl: 300,
177                    data: RecordData::A {
178                        address: "93.184.216.34".to_string(),
179                    },
180                }],
181                error: None,
182            },
183            server_b: ServerResult {
184                nameserver: "1.1.1.1".to_string(),
185                records: vec![DnsRecord {
186                    name: "example.com".to_string(),
187                    record_type: RecordType::A,
188                    ttl: 300,
189                    data: RecordData::A {
190                        address: "93.184.216.34".to_string(),
191                    },
192                }],
193                error: None,
194            },
195            matches: true,
196            only_in_a: vec![],
197            only_in_b: vec![],
198            common: vec!["93.184.216.34".to_string()],
199        };
200
201        let json = serde_json::to_string(&comparison).unwrap();
202        assert!(json.contains("example.com"));
203        assert!(json.contains("93.184.216.34"));
204        assert!(json.contains("\"matches\":true"));
205    }
206
207    #[test]
208    fn test_server_result_with_error() {
209        let result = ServerResult {
210            nameserver: "8.8.8.8".to_string(),
211            records: vec![],
212            error: Some("connection timed out".to_string()),
213        };
214
215        let json = serde_json::to_string(&result).unwrap();
216        assert!(json.contains("connection timed out"));
217    }
218}