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
15impl ServerResult {
16    /// Folds one server's query outcome into a result.
17    fn from_query(nameserver: &str, result: Result<Vec<DnsRecord>>) -> Self {
18        let (records, error) = match result {
19            Ok(records) => (records, None),
20            Err(e) => {
21                debug!(server = %nameserver, error = %e, "DNS compare query failed");
22                // Sanitized for external return; full detail logged above.
23                (vec![], Some(e.sanitized_message()))
24            }
25        };
26        Self {
27            nameserver: nameserver.to_string(),
28            records,
29            error,
30        }
31    }
32}
33
34/// Comparison of DNS records between two nameservers.
35///
36/// Contains the records from each server, whether they match,
37/// and the set differences (only_in_a, only_in_b, common).
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DnsComparison {
40    pub domain: String,
41    pub record_type: RecordType,
42    pub server_a: ServerResult,
43    pub server_b: ServerResult,
44    pub matches: bool,
45    pub only_in_a: Vec<String>,
46    pub only_in_b: Vec<String>,
47    pub common: Vec<String>,
48}
49
50/// Compares DNS records for a domain across two nameservers.
51///
52/// Queries both servers concurrently and produces a structured
53/// comparison showing common records, differences, and errors.
54pub struct DnsComparator {
55    resolver: DnsResolver,
56}
57
58impl Default for DnsComparator {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl DnsComparator {
65    /// Creates a new DNS comparator with default resolver settings.
66    pub fn new() -> Self {
67        Self {
68            resolver: DnsResolver::new(),
69        }
70    }
71
72    /// Compares DNS records for a domain between two nameservers.
73    ///
74    /// # Arguments
75    /// * `domain` - The domain name to query
76    /// * `record_type` - The type of DNS record to compare (A, AAAA, MX, etc.)
77    /// * `server_a` - IP address of the first nameserver
78    /// * `server_b` - IP address of the second nameserver
79    ///
80    /// # Returns
81    /// A `DnsComparison` showing records from each server, whether they match,
82    /// and which records are unique to each server or shared.
83    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
84    pub async fn compare(
85        &self,
86        domain: &str,
87        record_type: RecordType,
88        server_a: &str,
89        server_b: &str,
90    ) -> Result<DnsComparison> {
91        // Same per-name normalization `resolve` applies, so the stored name
92        // matches what was queried: `www.` is kept, and an IPv6 PTR literal
93        // is passed through instead of being mangled as `host:port`.
94        let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
95
96        // Query both servers concurrently
97        let (result_a, result_b) = tokio::join!(
98            self.resolver.resolve(&domain, record_type, Some(server_a)),
99            self.resolver.resolve(&domain, record_type, Some(server_b))
100        );
101
102        let server_a_result = ServerResult::from_query(server_a, result_a);
103        let server_b_result = ServerResult::from_query(server_b, result_b);
104
105        // Compare record values on their comparison key: domain-name fields
106        // case-insensitively (two servers returning `NS1.EXAMPLE.COM.` vs
107        // `ns1.example.com.` under 0x20 randomization agree), case-sensitive
108        // data (TXT, DNSKEY) verbatim. See `RecordData::comparison_key`.
109        let (values_equal, only_in_a, only_in_b) =
110            compare_server_values(&server_a_result, &server_b_result);
111
112        // `common` is informational; build it on the same key but emit the
113        // original-cased values from server A for display.
114        let mut common = common_values(&server_a_result, &server_b_result);
115        common.sort();
116
117        let matches =
118            values_equal && server_a_result.error.is_none() && server_b_result.error.is_none();
119
120        debug!(
121            matches = matches,
122            common = common.len(),
123            only_in_a = only_in_a.len(),
124            only_in_b = only_in_b.len(),
125            "DNS comparison complete"
126        );
127
128        Ok(DnsComparison {
129            domain: domain.to_string(),
130            record_type,
131            server_a: server_a_result,
132            server_b: server_b_result,
133            matches,
134            only_in_a,
135            only_in_b,
136            common,
137        })
138    }
139}
140
141/// Compares the record sets of two servers, returning
142/// `(values_equal, only_in_a, only_in_b)`. Each `only_in_*` entry is the
143/// original-cased `format_short()` value so display preserves what the server
144/// actually returned, but membership is decided on
145/// [`RecordData::comparison_key`](crate::dns::RecordData::comparison_key)
146/// (domain names case-folded, case-sensitive data verbatim).
147fn compare_server_values(a: &ServerResult, b: &ServerResult) -> (bool, Vec<String>, Vec<String>) {
148    use std::collections::HashSet;
149
150    let keys_a: HashSet<String> = a.records.iter().map(|r| r.data.comparison_key()).collect();
151    let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
152
153    let mut only_in_a: Vec<String> = a
154        .records
155        .iter()
156        .filter(|r| !keys_b.contains(&r.data.comparison_key()))
157        .map(|r| r.format_short())
158        .collect();
159    let mut only_in_b: Vec<String> = b
160        .records
161        .iter()
162        .filter(|r| !keys_a.contains(&r.data.comparison_key()))
163        .map(|r| r.format_short())
164        .collect();
165    only_in_a.sort();
166    only_in_a.dedup();
167    only_in_b.sort();
168    only_in_b.dedup();
169
170    let values_equal = only_in_a.is_empty() && only_in_b.is_empty();
171    (values_equal, only_in_a, only_in_b)
172}
173
174/// Returns the values present (by comparison key) on both servers, emitting
175/// server A's original casing for each shared value.
176fn common_values(a: &ServerResult, b: &ServerResult) -> Vec<String> {
177    use std::collections::HashSet;
178    let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
179    let mut seen: HashSet<String> = HashSet::new();
180    let mut common = Vec::new();
181    for r in &a.records {
182        let key = r.data.comparison_key();
183        if keys_b.contains(&key) && seen.insert(key) {
184            common.push(r.format_short());
185        }
186    }
187    common
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::dns::{RecordData, RecordType};
194
195    #[test]
196    fn test_dns_comparison_serialization() {
197        let comparison = DnsComparison {
198            domain: "example.com".to_string(),
199            record_type: RecordType::A,
200            server_a: ServerResult {
201                nameserver: "8.8.8.8".to_string(),
202                records: vec![DnsRecord {
203                    name: "example.com".to_string(),
204                    record_type: RecordType::A,
205                    ttl: 300,
206                    data: RecordData::A {
207                        address: "93.184.216.34".to_string(),
208                    },
209                }],
210                error: None,
211            },
212            server_b: ServerResult {
213                nameserver: "1.1.1.1".to_string(),
214                records: vec![DnsRecord {
215                    name: "example.com".to_string(),
216                    record_type: RecordType::A,
217                    ttl: 300,
218                    data: RecordData::A {
219                        address: "93.184.216.34".to_string(),
220                    },
221                }],
222                error: None,
223            },
224            matches: true,
225            only_in_a: vec![],
226            only_in_b: vec![],
227            common: vec!["93.184.216.34".to_string()],
228        };
229
230        let json = serde_json::to_string(&comparison).unwrap();
231        assert!(json.contains("example.com"));
232        assert!(json.contains("93.184.216.34"));
233        assert!(json.contains("\"matches\":true"));
234    }
235
236    #[test]
237    fn test_server_result_with_error() {
238        let result = ServerResult {
239            nameserver: "8.8.8.8".to_string(),
240            records: vec![],
241            error: Some("connection timed out".to_string()),
242        };
243
244        let json = serde_json::to_string(&result).unwrap();
245        assert!(json.contains("connection timed out"));
246    }
247
248    /// Two servers returning the same NS record with different casing (common
249    /// with 0x20 query-name randomization) must be treated as a match, not a
250    /// mismatch. The comparison key is case-folded so `NS1.EXAMPLE.COM.` and
251    /// `ns1.example.com.` are equal.
252    #[test]
253    fn compare_values_case_insensitive_match() {
254        let upper = ServerResult {
255            nameserver: "8.8.8.8".to_string(),
256            records: vec![DnsRecord {
257                name: "example.com".to_string(),
258                record_type: RecordType::NS,
259                ttl: 300,
260                data: RecordData::NS {
261                    nameserver: "NS1.EXAMPLE.COM.".to_string(),
262                },
263            }],
264            error: None,
265        };
266        let lower = ServerResult {
267            nameserver: "1.1.1.1".to_string(),
268            records: vec![DnsRecord {
269                name: "example.com".to_string(),
270                record_type: RecordType::NS,
271                ttl: 300,
272                data: RecordData::NS {
273                    nameserver: "ns1.example.com.".to_string(),
274                },
275            }],
276            error: None,
277        };
278
279        let (matches, only_in_a, only_in_b) = compare_server_values(&upper, &lower);
280        assert!(matches, "case-only difference must be a match");
281        assert!(
282            only_in_a.is_empty(),
283            "no records unique to A: {only_in_a:?}"
284        );
285        assert!(
286            only_in_b.is_empty(),
287            "no records unique to B: {only_in_b:?}"
288        );
289    }
290
291    /// TXT data is case-sensitive: a verification token that differs only in
292    /// case between two servers is a real disagreement, not a 0x20 artefact.
293    /// (Case was previously folded for every record type.)
294    #[test]
295    fn compare_values_txt_case_difference_is_a_mismatch() {
296        let txt = |ns: &str, text: &str| ServerResult {
297            nameserver: ns.to_string(),
298            records: vec![DnsRecord {
299                name: "example.com".to_string(),
300                record_type: RecordType::TXT,
301                ttl: 300,
302                data: RecordData::TXT {
303                    text: text.to_string(),
304                },
305            }],
306            error: None,
307        };
308        let (matches, only_in_a, only_in_b) = compare_server_values(
309            &txt("8.8.8.8", "verify=AbCd"),
310            &txt("1.1.1.1", "verify=abcd"),
311        );
312        assert!(!matches);
313        assert_eq!(only_in_a, vec!["\"verify=AbCd\"".to_string()]);
314        assert_eq!(only_in_b, vec!["\"verify=abcd\"".to_string()]);
315    }
316
317    /// End to end against the mock fixture: `compare` must keep `www.` and
318    /// accept an IPv6 PTR literal, like `resolve` does. It previously ran
319    /// `normalize_domain`, which stripped `www.` (querying the apex) and
320    /// mangled `2606:4700:4700::1111` as `host:port`.
321    #[tokio::test]
322    async fn compare_uses_per_name_normalization() {
323        use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
324
325        let port = spawn_mock_dns(MockMode::Zone).await;
326        let comparator = DnsComparator {
327            resolver: mock_dns_resolver(port),
328        };
329
330        let cmp = comparator
331            .compare("www.seer.test", RecordType::CNAME, "127.0.0.1", "127.0.0.1")
332            .await
333            .expect("compare www");
334        assert_eq!(cmp.domain, "www.seer.test");
335        assert!(cmp.matches);
336        assert_eq!(cmp.common, vec!["edge.cdn.test.".to_string()]);
337
338        let cmp = comparator
339            .compare(
340                "2606:4700:4700::1111",
341                RecordType::PTR,
342                "127.0.0.1",
343                "127.0.0.1",
344            )
345            .await
346            .expect("IPv6 PTR literal must be accepted");
347        assert_eq!(cmp.domain, "2606:4700:4700::1111");
348        assert_eq!(cmp.common, vec!["one.one.one.one.".to_string()]);
349    }
350}