Skip to main content

seer_core/dns/
compare.rs

1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4use tracing::{debug, instrument};
5
6use super::propagation::read_reply;
7use super::resolver::ServerReply;
8use crate::dns::{DnsQueryResult, DnsRecord, DnsResolver, DnsStatus, RecordType};
9use crate::error::Result;
10
11/// One nameserver's answer in a [`DnsComparison`].
12///
13/// A server that answered NOERROR or NXDOMAIN has its `status`, its CNAME
14/// chain and the records of the compared type, and no `error`. SERVFAIL,
15/// REFUSED or another code, a referral (a server that is neither recursive
16/// nor authoritative for the name) or no response at all sets `error` to
17/// the reason; `status` is kept whenever the server responded.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ServerResult {
20    pub nameserver: String,
21    /// The response code the server sent; `None` when it sent no response.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub status: Option<DnsStatus>,
24    /// The CNAME records the answer led with, in chain order (owner →
25    /// target), under their real owner names.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub cname_chain: Vec<DnsRecord>,
28    /// The answers of the compared type (for ANY: every answer).
29    pub records: Vec<DnsRecord>,
30    pub error: Option<String>,
31}
32
33impl ServerResult {
34    /// Folds one server's reply into a result.
35    fn from_reply(nameserver: &str, record_type: RecordType, reply: Result<ServerReply>) -> Self {
36        let (cname_chain, (records, status, error)) = match reply {
37            Ok(ServerReply::Response(response)) => read_response(response, record_type),
38            Ok(ServerReply::Silent(reason)) => (vec![], (vec![], None, Some(reason))),
39            Err(e) => {
40                debug!(server = %nameserver, error = %e, "DNS compare query failed");
41                // Sanitized for external return; full detail logged above.
42                (vec![], (vec![], None, Some(e.sanitized_message())))
43            }
44        };
45        Self {
46            nameserver: nameserver.to_string(),
47            status,
48            cname_chain,
49            records,
50            error,
51        }
52    }
53
54    /// How this server's outcome reads: its response code, with an empty
55    /// NOERROR answer read as `NODATA`; `None` when it sent no response.
56    pub fn status_label(&self) -> Option<String> {
57        self.status.map(|status| match status {
58            DnsStatus::NoError if self.records.is_empty() => "NODATA".to_string(),
59            other => other.to_string(),
60        })
61    }
62
63    /// The chain as compared: each hop's owner and target, case-folded.
64    fn chain_keys(&self) -> Vec<(String, String)> {
65        self.cname_chain
66            .iter()
67            .map(|r| {
68                (
69                    r.name.trim_end_matches('.').to_ascii_lowercase(),
70                    r.data.comparison_key(),
71                )
72            })
73            .collect()
74    }
75}
76
77/// One server's reading: `(cname_chain, records, status, error)`.
78type Reading = (
79    Vec<DnsRecord>,
80    (Vec<DnsRecord>, Option<DnsStatus>, Option<String>),
81);
82
83/// Splits a response into its CNAME chain and the propagation reading of it
84/// ([`read_reply`]: records of the type, status, error).
85fn read_response(response: DnsQueryResult, record_type: RecordType) -> Reading {
86    let chain = response.cname_chain().cloned().collect();
87    (chain, read_reply(response, record_type))
88}
89
90/// Comparison of DNS responses between two nameservers.
91///
92/// Contains each server's answer, whether they match, and the set
93/// differences of the records (only_in_a, only_in_b, common). Two servers
94/// match only when both answered, with the same outcome (NXDOMAIN and
95/// NODATA are different answers), the same CNAME chain and the same records.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct DnsComparison {
98    pub domain: String,
99    pub record_type: RecordType,
100    pub server_a: ServerResult,
101    pub server_b: ServerResult,
102    pub matches: bool,
103    pub only_in_a: Vec<String>,
104    pub only_in_b: Vec<String>,
105    pub common: Vec<String>,
106}
107
108impl DnsComparison {
109    /// Whether both servers gave the same outcome
110    /// ([`ServerResult::status_label`]).
111    pub fn status_matches(&self) -> bool {
112        self.server_a.status_label() == self.server_b.status_label()
113    }
114
115    /// Whether both servers' CNAME chains name the same hops (case-folded).
116    pub fn chain_matches(&self) -> bool {
117        self.server_a.chain_keys() == self.server_b.chain_keys()
118    }
119
120    /// One line saying how the servers compare — the reading every renderer
121    /// shares: `Records match`, both outcomes when they differ
122    /// (`Responses differ: NXDOMAIN vs NODATA`), `CNAME chains differ`, or
123    /// `Records differ`.
124    pub fn summary(&self) -> String {
125        if self.matches {
126            return "Records match".to_string();
127        }
128        if self.server_a.error.is_none() && self.server_b.error.is_none() {
129            if !self.status_matches() {
130                let label = |s: &ServerResult| s.status_label().unwrap_or_default();
131                return format!(
132                    "Responses differ: {} vs {}",
133                    label(&self.server_a),
134                    label(&self.server_b)
135                );
136            }
137            if !self.chain_matches() {
138                return "CNAME chains differ".to_string();
139            }
140        }
141        "Records differ".to_string()
142    }
143}
144
145/// Compares DNS records for a domain across two nameservers.
146///
147/// Queries both servers concurrently — one direct query each, as dig sends
148/// it — and compares their response codes, CNAME chains and records.
149pub struct DnsComparator {
150    resolver: DnsResolver,
151}
152
153impl Default for DnsComparator {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl DnsComparator {
160    /// Creates a new DNS comparator with default resolver settings.
161    pub fn new() -> Self {
162        Self {
163            resolver: DnsResolver::new(),
164        }
165    }
166
167    /// Builds a comparator honoring `~/.seer/config.toml`
168    /// (`timeouts.dns_secs`). The two nameservers are always the caller's,
169    /// so the configured nameserver does not apply.
170    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
171        Self::new().with_timeout(config.dns_timeout())
172    }
173
174    /// Sets the per-query timeout.
175    pub fn with_timeout(mut self, timeout: Duration) -> Self {
176        self.resolver = self.resolver.with_timeout(timeout);
177        self
178    }
179
180    /// Compares DNS records for a domain between two nameservers.
181    ///
182    /// # Arguments
183    /// * `domain` - The domain name to query
184    /// * `record_type` - The type of DNS record to compare (A, AAAA, MX, etc.)
185    /// * `server_a` - The first nameserver (a
186    ///   [`NameserverSpec`](crate::dns::NameserverSpec))
187    /// * `server_b` - The second nameserver
188    ///
189    /// # Returns
190    /// A `DnsComparison` showing each server's answer, whether they match,
191    /// and which records are unique to each server or shared.
192    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
193    pub async fn compare(
194        &self,
195        domain: &str,
196        record_type: RecordType,
197        server_a: &str,
198        server_b: &str,
199    ) -> Result<DnsComparison> {
200        // The resolver's per-name normalization, so the stored name matches
201        // what was queried: `www.` is kept, and an IPv6 PTR literal is passed
202        // through instead of being mangled as `host:port`. A bad name (or a
203        // bare-domain SRV query) fails once, here, not as two server errors.
204        let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
205        crate::dns::resolver::wire_query_name(&domain, record_type)?;
206
207        // Direct queries, like propagation: each server's response as sent —
208        // status, chain and records — rather than `resolve`, which folds
209        // NXDOMAIN and NODATA into the same empty list and hides the chain.
210        let (reply_a, reply_b) = tokio::join!(
211            self.resolver.query_server(&domain, record_type, server_a),
212            self.resolver.query_server(&domain, record_type, server_b)
213        );
214
215        let server_a = ServerResult::from_reply(server_a, record_type, reply_a);
216        let server_b = ServerResult::from_reply(server_b, record_type, reply_b);
217
218        // Compare record values on their comparison key: domain-name fields
219        // case-insensitively (two servers returning `NS1.EXAMPLE.COM.` vs
220        // `ns1.example.com.` under 0x20 randomization agree), case-sensitive
221        // data (TXT, DNSKEY) verbatim. See `RecordData::comparison_key`.
222        let (values_equal, only_in_a, only_in_b, mut common) =
223            compare_server_values(&server_a, &server_b);
224        common.sort();
225
226        let mut comparison = DnsComparison {
227            domain,
228            record_type,
229            server_a,
230            server_b,
231            matches: false,
232            only_in_a,
233            only_in_b,
234            common,
235        };
236        comparison.matches = values_equal
237            && comparison.server_a.error.is_none()
238            && comparison.server_b.error.is_none()
239            && comparison.status_matches()
240            && comparison.chain_matches();
241
242        debug!(
243            matches = comparison.matches,
244            common = comparison.common.len(),
245            only_in_a = comparison.only_in_a.len(),
246            only_in_b = comparison.only_in_b.len(),
247            "DNS comparison complete"
248        );
249
250        Ok(comparison)
251    }
252}
253
254/// Compares the record sets of two servers, returning
255/// `(values_equal, only_in_a, only_in_b, common)`. Each entry is the
256/// original-cased `format_short()` value (for `common`, server A's) so
257/// display preserves what the server actually returned, but membership is
258/// decided on
259/// [`RecordData::comparison_key`](crate::dns::RecordData::comparison_key)
260/// (domain names case-folded, case-sensitive data verbatim).
261fn compare_server_values(
262    a: &ServerResult,
263    b: &ServerResult,
264) -> (bool, Vec<String>, Vec<String>, Vec<String>) {
265    use std::collections::HashSet;
266
267    let keys_a: HashSet<String> = a.records.iter().map(|r| r.data.comparison_key()).collect();
268    let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
269
270    let only_in = |records: &[DnsRecord], other: &HashSet<String>| {
271        let mut values: Vec<String> = records
272            .iter()
273            .filter(|r| !other.contains(&r.data.comparison_key()))
274            .map(|r| r.format_short())
275            .collect();
276        values.sort();
277        values.dedup();
278        values
279    };
280    let only_in_a = only_in(&a.records, &keys_b);
281    let only_in_b = only_in(&b.records, &keys_a);
282
283    let mut seen: HashSet<String> = HashSet::new();
284    let common = a
285        .records
286        .iter()
287        .filter(|r| {
288            let key = r.data.comparison_key();
289            keys_b.contains(&key) && seen.insert(key)
290        })
291        .map(|r| r.format_short())
292        .collect();
293
294    let values_equal = only_in_a.is_empty() && only_in_b.is_empty();
295    (values_equal, only_in_a, only_in_b, common)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::dns::{RecordData, RecordType};
302
303    #[test]
304    fn test_dns_comparison_serialization() {
305        let comparison = DnsComparison {
306            domain: "example.com".to_string(),
307            record_type: RecordType::A,
308            server_a: ServerResult {
309                nameserver: "8.8.8.8".to_string(),
310                records: vec![DnsRecord {
311                    name: "example.com".to_string(),
312                    record_type: RecordType::A,
313                    ttl: 300,
314                    data: RecordData::A {
315                        address: "93.184.216.34".to_string(),
316                    },
317                }],
318                error: None,
319                status: Some(DnsStatus::NoError),
320                cname_chain: vec![],
321            },
322            server_b: ServerResult {
323                nameserver: "1.1.1.1".to_string(),
324                records: vec![DnsRecord {
325                    name: "example.com".to_string(),
326                    record_type: RecordType::A,
327                    ttl: 300,
328                    data: RecordData::A {
329                        address: "93.184.216.34".to_string(),
330                    },
331                }],
332                error: None,
333                status: Some(DnsStatus::NoError),
334                cname_chain: vec![],
335            },
336            matches: true,
337            only_in_a: vec![],
338            only_in_b: vec![],
339            common: vec!["93.184.216.34".to_string()],
340        };
341
342        let json = serde_json::to_string(&comparison).unwrap();
343        assert!(json.contains("example.com"));
344        assert!(json.contains("93.184.216.34"));
345        assert!(json.contains("\"matches\":true"));
346    }
347
348    #[test]
349    fn test_server_result_with_error() {
350        let result = ServerResult {
351            nameserver: "8.8.8.8".to_string(),
352            records: vec![],
353            error: Some("connection timed out".to_string()),
354            status: None,
355            cname_chain: vec![],
356        };
357
358        let json = serde_json::to_string(&result).unwrap();
359        assert!(json.contains("connection timed out"));
360    }
361
362    /// Two servers returning the same NS record with different casing (common
363    /// with 0x20 query-name randomization) must be treated as a match, not a
364    /// mismatch. The comparison key is case-folded so `NS1.EXAMPLE.COM.` and
365    /// `ns1.example.com.` are equal.
366    #[test]
367    fn compare_values_case_insensitive_match() {
368        let upper = ServerResult {
369            nameserver: "8.8.8.8".to_string(),
370            records: vec![DnsRecord {
371                name: "example.com".to_string(),
372                record_type: RecordType::NS,
373                ttl: 300,
374                data: RecordData::NS {
375                    nameserver: "NS1.EXAMPLE.COM.".to_string(),
376                },
377            }],
378            error: None,
379            status: Some(DnsStatus::NoError),
380            cname_chain: vec![],
381        };
382        let lower = ServerResult {
383            nameserver: "1.1.1.1".to_string(),
384            records: vec![DnsRecord {
385                name: "example.com".to_string(),
386                record_type: RecordType::NS,
387                ttl: 300,
388                data: RecordData::NS {
389                    nameserver: "ns1.example.com.".to_string(),
390                },
391            }],
392            error: None,
393            status: Some(DnsStatus::NoError),
394            cname_chain: vec![],
395        };
396
397        let (matches, only_in_a, only_in_b, _) = compare_server_values(&upper, &lower);
398        assert!(matches, "case-only difference must be a match");
399        assert!(
400            only_in_a.is_empty(),
401            "no records unique to A: {only_in_a:?}"
402        );
403        assert!(
404            only_in_b.is_empty(),
405            "no records unique to B: {only_in_b:?}"
406        );
407    }
408
409    /// TXT data is case-sensitive: a verification token that differs only in
410    /// case between two servers is a real disagreement, not a 0x20 artefact.
411    /// (Case was previously folded for every record type.)
412    #[test]
413    fn compare_values_txt_case_difference_is_a_mismatch() {
414        let txt = |ns: &str, text: &str| ServerResult {
415            nameserver: ns.to_string(),
416            records: vec![DnsRecord {
417                name: "example.com".to_string(),
418                record_type: RecordType::TXT,
419                ttl: 300,
420                data: RecordData::txt(vec![text.to_string()]),
421            }],
422            error: None,
423            status: Some(DnsStatus::NoError),
424            cname_chain: vec![],
425        };
426        let (matches, only_in_a, only_in_b, _) = compare_server_values(
427            &txt("8.8.8.8", "verify=AbCd"),
428            &txt("1.1.1.1", "verify=abcd"),
429        );
430        assert!(!matches);
431        assert_eq!(only_in_a, vec!["\"verify=AbCd\"".to_string()]);
432        assert_eq!(only_in_b, vec!["\"verify=abcd\"".to_string()]);
433    }
434
435    /// End to end against the mock fixture: `compare` must keep `www.` and
436    /// accept an IPv6 PTR literal, like `resolve` does. It previously ran
437    /// `normalize_domain`, which stripped `www.` (querying the apex) and
438    /// mangled `2606:4700:4700::1111` as `host:port`.
439    #[tokio::test]
440    async fn compare_uses_per_name_normalization() {
441        use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
442
443        let port = spawn_mock_dns(MockMode::Zone).await;
444        let comparator = DnsComparator {
445            resolver: mock_dns_resolver(port),
446        };
447
448        let cmp = comparator
449            .compare("www.seer.test", RecordType::CNAME, "127.0.0.1", "127.0.0.1")
450            .await
451            .expect("compare www");
452        assert_eq!(cmp.domain, "www.seer.test");
453        assert!(cmp.matches);
454        assert_eq!(cmp.common, vec!["edge.cdn.test.".to_string()]);
455
456        let cmp = comparator
457            .compare(
458                "2606:4700:4700::1111",
459                RecordType::PTR,
460                "127.0.0.1",
461                "127.0.0.1",
462            )
463            .await
464            .expect("IPv6 PTR literal must be accepted");
465        assert_eq!(cmp.domain, "2606:4700:4700::1111");
466        assert_eq!(cmp.common, vec!["one.one.one.one.".to_string()]);
467    }
468
469    /// A comparator whose two servers are separate loopback mocks (the port
470    /// rides in each spec, so no port override).
471    fn two_server_comparator() -> DnsComparator {
472        DnsComparator {
473            resolver: DnsResolver::new()
474                .with_timeout(Duration::from_millis(500))
475                .allowing_private_hosts(),
476        }
477    }
478
479    /// Regression: `compare` went through `resolve`, which folds NXDOMAIN
480    /// and NODATA into the same empty list, so a server saying the name does
481    /// not exist "matched" one saying it exists without records.
482    #[tokio::test]
483    async fn nxdomain_and_nodata_do_not_match() {
484        use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
485
486        let nx = spawn_mock_dns_fn(|_, _| MockReply::NxDomainWithSoa("seer.test")).await;
487        let nodata = spawn_mock_dns_fn(|_, _| MockReply::NoDataWithSoa("seer.test")).await;
488        let cmp = two_server_comparator()
489            .compare(
490                "gone.seer.test",
491                RecordType::A,
492                &format!("127.0.0.1:{nx}"),
493                &format!("127.0.0.1:{nodata}"),
494            )
495            .await
496            .expect("compare");
497        assert_eq!(cmp.server_a.status, Some(DnsStatus::NxDomain));
498        assert_eq!(cmp.server_b.status, Some(DnsStatus::NoError));
499        assert!(cmp.server_a.error.is_none() && cmp.server_b.error.is_none());
500        assert!(!cmp.matches, "NXDOMAIN vs NODATA must differ");
501        assert_eq!(cmp.summary(), "Responses differ: NXDOMAIN vs NODATA");
502
503        // The same negative answer on both sides is a match.
504        let cmp = two_server_comparator()
505            .compare(
506                "gone.seer.test",
507                RecordType::A,
508                &format!("127.0.0.1:{nx}"),
509                &format!("127.0.0.1:{nx}"),
510            )
511            .await
512            .expect("compare");
513        assert!(cmp.matches);
514        assert_eq!(cmp.summary(), "Records match");
515    }
516
517    /// Regression: the resolver chased CNAMEs and returned only the final
518    /// records, so two servers pointing a name at different targets that
519    /// happen to share an address "matched".
520    #[tokio::test]
521    async fn differing_cname_chains_do_not_match() {
522        use crate::dns::test_support::{
523            a_rdata, cname_rdata, record, spawn_mock_dns_fn, MockReply,
524        };
525
526        let via = |target: &'static str| {
527            move |_: &str, _| {
528                MockReply::Records(vec![
529                    record("www.seer.test", 300, cname_rdata(target)),
530                    record(target, 300, a_rdata([192, 0, 2, 1])),
531                ])
532            }
533        };
534        let old = spawn_mock_dns_fn(via("old.cdn.test")).await;
535        let new = spawn_mock_dns_fn(via("new.cdn.test")).await;
536        let comparator = two_server_comparator();
537        let cmp = comparator
538            .compare(
539                "www.seer.test",
540                RecordType::A,
541                &format!("127.0.0.1:{old}"),
542                &format!("127.0.0.1:{new}"),
543            )
544            .await
545            .expect("compare");
546        assert_eq!(cmp.common, vec!["192.0.2.1".to_string()]);
547        assert_eq!(cmp.server_a.cname_chain.len(), 1);
548        assert!(!cmp.matches, "different chains must differ");
549        assert_eq!(cmp.summary(), "CNAME chains differ");
550
551        let cmp = comparator
552            .compare(
553                "www.seer.test",
554                RecordType::A,
555                &format!("127.0.0.1:{old}"),
556                &format!("127.0.0.1:{old}"),
557            )
558            .await
559            .expect("compare");
560        assert!(cmp.matches);
561    }
562
563    /// SERVFAIL is kept as the status and reported as the error, and a
564    /// silent server's reason is its error; neither matches.
565    #[tokio::test]
566    async fn failures_carry_status_and_reason() {
567        use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
568        use hickory_resolver::proto::op::ResponseCode;
569
570        let servfail = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::ServFail)).await;
571        let silent = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
572        let cmp = two_server_comparator()
573            .compare(
574                "seer.test",
575                RecordType::A,
576                &format!("127.0.0.1:{servfail}"),
577                &format!("127.0.0.1:{silent}"),
578            )
579            .await
580            .expect("compare");
581        assert_eq!(cmp.server_a.status, Some(DnsStatus::ServFail));
582        assert_eq!(cmp.server_a.error.as_deref(), Some("SERVFAIL"));
583        assert_eq!(cmp.server_b.status, None);
584        assert_eq!(cmp.server_b.error.as_deref(), Some("timed out"));
585        assert!(!cmp.matches);
586        assert_eq!(cmp.summary(), "Records differ");
587    }
588
589    #[test]
590    fn from_config_applies_dns_timeout() {
591        let mut config = crate::config::SeerConfig::default();
592        config.timeouts.dns_secs = 9;
593        let comparator = DnsComparator::from_config(&config);
594        assert_eq!(comparator.resolver.timeout(), Duration::from_secs(9));
595    }
596}