use std::collections::BTreeSet;
use std::net::IpAddr;
use std::time::Duration;
use hickory_resolver::config::{NameServerConfig, ResolveHosts, ResolverConfig, GOOGLE};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::op::ResponseCode;
use hickory_resolver::proto::rr::{RData as HickoryRData, Record, RecordType as HickoryRecordType};
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use crate::error::{Result, SeerError};
use crate::validation::normalize_domain;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_NS_PROBED: usize = 6;
const MAX_PARENT_SERVERS_TRIED: usize = 4;
const PARENT_SERVERS_WANTED: usize = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LameNs {
pub host: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationReport {
pub domain: String,
pub parent_zone: String,
pub parent_server_queried: Vec<String>,
pub delegated_ns: Vec<String>,
pub zone_ns: Vec<String>,
pub in_sync: bool,
pub missing_from_zone: Vec<String>,
pub missing_from_parent: Vec<String>,
pub lame: Vec<LameNs>,
pub warnings: Vec<String>,
}
#[derive(Debug)]
enum DirectNs {
Response {
authoritative: bool,
response_code: ResponseCode,
answer_ns: Vec<String>,
authority_ns: Vec<String>,
},
Rcode(ResponseCode),
Unreachable(String),
LocalNoRoute(String),
}
#[derive(Debug)]
enum ProbeOutcome {
Healthy(Vec<String>),
Lame(String),
Skipped(String),
}
pub struct DelegationChecker {
timeout: Duration,
recursive: TokioResolver,
#[cfg(test)]
recursive_upstream: Option<(IpAddr, u16)>,
#[cfg(test)]
port_map: Option<std::collections::HashMap<String, u16>>,
#[cfg(test)]
allow_private_hosts: bool,
}
impl std::fmt::Debug for DelegationChecker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DelegationChecker")
.field("timeout", &self.timeout)
.finish()
}
}
impl Default for DelegationChecker {
fn default() -> Self {
Self::new()
}
}
impl DelegationChecker {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
recursive: build_recursive_resolver(DEFAULT_TIMEOUT, None),
#[cfg(test)]
recursive_upstream: None,
#[cfg(test)]
port_map: None,
#[cfg(test)]
allow_private_hosts: false,
}
}
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.timeout = timeout;
self.recursive = build_recursive_resolver(timeout, self.recursive_upstream());
self
}
#[cfg(test)]
fn with_recursive_upstream(mut self, ip: IpAddr, port: u16) -> Self {
self.recursive_upstream = Some((ip, port));
self.recursive = build_recursive_resolver(self.timeout, self.recursive_upstream);
self
}
#[cfg(test)]
fn with_port_map(mut self, map: std::collections::HashMap<String, u16>) -> Self {
self.port_map = Some(map);
self
}
#[cfg(test)]
fn allowing_private_hosts(mut self) -> Self {
self.allow_private_hosts = true;
self
}
#[cfg(test)]
fn recursive_upstream(&self) -> Option<(IpAddr, u16)> {
self.recursive_upstream
}
#[cfg(not(test))]
fn recursive_upstream(&self) -> Option<(IpAddr, u16)> {
None
}
#[cfg(test)]
fn allow_private(&self) -> bool {
self.allow_private_hosts
}
#[cfg(not(test))]
fn allow_private(&self) -> bool {
false
}
#[cfg(test)]
fn direct_port(&self, host: &str) -> u16 {
self.port_map
.as_ref()
.and_then(|map| map.get(&normalize_ns_name(host)).copied())
.unwrap_or(53)
}
#[cfg(not(test))]
fn direct_port(&self, _host: &str) -> u16 {
53
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<DelegationReport> {
let domain = normalize_domain(domain)?;
let parent_zone = parent_zone_of(&domain);
let mut warnings = Vec::new();
let parent_hosts = self.recursive_ns_set(&parent_zone).await?;
if parent_hosts.is_empty() {
return Err(SeerError::DnsError(format!(
"parent zone {} has no NS records — cannot locate the delegation for {}",
parent_zone, domain
)));
}
let mut parent_server_queried = Vec::new();
let mut delegated: BTreeSet<String> = BTreeSet::new();
for host in parent_hosts.iter().take(MAX_PARENT_SERVERS_TRIED) {
if parent_server_queried.len() >= PARENT_SERVERS_WANTED {
break;
}
let ip = match self.resolve_host_ip(host).await {
Ok(ip) => ip,
Err(reason) => {
warnings.push(format!("skipped parent server {}: {}", host, reason));
continue;
}
};
debug!(server = %host, %ip, "querying parent server for delegation");
match self.direct_ns_query(host, ip, &domain).await {
DirectNs::Response {
response_code,
answer_ns,
authority_ns,
..
} => {
if response_code == ResponseCode::NXDomain {
warnings.push(format!(
"parent server {} returned NXDOMAIN for {}",
host, domain
));
}
delegated.extend(answer_ns);
delegated.extend(authority_ns);
parent_server_queried.push(host.clone());
}
DirectNs::Rcode(code) => {
warnings.push(format!("parent server {} answered with {}", host, code));
}
DirectNs::Unreachable(reason) => {
warnings.push(format!(
"parent server {} did not respond: {}",
host, reason
));
}
DirectNs::LocalNoRoute(reason) => {
warnings.push(format!(
"parent server {} not probed — no route from this host ({})",
host, reason
));
}
}
}
if parent_server_queried.is_empty() {
return Err(SeerError::DnsError(format!(
"no parent-zone ({}) nameserver answered the delegation query for {}",
parent_zone, domain
)));
}
if delegated.is_empty() {
warnings.push(format!(
"parent zone {} returned no NS delegation for {} — the domain may not be delegated",
parent_zone, domain
));
}
let probe_hosts: Vec<String> = delegated.iter().take(MAX_NS_PROBED).cloned().collect();
if delegated.len() > MAX_NS_PROBED {
warnings.push(format!(
"probing only {} of {} delegated nameservers",
MAX_NS_PROBED,
delegated.len()
));
}
let outcomes = futures::future::join_all(
probe_hosts
.iter()
.map(|host| self.probe_delegated_ns(host, &domain)),
)
.await;
let mut zone_ns: BTreeSet<String> = BTreeSet::new();
let mut lame = Vec::new();
for (host, outcome) in probe_hosts.iter().zip(outcomes) {
match outcome {
ProbeOutcome::Healthy(ns) => zone_ns.extend(ns),
ProbeOutcome::Lame(reason) => lame.push(LameNs {
host: host.clone(),
reason,
}),
ProbeOutcome::Skipped(reason) => {
warnings.push(format!("could not probe {}: {}", host, reason));
}
}
}
let (missing_from_zone, missing_from_parent) = if zone_ns.is_empty() {
if !delegated.is_empty() {
warnings.push(
"no delegated nameserver returned an authoritative NS answer — \
parent/zone set comparison skipped"
.to_string(),
);
}
(Vec::new(), Vec::new())
} else {
(
delegated.difference(&zone_ns).cloned().collect(),
zone_ns.difference(&delegated).cloned().collect(),
)
};
let in_sync = !delegated.is_empty()
&& !zone_ns.is_empty()
&& missing_from_zone.is_empty()
&& missing_from_parent.is_empty()
&& lame.is_empty();
lame.sort_by(|a, b| a.host.cmp(&b.host));
Ok(DelegationReport {
domain,
parent_zone,
parent_server_queried,
delegated_ns: delegated.into_iter().collect(),
zone_ns: zone_ns.into_iter().collect(),
in_sync,
missing_from_zone,
missing_from_parent,
lame,
warnings,
})
}
async fn recursive_ns_set(&self, zone: &str) -> Result<Vec<String>> {
match self
.recursive
.lookup(fqdn(zone), HickoryRecordType::NS)
.await
{
Ok(lookup) => {
let set: BTreeSet<String> =
ns_targets_for(lookup.answers(), zone).into_iter().collect();
Ok(set.into_iter().collect())
}
Err(e) if e.is_no_records_found() => Ok(Vec::new()),
Err(e) => Err(SeerError::DnsError(format!(
"NS lookup for parent zone {} failed: {}",
zone, e
))),
}
}
async fn resolve_host_ip(&self, host: &str) -> std::result::Result<IpAddr, String> {
let response = self
.recursive
.lookup_ip(fqdn(host))
.await
.map_err(|e| format!("could not resolve {}: {}", host, e))?;
let ips: Vec<IpAddr> = response.iter().collect();
if ips.is_empty() {
return Err(format!("{} did not resolve to any address", host));
}
let mut blocked_reason = None;
let mut vetted = Vec::new();
for ip in ips {
if !self.allow_private() {
if let Some(reason) = crate::validation::describe_reserved_ip(&ip) {
blocked_reason.get_or_insert_with(|| {
format!("{} resolves to a blocked address ({})", host, reason)
});
continue;
}
}
vetted.push(ip);
}
prefer_ipv4(&vetted).ok_or_else(|| {
blocked_reason
.unwrap_or_else(|| format!("{} did not resolve to a usable address", host))
})
}
async fn direct_ns_query(&self, host: &str, ip: IpAddr, domain: &str) -> DirectNs {
let resolver = match self.build_direct_resolver(host, ip) {
Ok(resolver) => resolver,
Err(e) => return DirectNs::Unreachable(format!("resolver construction failed: {}", e)),
};
match resolver.lookup(fqdn(domain), HickoryRecordType::NS).await {
Ok(lookup) => {
let message = lookup.message();
DirectNs::Response {
authoritative: message.metadata.authoritative,
response_code: message.metadata.response_code,
answer_ns: ns_targets_for(&message.answers, domain),
authority_ns: ns_targets_for(&message.authorities, domain),
}
}
Err(e) => classify_net_error(e, domain),
}
}
fn build_direct_resolver(&self, host: &str, ip: IpAddr) -> Result<TokioResolver> {
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
let mut ns = NameServerConfig::udp_and_tcp(ip);
let port = self.direct_port(host);
for connection in &mut ns.connections {
connection.port = port;
}
config.add_name_server(ns);
let mut builder =
TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
{
let opts = builder.options_mut();
opts.timeout = self.timeout;
opts.attempts = 2;
opts.use_hosts_file = ResolveHosts::Never;
opts.recursion_desired = false;
}
builder
.build()
.map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
}
async fn probe_delegated_ns(&self, host: &str, domain: &str) -> ProbeOutcome {
let ip = match self.resolve_host_ip(host).await {
Ok(ip) => ip,
Err(reason) => return ProbeOutcome::Skipped(reason),
};
debug!(server = %host, %ip, "probing delegated nameserver");
outcome_for_direct(self.direct_ns_query(host, ip, domain).await)
}
}
fn outcome_for_direct(direct: DirectNs) -> ProbeOutcome {
match direct {
DirectNs::Response {
response_code: ResponseCode::NXDomain,
..
} => ProbeOutcome::Lame("returned NXDOMAIN for its own zone".to_string()),
DirectNs::Response {
authoritative: true,
answer_ns,
..
} if !answer_ns.is_empty() => ProbeOutcome::Healthy(answer_ns),
DirectNs::Response {
authoritative: true,
..
} => ProbeOutcome::Lame("authoritative answer contained no NS records".to_string()),
DirectNs::Response { answer_ns, .. } if !answer_ns.is_empty() => {
ProbeOutcome::Lame("answered non-authoritatively (AA bit not set)".to_string())
}
DirectNs::Response { authority_ns, .. } if !authority_ns.is_empty() => {
ProbeOutcome::Lame("not authoritative — referred the query elsewhere".to_string())
}
DirectNs::Response { .. } => {
ProbeOutcome::Lame("returned an empty answer (no NS records)".to_string())
}
DirectNs::Rcode(code) => ProbeOutcome::Lame(format!("refused the query ({})", code)),
DirectNs::Unreachable(reason) => {
ProbeOutcome::Lame(format!("no usable response: {}", reason))
}
DirectNs::LocalNoRoute(reason) => ProbeOutcome::Skipped(format!(
"no route from this host ({}) — server not probed (e.g. an IPv6-only nameserver on an IPv4-only host)",
reason
)),
}
}
fn build_recursive_resolver(timeout: Duration, upstream: Option<(IpAddr, u16)>) -> TokioResolver {
let config = match upstream {
None => ResolverConfig::udp_and_tcp(&GOOGLE),
Some((ip, port)) => {
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
let mut ns = NameServerConfig::udp(ip);
for connection in &mut ns.connections {
connection.port = port;
}
config.add_name_server(ns);
config
}
};
let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
{
let opts = builder.options_mut();
opts.timeout = timeout;
opts.attempts = 2;
opts.use_hosts_file = ResolveHosts::Never;
}
builder
.build()
.expect("plain UDP/TCP resolver build cannot fail with the bundled webpki root store")
}
fn classify_net_error(err: NetError, domain: &str) -> DirectNs {
match err {
NetError::Dns(DnsError::NoRecordsFound(no_records)) => DirectNs::Response {
authoritative: false,
response_code: no_records.response_code,
answer_ns: Vec::new(),
authority_ns: no_records
.authorities
.as_deref()
.map(|records| ns_targets_for(records, domain))
.unwrap_or_default(),
},
NetError::Dns(DnsError::ResponseCode(code)) => DirectNs::Rcode(code),
NetError::Timeout => DirectNs::Unreachable("timed out".to_string()),
NetError::NoConnections => DirectNs::LocalNoRoute(
"every connection attempt failed at the local socket layer".to_string(),
),
NetError::Io(io_err) if is_local_no_route(&io_err) => {
DirectNs::LocalNoRoute(io_err.to_string())
}
other => DirectNs::Unreachable(other.to_string()),
}
}
fn is_local_no_route(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::NetworkUnreachable | std::io::ErrorKind::HostUnreachable
)
}
fn parent_zone_of(domain: &str) -> String {
domain
.split_once('.')
.map(|(_, rest)| rest.to_string())
.unwrap_or_else(|| domain.to_string())
}
fn prefer_ipv4(vetted: &[IpAddr]) -> Option<IpAddr> {
vetted
.iter()
.find(|ip| ip.is_ipv4())
.or_else(|| vetted.first())
.copied()
}
fn fqdn(name: &str) -> String {
if name.ends_with('.') {
name.to_string()
} else {
format!("{}.", name)
}
}
fn normalize_ns_name(name: &str) -> String {
name.trim_end_matches('.').to_ascii_lowercase()
}
fn ns_targets_for(records: &[Record], owner: &str) -> Vec<String> {
let owner = normalize_ns_name(owner);
records
.iter()
.filter_map(|record| {
if normalize_ns_name(&record.name.to_string()) != owner {
return None;
}
match &record.data {
HickoryRData::NS(ns) => Some(normalize_ns_name(&ns.0.to_string())),
_ => None,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::Arc;
use hickory_resolver::proto::op::{Message, OpCode};
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::Name;
use tokio::net::UdpSocket;
use super::*;
use crate::dns::test_support::{spawn_mock_dns, MockMode};
#[test]
fn parent_zone_strips_leftmost_label() {
assert_eq!(parent_zone_of("example.com"), "com");
assert_eq!(parent_zone_of("sub.example.co.uk"), "example.co.uk");
assert_eq!(parent_zone_of("example.co.uk"), "co.uk");
}
#[test]
fn ns_name_normalization_is_case_and_dot_insensitive() {
assert_eq!(normalize_ns_name("NS1.Example.COM."), "ns1.example.com");
assert_eq!(normalize_ns_name("ns1.example.com"), "ns1.example.com");
}
#[test]
fn probe_ip_selection_prefers_ipv4_over_first_listed() {
let v6: IpAddr = "2001:503:a83e::2:30".parse().unwrap();
let v4: IpAddr = "192.5.6.30".parse().unwrap();
assert_eq!(prefer_ipv4(&[v6, v4]), Some(v4));
assert_eq!(prefer_ipv4(&[v4, v6]), Some(v4));
assert_eq!(prefer_ipv4(&[v6]), Some(v6));
assert_eq!(prefer_ipv4(&[]), None);
}
#[test]
fn fqdn_appends_root_dot_once() {
assert_eq!(fqdn("example.com"), "example.com.");
assert_eq!(fqdn("example.com."), "example.com.");
}
#[test]
fn local_no_route_transport_errors_classify_as_local() {
use std::io;
for kind in [
io::ErrorKind::NetworkUnreachable,
io::ErrorKind::HostUnreachable,
] {
let direct = classify_net_error(io::Error::new(kind, "no route").into(), "example.com");
assert!(
matches!(direct, DirectNs::LocalNoRoute(_)),
"{kind:?} classified as {direct:?}"
);
}
let pooled = classify_net_error(NetError::NoConnections, "example.com");
assert!(
matches!(pooled, DirectNs::LocalNoRoute(_)),
"NoConnections classified as {pooled:?}"
);
let refused = classify_net_error(
io::Error::new(io::ErrorKind::ConnectionRefused, "refused").into(),
"example.com",
);
assert!(
matches!(refused, DirectNs::Unreachable(_)),
"refused classified as {refused:?}"
);
}
#[test]
fn local_no_route_probe_outcome_is_skipped_not_lame() {
let outcome =
outcome_for_direct(DirectNs::LocalNoRoute("Network is unreachable".to_string()));
assert!(
matches!(outcome, ProbeOutcome::Skipped(_)),
"got {outcome:?}"
);
}
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
let checker = DelegationChecker::from_config(&config);
assert_eq!(checker.timeout, Duration::from_secs(9));
}
#[derive(Clone)]
enum MockReply {
Answers {
records: Vec<HickoryRData>,
authoritative: bool,
},
Referral(Vec<HickoryRData>),
NoData,
Refused,
}
type Handler = Arc<dyn Fn(&str, HickoryRecordType) -> MockReply + Send + Sync>;
async fn spawn_mock(handler: Handler) -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").await.expect("bind mock DNS");
let port = socket.local_addr().expect("mock DNS local addr").port();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
let Ok((len, src)) = socket.recv_from(&mut buf).await else {
return;
};
let Ok(request) = Message::from_vec(&buf[..len]) else {
continue;
};
let Some(query) = request.queries.first().cloned() else {
continue;
};
let mut response = Message::response(request.metadata.id, OpCode::Query);
response.metadata.recursion_desired = request.metadata.recursion_desired;
response.metadata.recursion_available = true;
response.add_query(query.clone());
let qname = query.name.to_string().to_ascii_lowercase();
match handler(qname.trim_end_matches('.'), query.query_type) {
MockReply::Answers {
records,
authoritative,
} => {
response.metadata.authoritative = authoritative;
for rdata in records {
response.add_answer(Record::from_rdata(query.name.clone(), 300, rdata));
}
}
MockReply::Referral(records) => {
for rdata in records {
response.add_authority(Record::from_rdata(
query.name.clone(),
300,
rdata,
));
}
}
MockReply::NoData => {}
MockReply::Refused => {
response.metadata.response_code = ResponseCode::Refused;
}
}
let Ok(bytes) = response.to_vec() else {
continue;
};
let _ = socket.send_to(&bytes, src).await;
}
});
port
}
fn ns_rdata(target: &str) -> HickoryRData {
HickoryRData::NS(wire::NS(
Name::from_ascii(target).expect("valid test NS name"),
))
}
fn loopback_a() -> HickoryRData {
HickoryRData::A(wire::A(Ipv4Addr::LOCALHOST))
}
async fn spawn_recursive_mock(resolvable: &[&str]) -> u16 {
let resolvable: Vec<String> = resolvable.iter().map(|s| s.to_string()).collect();
spawn_mock(Arc::new(move |qname, qtype| {
match (qname, qtype) {
("test", HickoryRecordType::NS) => MockReply::Answers {
records: vec![ns_rdata("a.parent.test.")],
authoritative: false,
},
(host, HickoryRecordType::A)
if host == "a.parent.test" || resolvable.iter().any(|r| r == host) =>
{
MockReply::Answers {
records: vec![loopback_a()],
authoritative: false,
}
}
_ => MockReply::NoData,
}
}))
.await
}
async fn spawn_zone_server(rrset: &[&str]) -> u16 {
let records: Vec<HickoryRData> = rrset.iter().map(|ns| ns_rdata(ns)).collect();
spawn_mock(Arc::new(move |_qname, qtype| {
if qtype == HickoryRecordType::NS {
MockReply::Answers {
records: records.clone(),
authoritative: true,
}
} else {
MockReply::NoData
}
}))
.await
}
async fn spawn_parent_server(referral: &[&str]) -> u16 {
let records: Vec<HickoryRData> = referral.iter().map(|ns| ns_rdata(ns)).collect();
spawn_mock(Arc::new(move |qname, qtype| {
if qname == "seer.test" && qtype == HickoryRecordType::NS {
MockReply::Referral(records.clone())
} else {
MockReply::NoData
}
}))
.await
}
fn checker(recursive_port: u16, port_map: HashMap<String, u16>) -> DelegationChecker {
DelegationChecker::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts()
.with_recursive_upstream(Ipv4Addr::LOCALHOST.into(), recursive_port)
.with_port_map(port_map)
}
#[tokio::test]
async fn healthy_in_sync_delegation() {
let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns2 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
("ns2.seer.test".to_string(), ns2),
]),
)
.check("seer.test")
.await
.expect("healthy delegation check must succeed");
assert_eq!(report.domain, "seer.test");
assert_eq!(report.parent_zone, "test");
assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
assert_eq!(report.delegated_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
assert!(report.in_sync, "report should be in sync: {report:?}");
assert!(report.missing_from_zone.is_empty());
assert!(report.missing_from_parent.is_empty());
assert!(report.lame.is_empty());
}
#[tokio::test]
async fn parent_lists_ns_the_zone_omits() {
let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns3.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns3.seer.test."]).await;
let zone = spawn_zone_server(&["ns1.seer.test."]).await;
let zone2 = spawn_zone_server(&["ns1.seer.test."]).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), zone),
("ns3.seer.test".to_string(), zone2),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert_eq!(report.delegated_ns, vec!["ns1.seer.test", "ns3.seer.test"]);
assert_eq!(report.zone_ns, vec!["ns1.seer.test"]);
assert_eq!(report.missing_from_zone, vec!["ns3.seer.test"]);
assert!(report.missing_from_parent.is_empty());
assert!(!report.in_sync);
assert!(report.lame.is_empty(), "set mismatch is not lameness");
}
#[tokio::test]
async fn zone_lists_ns_the_parent_omits() {
let recursive = spawn_recursive_mock(&["ns1.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test."]).await;
let zone = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), zone),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert_eq!(report.delegated_ns, vec!["ns1.seer.test"]);
assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
assert_eq!(report.missing_from_parent, vec!["ns2.seer.test"]);
assert!(report.missing_from_zone.is_empty());
assert!(!report.in_sync);
}
#[tokio::test]
async fn refusing_server_is_lame() {
let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns2 = spawn_mock(Arc::new(|_, _| MockReply::Refused)).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
("ns2.seer.test".to_string(), ns2),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert!(report.missing_from_zone.is_empty());
assert!(report.missing_from_parent.is_empty());
assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
assert_eq!(report.lame[0].host, "ns2.seer.test");
assert!(
report.lame[0].reason.to_lowercase().contains("refused"),
"reason should mention the refusal: {}",
report.lame[0].reason
);
assert!(!report.in_sync);
}
#[tokio::test]
async fn non_authoritative_answer_is_lame() {
let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns2 = spawn_mock(Arc::new(|_, qtype| {
if qtype == HickoryRecordType::NS {
MockReply::Answers {
records: vec![ns_rdata("ns1.seer.test."), ns_rdata("ns2.seer.test.")],
authoritative: false,
}
} else {
MockReply::NoData
}
}))
.await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
("ns2.seer.test".to_string(), ns2),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
assert_eq!(report.lame[0].host, "ns2.seer.test");
assert!(
report.lame[0].reason.contains("non-authoritatively"),
"reason: {}",
report.lame[0].reason
);
assert!(!report.in_sync);
assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
}
#[tokio::test]
async fn unresponsive_server_is_lame_with_timeout_reason() {
let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns2 = spawn_mock_dns(MockMode::Ignore).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
("ns2.seer.test".to_string(), ns2),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
assert_eq!(report.lame[0].host, "ns2.seer.test");
assert!(
report.lame[0].reason.contains("no usable response"),
"reason: {}",
report.lame[0].reason
);
assert!(!report.in_sync);
}
#[tokio::test]
async fn unresolvable_glue_is_warned_and_skipped_not_lame() {
let recursive = spawn_recursive_mock(&["ns1.seer.test"]).await;
let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
]),
)
.check("seer.test")
.await
.expect("check must succeed");
assert!(report.lame.is_empty(), "skip must not be lame: {report:?}");
assert!(
report.warnings.iter().any(|w| w.contains("ns2.seer.test")),
"warnings should mention the skipped host: {:?}",
report.warnings
);
assert!(report.in_sync, "sets match and nothing is lame: {report:?}");
}
#[tokio::test]
async fn no_responsive_parent_server_is_an_error() {
let recursive = spawn_recursive_mock(&[]).await;
let parent = spawn_mock_dns(MockMode::Ignore).await;
let err = checker(
recursive,
HashMap::from([("a.parent.test".to_string(), parent)]),
)
.check("seer.test")
.await
.expect_err("no parent answer must be an error");
assert!(
matches!(err, SeerError::DnsError(_)),
"expected DnsError, got: {err:?}"
);
assert!(err.to_string().contains("parent"), "got: {err}");
}
}