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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ServerResult {
20 pub nameserver: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub status: Option<DnsStatus>,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub cname_chain: Vec<DnsRecord>,
28 pub records: Vec<DnsRecord>,
30 pub error: Option<String>,
31}
32
33impl ServerResult {
34 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 (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 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 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
77type Reading = (
79 Vec<DnsRecord>,
80 (Vec<DnsRecord>, Option<DnsStatus>, Option<String>),
81);
82
83fn 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#[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 pub fn status_matches(&self) -> bool {
112 self.server_a.status_label() == self.server_b.status_label()
113 }
114
115 pub fn chain_matches(&self) -> bool {
117 self.server_a.chain_keys() == self.server_b.chain_keys()
118 }
119
120 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
145pub struct DnsComparator {
150 resolver: DnsResolver,
151}
152
153impl Default for DnsComparator {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159impl DnsComparator {
160 pub fn new() -> Self {
162 Self {
163 resolver: DnsResolver::new(),
164 }
165 }
166
167 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
171 Self::new().with_timeout(config.dns_timeout())
172 }
173
174 pub fn with_timeout(mut self, timeout: Duration) -> Self {
176 self.resolver = self.resolver.with_timeout(timeout);
177 self
178 }
179
180 #[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 let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
205 crate::dns::resolver::wire_query_name(&domain, record_type)?;
206
207 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 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
254fn 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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}