use std::time::Duration;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use super::propagation::read_reply;
use super::resolver::ServerReply;
use crate::dns::{DnsQueryResult, DnsRecord, DnsResolver, DnsStatus, RecordType};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerResult {
pub nameserver: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<DnsStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cname_chain: Vec<DnsRecord>,
pub records: Vec<DnsRecord>,
pub error: Option<String>,
}
impl ServerResult {
fn from_reply(nameserver: &str, record_type: RecordType, reply: Result<ServerReply>) -> Self {
let (cname_chain, (records, status, error)) = match reply {
Ok(ServerReply::Response(response)) => read_response(response, record_type),
Ok(ServerReply::Silent(reason)) => (vec![], (vec![], None, Some(reason))),
Err(e) => {
debug!(server = %nameserver, error = %e, "DNS compare query failed");
(vec![], (vec![], None, Some(e.sanitized_message())))
}
};
Self {
nameserver: nameserver.to_string(),
status,
cname_chain,
records,
error,
}
}
pub fn status_label(&self) -> Option<String> {
self.status.map(|status| match status {
DnsStatus::NoError if self.records.is_empty() => "NODATA".to_string(),
other => other.to_string(),
})
}
fn chain_keys(&self) -> Vec<(String, String)> {
self.cname_chain
.iter()
.map(|r| {
(
r.name.trim_end_matches('.').to_ascii_lowercase(),
r.data.comparison_key(),
)
})
.collect()
}
}
type Reading = (
Vec<DnsRecord>,
(Vec<DnsRecord>, Option<DnsStatus>, Option<String>),
);
fn read_response(response: DnsQueryResult, record_type: RecordType) -> Reading {
let chain = response.cname_chain().cloned().collect();
(chain, read_reply(response, record_type))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsComparison {
pub domain: String,
pub record_type: RecordType,
pub server_a: ServerResult,
pub server_b: ServerResult,
pub matches: bool,
pub only_in_a: Vec<String>,
pub only_in_b: Vec<String>,
pub common: Vec<String>,
}
impl DnsComparison {
pub fn status_matches(&self) -> bool {
self.server_a.status_label() == self.server_b.status_label()
}
pub fn chain_matches(&self) -> bool {
self.server_a.chain_keys() == self.server_b.chain_keys()
}
pub fn summary(&self) -> String {
if self.matches {
return "Records match".to_string();
}
if self.server_a.error.is_none() && self.server_b.error.is_none() {
if !self.status_matches() {
let label = |s: &ServerResult| s.status_label().unwrap_or_default();
return format!(
"Responses differ: {} vs {}",
label(&self.server_a),
label(&self.server_b)
);
}
if !self.chain_matches() {
return "CNAME chains differ".to_string();
}
}
"Records differ".to_string()
}
}
pub struct DnsComparator {
resolver: DnsResolver,
}
impl Default for DnsComparator {
fn default() -> Self {
Self::new()
}
}
impl DnsComparator {
pub fn new() -> Self {
Self {
resolver: DnsResolver::new(),
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.dns_timeout())
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.resolver = self.resolver.with_timeout(timeout);
self
}
#[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
pub async fn compare(
&self,
domain: &str,
record_type: RecordType,
server_a: &str,
server_b: &str,
) -> Result<DnsComparison> {
let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
crate::dns::resolver::wire_query_name(&domain, record_type)?;
let (reply_a, reply_b) = tokio::join!(
self.resolver.query_server(&domain, record_type, server_a),
self.resolver.query_server(&domain, record_type, server_b)
);
let server_a = ServerResult::from_reply(server_a, record_type, reply_a);
let server_b = ServerResult::from_reply(server_b, record_type, reply_b);
let (values_equal, only_in_a, only_in_b, mut common) =
compare_server_values(&server_a, &server_b);
common.sort();
let mut comparison = DnsComparison {
domain,
record_type,
server_a,
server_b,
matches: false,
only_in_a,
only_in_b,
common,
};
comparison.matches = values_equal
&& comparison.server_a.error.is_none()
&& comparison.server_b.error.is_none()
&& comparison.status_matches()
&& comparison.chain_matches();
debug!(
matches = comparison.matches,
common = comparison.common.len(),
only_in_a = comparison.only_in_a.len(),
only_in_b = comparison.only_in_b.len(),
"DNS comparison complete"
);
Ok(comparison)
}
}
fn compare_server_values(
a: &ServerResult,
b: &ServerResult,
) -> (bool, Vec<String>, Vec<String>, Vec<String>) {
use std::collections::HashSet;
let keys_a: HashSet<String> = a.records.iter().map(|r| r.data.comparison_key()).collect();
let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
let only_in = |records: &[DnsRecord], other: &HashSet<String>| {
let mut values: Vec<String> = records
.iter()
.filter(|r| !other.contains(&r.data.comparison_key()))
.map(|r| r.format_short())
.collect();
values.sort();
values.dedup();
values
};
let only_in_a = only_in(&a.records, &keys_b);
let only_in_b = only_in(&b.records, &keys_a);
let mut seen: HashSet<String> = HashSet::new();
let common = a
.records
.iter()
.filter(|r| {
let key = r.data.comparison_key();
keys_b.contains(&key) && seen.insert(key)
})
.map(|r| r.format_short())
.collect();
let values_equal = only_in_a.is_empty() && only_in_b.is_empty();
(values_equal, only_in_a, only_in_b, common)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dns::{RecordData, RecordType};
#[test]
fn test_dns_comparison_serialization() {
let comparison = DnsComparison {
domain: "example.com".to_string(),
record_type: RecordType::A,
server_a: ServerResult {
nameserver: "8.8.8.8".to_string(),
records: vec![DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "93.184.216.34".to_string(),
},
}],
error: None,
status: Some(DnsStatus::NoError),
cname_chain: vec![],
},
server_b: ServerResult {
nameserver: "1.1.1.1".to_string(),
records: vec![DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "93.184.216.34".to_string(),
},
}],
error: None,
status: Some(DnsStatus::NoError),
cname_chain: vec![],
},
matches: true,
only_in_a: vec![],
only_in_b: vec![],
common: vec!["93.184.216.34".to_string()],
};
let json = serde_json::to_string(&comparison).unwrap();
assert!(json.contains("example.com"));
assert!(json.contains("93.184.216.34"));
assert!(json.contains("\"matches\":true"));
}
#[test]
fn test_server_result_with_error() {
let result = ServerResult {
nameserver: "8.8.8.8".to_string(),
records: vec![],
error: Some("connection timed out".to_string()),
status: None,
cname_chain: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("connection timed out"));
}
#[test]
fn compare_values_case_insensitive_match() {
let upper = ServerResult {
nameserver: "8.8.8.8".to_string(),
records: vec![DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::NS,
ttl: 300,
data: RecordData::NS {
nameserver: "NS1.EXAMPLE.COM.".to_string(),
},
}],
error: None,
status: Some(DnsStatus::NoError),
cname_chain: vec![],
};
let lower = ServerResult {
nameserver: "1.1.1.1".to_string(),
records: vec![DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::NS,
ttl: 300,
data: RecordData::NS {
nameserver: "ns1.example.com.".to_string(),
},
}],
error: None,
status: Some(DnsStatus::NoError),
cname_chain: vec![],
};
let (matches, only_in_a, only_in_b, _) = compare_server_values(&upper, &lower);
assert!(matches, "case-only difference must be a match");
assert!(
only_in_a.is_empty(),
"no records unique to A: {only_in_a:?}"
);
assert!(
only_in_b.is_empty(),
"no records unique to B: {only_in_b:?}"
);
}
#[test]
fn compare_values_txt_case_difference_is_a_mismatch() {
let txt = |ns: &str, text: &str| ServerResult {
nameserver: ns.to_string(),
records: vec![DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::TXT,
ttl: 300,
data: RecordData::txt(vec![text.to_string()]),
}],
error: None,
status: Some(DnsStatus::NoError),
cname_chain: vec![],
};
let (matches, only_in_a, only_in_b, _) = compare_server_values(
&txt("8.8.8.8", "verify=AbCd"),
&txt("1.1.1.1", "verify=abcd"),
);
assert!(!matches);
assert_eq!(only_in_a, vec!["\"verify=AbCd\"".to_string()]);
assert_eq!(only_in_b, vec!["\"verify=abcd\"".to_string()]);
}
#[tokio::test]
async fn compare_uses_per_name_normalization() {
use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
let port = spawn_mock_dns(MockMode::Zone).await;
let comparator = DnsComparator {
resolver: mock_dns_resolver(port),
};
let cmp = comparator
.compare("www.seer.test", RecordType::CNAME, "127.0.0.1", "127.0.0.1")
.await
.expect("compare www");
assert_eq!(cmp.domain, "www.seer.test");
assert!(cmp.matches);
assert_eq!(cmp.common, vec!["edge.cdn.test.".to_string()]);
let cmp = comparator
.compare(
"2606:4700:4700::1111",
RecordType::PTR,
"127.0.0.1",
"127.0.0.1",
)
.await
.expect("IPv6 PTR literal must be accepted");
assert_eq!(cmp.domain, "2606:4700:4700::1111");
assert_eq!(cmp.common, vec!["one.one.one.one.".to_string()]);
}
fn two_server_comparator() -> DnsComparator {
DnsComparator {
resolver: DnsResolver::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts(),
}
}
#[tokio::test]
async fn nxdomain_and_nodata_do_not_match() {
use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
let nx = spawn_mock_dns_fn(|_, _| MockReply::NxDomainWithSoa("seer.test")).await;
let nodata = spawn_mock_dns_fn(|_, _| MockReply::NoDataWithSoa("seer.test")).await;
let cmp = two_server_comparator()
.compare(
"gone.seer.test",
RecordType::A,
&format!("127.0.0.1:{nx}"),
&format!("127.0.0.1:{nodata}"),
)
.await
.expect("compare");
assert_eq!(cmp.server_a.status, Some(DnsStatus::NxDomain));
assert_eq!(cmp.server_b.status, Some(DnsStatus::NoError));
assert!(cmp.server_a.error.is_none() && cmp.server_b.error.is_none());
assert!(!cmp.matches, "NXDOMAIN vs NODATA must differ");
assert_eq!(cmp.summary(), "Responses differ: NXDOMAIN vs NODATA");
let cmp = two_server_comparator()
.compare(
"gone.seer.test",
RecordType::A,
&format!("127.0.0.1:{nx}"),
&format!("127.0.0.1:{nx}"),
)
.await
.expect("compare");
assert!(cmp.matches);
assert_eq!(cmp.summary(), "Records match");
}
#[tokio::test]
async fn differing_cname_chains_do_not_match() {
use crate::dns::test_support::{
a_rdata, cname_rdata, record, spawn_mock_dns_fn, MockReply,
};
let via = |target: &'static str| {
move |_: &str, _| {
MockReply::Records(vec![
record("www.seer.test", 300, cname_rdata(target)),
record(target, 300, a_rdata([192, 0, 2, 1])),
])
}
};
let old = spawn_mock_dns_fn(via("old.cdn.test")).await;
let new = spawn_mock_dns_fn(via("new.cdn.test")).await;
let comparator = two_server_comparator();
let cmp = comparator
.compare(
"www.seer.test",
RecordType::A,
&format!("127.0.0.1:{old}"),
&format!("127.0.0.1:{new}"),
)
.await
.expect("compare");
assert_eq!(cmp.common, vec!["192.0.2.1".to_string()]);
assert_eq!(cmp.server_a.cname_chain.len(), 1);
assert!(!cmp.matches, "different chains must differ");
assert_eq!(cmp.summary(), "CNAME chains differ");
let cmp = comparator
.compare(
"www.seer.test",
RecordType::A,
&format!("127.0.0.1:{old}"),
&format!("127.0.0.1:{old}"),
)
.await
.expect("compare");
assert!(cmp.matches);
}
#[tokio::test]
async fn failures_carry_status_and_reason() {
use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
use hickory_resolver::proto::op::ResponseCode;
let servfail = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::ServFail)).await;
let silent = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
let cmp = two_server_comparator()
.compare(
"seer.test",
RecordType::A,
&format!("127.0.0.1:{servfail}"),
&format!("127.0.0.1:{silent}"),
)
.await
.expect("compare");
assert_eq!(cmp.server_a.status, Some(DnsStatus::ServFail));
assert_eq!(cmp.server_a.error.as_deref(), Some("SERVFAIL"));
assert_eq!(cmp.server_b.status, None);
assert_eq!(cmp.server_b.error.as_deref(), Some("timed out"));
assert!(!cmp.matches);
assert_eq!(cmp.summary(), "Records differ");
}
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
let comparator = DnsComparator::from_config(&config);
assert_eq!(comparator.resolver.timeout(), Duration::from_secs(9));
}
}