use std::collections::BTreeSet;
use std::net::IpAddr;
use std::time::Duration;
use hickory_resolver::config::ResolverOpts;
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 super::resolver::{
apply_standard_opts, build_plain_resolver, fqdn, google_or_pinned, single_server_config,
};
use super::DEFAULT_DNS_TIMEOUT;
use crate::error::{Result, SeerError};
use crate::validation::normalize_domain;
const CHECK_BUDGET_TIMEOUTS: u32 = 2 + 3 * PARENT_SERVERS_WANTED as u32 + 3;
const DELEGATED_NS_BUDGET_TIMEOUTS: u32 = 2;
const MAX_NS_PROBED: usize = 6;
const MAX_PARENT_SERVERS_TRIED: usize = 4;
const PARENT_SERVERS_WANTED: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LameKind {
Nxdomain,
NoNsRecords,
NotAuthoritative,
Referral,
EmptyAnswer,
ErrorRcode,
Unresponsive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LameNs {
pub host: String,
pub kind: LameKind,
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, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsNameservers {
pub delegated: Vec<String>,
pub in_sync: bool,
pub missing_from_whois: Vec<String>,
pub missing_from_dns: Vec<String>,
}
impl DnsNameservers {
pub fn compare(whois: &[String], delegated: Vec<String>) -> Self {
let norm = |n: &String| n.trim_end_matches('.').to_ascii_lowercase();
let whois: BTreeSet<String> = whois.iter().map(norm).collect();
let delegated: BTreeSet<String> = delegated.iter().map(norm).collect();
let missing_from_whois: Vec<String> = delegated.difference(&whois).cloned().collect();
let missing_from_dns: Vec<String> = whois.difference(&delegated).cloned().collect();
Self {
in_sync: missing_from_whois.is_empty() && missing_from_dns.is_empty(),
delegated: delegated.into_iter().collect(),
missing_from_whois,
missing_from_dns,
}
}
pub fn whois_lists_none(&self) -> bool {
!self.delegated.is_empty()
&& self.missing_from_dns.is_empty()
&& self.missing_from_whois.len() == self.delegated.len()
}
}
struct ParentDelegation {
servers_queried: Vec<String>,
delegated: BTreeSet<String>,
warnings: Vec<String>,
}
#[derive(Debug)]
enum DirectNs {
Response {
authoritative: bool,
response_code: ResponseCode,
answer_ns: Vec<String>,
authority_ns: Vec<String>,
soa_zone: Option<String>,
},
Rcode(ResponseCode),
Unreachable(String),
LocalNoRoute(String),
}
#[derive(Debug)]
enum ProbeOutcome {
Healthy(Vec<String>),
Lame(LameKind, String),
Skipped(String),
}
#[derive(Clone)]
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,
#[cfg(test)]
unroutable: Vec<IpAddr>,
#[cfg(test)]
budget: Option<Duration>,
}
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_DNS_TIMEOUT,
recursive: build_recursive_resolver(DEFAULT_DNS_TIMEOUT, None),
#[cfg(test)]
recursive_upstream: None,
#[cfg(test)]
port_map: None,
#[cfg(test)]
allow_private_hosts: false,
#[cfg(test)]
unroutable: Vec::new(),
#[cfg(test)]
budget: None,
}
}
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 with_unroutable(mut self, addrs: &[IpAddr]) -> Self {
self.unroutable.extend_from_slice(addrs);
self
}
#[cfg(test)]
fn with_budget(mut self, budget: Duration) -> Self {
self.budget = Some(budget);
self
}
async fn parent_delegation(
&self,
domain: &str,
parent_zone: &str,
deadline: tokio::time::Instant,
budget: Duration,
) -> Result<ParentDelegation> {
let mut warnings = Vec::new();
let out_of_time = || {
SeerError::DnsError(format!(
"delegation check for {domain} gave up after {budget:?} without a parent-zone answer"
))
};
let parent_hosts = tokio::time::timeout_at(deadline, self.recursive_ns_set(parent_zone))
.await
.map_err(|_| out_of_time())??;
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 Ok(asked) = tokio::time::timeout_at(deadline, self.ask_host(host, domain)).await
else {
warnings.push(format!(
"stopped asking parent servers: the check's {budget:?} budget ran out"
));
break;
};
let direct = match asked {
Ok(direct) => direct,
Err(reason) => {
warnings.push(format!("skipped parent server {}: {}", host, reason));
continue;
}
};
match parent_answer(direct, domain) {
ParentAnswer::Delegation(ns) => {
delegated.extend(ns);
parent_server_queried.push(host.clone());
}
ParentAnswer::Negative(code) => {
if code == ResponseCode::NXDomain {
warnings.push(format!(
"parent server {} returned NXDOMAIN for {}",
host, domain
));
}
parent_server_queried.push(host.clone());
}
ParentAnswer::Lame(reason) => {
warnings.push(format!("parent server {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
));
}
Ok(ParentDelegation {
servers_queried: parent_server_queried,
delegated,
warnings,
})
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn delegated_ns(&self, domain: &str) -> Result<Vec<String>> {
let domain = normalize_domain(domain)?;
let parent_zone = parent_zone_of(&domain);
let budget = self.timeout.saturating_mul(DELEGATED_NS_BUDGET_TIMEOUTS);
let deadline = tokio::time::Instant::now() + budget;
let delegation = self
.parent_delegation(&domain, &parent_zone, deadline, budget)
.await?;
Ok(delegation.delegated.into_iter().collect())
}
#[cfg(not(test))]
fn budget(&self) -> Duration {
self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS)
}
#[cfg(test)]
fn budget(&self) -> Duration {
self.budget
.unwrap_or_else(|| self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS))
}
#[cfg(test)]
fn simulated_no_route(&self, ip: IpAddr) -> bool {
self.unroutable.contains(&ip)
}
#[cfg(not(test))]
fn simulated_no_route(&self, _ip: IpAddr) -> bool {
false
}
#[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 budget = self.budget();
let deadline = tokio::time::Instant::now() + budget;
let ParentDelegation {
servers_queried: parent_server_queried,
delegated,
mut warnings,
} = self
.parent_delegation(&domain, &parent_zone, deadline, budget)
.await?;
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| async {
tokio::time::timeout_at(deadline, self.probe_delegated_ns(host, &domain))
.await
.unwrap_or_else(|_| {
ProbeOutcome::Skipped(format!(
"not probed within the check's {budget:?} budget"
))
})
}))
.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(kind, reason) => lame.push(LameNs {
host: host.clone(),
kind,
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_ips(&self, host: &str) -> std::result::Result<Vec<IpAddr>, String> {
let response = tokio::time::timeout(self.timeout, self.recursive.lookup_ip(fqdn(host)))
.await
.unwrap_or(Err(NetError::Timeout))
.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 (vetted, refused) = partition_reserved(&ips, self.allow_private());
if vetted.is_empty() {
return Err(match refused.first() {
Some((_, reason)) => format!("{} resolves to a blocked address ({})", host, reason),
None => format!("{} did not resolve to a usable address", host),
});
}
let mut vetted = vetted;
crate::net::ipv4_first(&mut vetted, |ip| *ip);
Ok(vetted)
}
async fn ask_host(&self, host: &str, domain: &str) -> std::result::Result<DirectNs, String> {
let mut last = None;
for ip in self.resolve_host_ips(host).await? {
debug!(server = %host, %ip, "direct NS query");
match self.direct_ns_query(host, ip, domain).await {
DirectNs::LocalNoRoute(reason) => {
last = Some(DirectNs::LocalNoRoute(format!("{ip}: {reason}")));
}
direct => return Ok(direct),
}
}
Ok(last.unwrap_or_else(|| DirectNs::Unreachable("no address to query".to_string())))
}
async fn direct_ns_query(&self, host: &str, ip: IpAddr, domain: &str) -> DirectNs {
if self.simulated_no_route(ip) {
return DirectNs::LocalNoRoute("network is unreachable (simulated)".to_string());
}
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),
soa_zone: soa_zone_in(&message.authorities),
}
}
Err(e) => classify_net_error(e, domain),
}
}
fn build_direct_resolver(&self, host: &str, ip: IpAddr) -> Result<TokioResolver> {
let config = single_server_config(ip, self.direct_port(host), true);
let mut builder =
TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
apply_direct_opts(builder.options_mut(), self.timeout);
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 {
match self.ask_host(host, domain).await {
Ok(direct) => outcome_for_direct(direct),
Err(reason) => ProbeOutcome::Skipped(reason),
}
}
}
fn outcome_for_direct(direct: DirectNs) -> ProbeOutcome {
match direct {
DirectNs::Response {
response_code: ResponseCode::NXDomain,
..
} => ProbeOutcome::Lame(
LameKind::Nxdomain,
"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(
LameKind::NoNsRecords,
"authoritative answer contained no NS records".to_string(),
),
DirectNs::Response { answer_ns, .. } if !answer_ns.is_empty() => ProbeOutcome::Lame(
LameKind::NotAuthoritative,
"answered non-authoritatively (AA bit not set)".to_string(),
),
DirectNs::Response { authority_ns, .. } if !authority_ns.is_empty() => ProbeOutcome::Lame(
LameKind::Referral,
"not authoritative — referred the query elsewhere".to_string(),
),
DirectNs::Response { .. } => ProbeOutcome::Lame(
LameKind::EmptyAnswer,
"returned an empty answer (no NS records)".to_string(),
),
DirectNs::Rcode(code) => ProbeOutcome::Lame(
LameKind::ErrorRcode,
format!("refused the query ({})", code),
),
DirectNs::Unreachable(reason) => ProbeOutcome::Lame(
LameKind::Unresponsive,
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 apply_direct_opts(opts: &mut ResolverOpts, timeout: Duration) {
apply_standard_opts(opts, timeout);
opts.recursion_desired = false;
}
pub(crate) fn build_recursive_resolver(
timeout: Duration,
upstream: Option<(IpAddr, u16)>,
) -> TokioResolver {
build_plain_resolver(google_or_pinned(upstream), timeout)
}
fn classify_net_error(err: NetError, domain: &str) -> DirectNs {
match err {
NetError::Dns(DnsError::NoRecordsFound(no_records)) => {
let authorities = no_records.authorities.as_deref().unwrap_or_default();
DirectNs::Response {
authoritative: false,
response_code: no_records.response_code,
answer_ns: Vec::new(),
authority_ns: ns_targets_for(authorities, domain),
soa_zone: no_records
.soa
.as_ref()
.map(|soa| normalize_ns_name(&soa.name.to_ascii()))
.or_else(|| soa_zone_in(authorities)),
}
}
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()),
}
}
pub(crate) fn is_local_no_route(err: &std::io::Error) -> bool {
use std::io::ErrorKind::{HostUnreachable, NetworkUnreachable};
matches!(err.kind(), NetworkUnreachable | HostUnreachable)
|| err
.raw_os_error()
.is_some_and(|code| Some(code) == ADDRESS_FAMILY_UNSUPPORTED)
}
#[cfg(unix)]
pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> = Some(libc::EAFNOSUPPORT);
#[cfg(windows)]
pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> =
Some(windows_sys::Win32::Networking::WinSock::WSAEAFNOSUPPORT);
#[cfg(not(any(unix, windows)))]
pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> = None;
fn parent_zone_of(domain: &str) -> String {
domain
.split_once('.')
.map(|(_, rest)| rest.to_string())
.unwrap_or_else(|| domain.to_string())
}
pub(crate) fn partition_reserved(
ips: &[IpAddr],
allow_private: bool,
) -> (Vec<IpAddr>, Vec<(IpAddr, &'static str)>) {
let mut usable = Vec::new();
let mut refused = Vec::new();
for &ip in ips {
match crate::validation::describe_reserved_ip(&ip) {
Some(reason) if !allow_private => refused.push((ip, reason)),
_ => usable.push(ip),
}
}
(usable, refused)
}
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_ascii()) != owner {
return None;
}
match &record.data {
HickoryRData::NS(ns) => Some(normalize_ns_name(&ns.0.to_ascii())),
_ => None,
}
})
.collect()
}
fn soa_zone_in(records: &[Record]) -> Option<String> {
records
.iter()
.find(|record| matches!(record.data, HickoryRData::SOA(_)))
.map(|record| normalize_ns_name(&record.name.to_ascii()))
}
#[derive(Debug, PartialEq)]
enum ParentAnswer {
Delegation(Vec<String>),
Negative(ResponseCode),
Lame(String),
}
fn parent_answer(direct: DirectNs, domain: &str) -> ParentAnswer {
match direct {
DirectNs::Response {
answer_ns,
authority_ns,
..
} if !answer_ns.is_empty() || !authority_ns.is_empty() => {
ParentAnswer::Delegation(answer_ns.into_iter().chain(authority_ns).collect())
}
DirectNs::Response {
response_code,
soa_zone: Some(zone),
..
} if zone_contains(&zone, domain) => ParentAnswer::Negative(response_code),
DirectNs::Response { response_code, .. } => ParentAnswer::Lame(format!(
"gave no delegation for {domain} ({response_code} without the zone's SOA)"
)),
DirectNs::Rcode(code) => ParentAnswer::Lame(format!("answered with {code}")),
DirectNs::Unreachable(reason) => ParentAnswer::Lame(format!("did not respond: {reason}")),
DirectNs::LocalNoRoute(reason) => {
ParentAnswer::Lame(format!("not probed — no route from this host ({reason})"))
}
}
}
fn zone_contains(zone: &str, name: &str) -> bool {
zone.is_empty()
|| name == zone
|| name
.strip_suffix(zone)
.is_some_and(|rest| rest.ends_with('.'))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::net::Ipv4Addr;
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::Name;
use super::*;
use crate::dns::test_support::{spawn_mock_dns, spawn_mock_dns_fn, MockMode, MockReply};
#[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 direct_opts_share_the_standard_set_and_disable_recursion() {
let mut opts = ResolverOpts::default();
apply_direct_opts(&mut opts, Duration::from_secs(7));
assert_eq!(
opts.server_ordering_strategy,
hickory_resolver::config::ServerOrderingStrategy::UserProvidedOrder
);
assert_eq!(opts.timeout, Duration::from_secs(7));
assert!(!opts.recursion_desired);
}
#[test]
fn ns_targets_match_idn_owners_in_punycode() {
let owner = Name::from_ascii("xn--mnchen-3ya.de.").unwrap();
let records = vec![
Record::from_rdata(
owner.clone(),
300,
HickoryRData::NS(wire::NS(
Name::from_ascii("ns1.xn--mnchen-3ya.de.").unwrap(),
)),
),
Record::from_rdata(
owner,
300,
HickoryRData::NS(wire::NS(Name::from_ascii("NS2.Example.NET.").unwrap())),
),
];
let mut targets = ns_targets_for(&records, "xn--mnchen-3ya.de");
targets.sort();
assert_eq!(
targets,
vec![
"ns1.xn--mnchen-3ya.de".to_string(),
"ns2.example.net".to_string()
]
);
let tld = vec![Record::from_rdata(
Name::from_ascii("xn--p1ai.").unwrap(),
300,
HickoryRData::NS(wire::NS(Name::from_ascii("a.dns.ripn.net.").unwrap())),
)];
assert_eq!(
ns_targets_for(&tld, "xn--p1ai"),
vec!["a.dns.ripn.net".to_string()]
);
}
#[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 partition_reserved_refuses_reserved_addresses_with_their_reason() {
let public: IpAddr = "192.5.6.30".parse().unwrap();
let private: IpAddr = "10.0.0.53".parse().unwrap();
let loopback: IpAddr = "::1".parse().unwrap();
let (usable, refused) = partition_reserved(&[private, public, loopback], false);
assert_eq!(usable, vec![public]);
assert_eq!(
refused,
vec![
(private, "private network (RFC 1918)"),
(loopback, "IPv6 loopback (::1)")
]
);
let (usable, refused) = partition_reserved(&[private, public], true);
assert_eq!(usable, vec![private, public]);
assert!(refused.is_empty());
}
#[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 address_family_unsupported_classifies_as_local() {
use std::io;
let code = ADDRESS_FAMILY_UNSUPPORTED.expect("a Unix or Windows target");
let err = io::Error::from_raw_os_error(code);
assert!(is_local_no_route(&err), "{err:?} ({:?})", err.kind());
let direct = classify_net_error(err.into(), "example.com");
assert!(
matches!(direct, DirectNs::LocalNoRoute(_)),
"EAFNOSUPPORT classified as {direct:?}"
);
for other in [
io::Error::other("Address family not supported by protocol"),
io::Error::from(io::ErrorKind::Unsupported),
] {
assert!(!is_local_no_route(&other), "{other:?}");
}
}
#[test]
fn each_lame_outcome_has_its_kind() {
let response =
|authoritative, code, answer: &[&str], authority: &[&str]| DirectNs::Response {
authoritative,
response_code: code,
answer_ns: answer.iter().map(|s| s.to_string()).collect(),
authority_ns: authority.iter().map(|s| s.to_string()).collect(),
soa_zone: None,
};
let kind = |direct| match outcome_for_direct(direct) {
ProbeOutcome::Lame(kind, _) => kind,
other => panic!("expected lame, got {other:?}"),
};
assert_eq!(
kind(response(true, ResponseCode::NXDomain, &[], &[])),
LameKind::Nxdomain
);
assert_eq!(
kind(response(true, ResponseCode::NoError, &[], &[])),
LameKind::NoNsRecords
);
assert_eq!(
kind(response(false, ResponseCode::NoError, &["ns1.test"], &[])),
LameKind::NotAuthoritative
);
assert_eq!(
kind(response(
false,
ResponseCode::NoError,
&[],
&["ns.parent.test"]
)),
LameKind::Referral
);
assert_eq!(
kind(response(false, ResponseCode::NoError, &[], &[])),
LameKind::EmptyAnswer
);
assert_eq!(
kind(DirectNs::Rcode(ResponseCode::Refused)),
LameKind::ErrorRcode
);
assert_eq!(
kind(DirectNs::Unreachable("timed out".to_string())),
LameKind::Unresponsive
);
}
#[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));
}
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_dns_fn(move |qname, qtype| {
match (qname, qtype) {
("test", HickoryRecordType::NS) => {
MockReply::Answer(vec![ns_rdata("a.parent.test.")])
}
(host, HickoryRecordType::A)
if host == "a.parent.test" || resolvable.iter().any(|r| r == host) =>
{
MockReply::Answer(vec![loopback_a()])
}
_ => 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_dns_fn(move |_qname, qtype| {
if qtype == HickoryRecordType::NS {
MockReply::AuthoritativeAnswer(records.clone())
} else {
MockReply::NoData
}
})
.await
}
async fn spawn_parent_server(referral: &[&str]) -> u16 {
let servers: Vec<(String, Vec<IpAddr>)> = referral
.iter()
.map(|ns| (ns.to_string(), Vec::new()))
.collect();
spawn_mock_dns_fn(move |qname, qtype| {
if qname == "seer.test" && qtype == HickoryRecordType::NS {
MockReply::Delegation {
zone: "seer.test".to_string(),
servers: servers.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_dns_fn(|_, _| MockReply::Rcode(ResponseCode::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_eq!(report.lame[0].kind, LameKind::ErrorRcode);
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_dns_fn(|_, qtype| {
if qtype == HickoryRecordType::NS {
MockReply::Answer(vec![ns_rdata("ns1.seer.test."), ns_rdata("ns2.seer.test.")])
} 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_eq!(report.lame[0].kind, LameKind::NotAuthoritative);
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_eq!(report.lame[0].kind, LameKind::Unresponsive);
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}");
}
#[tokio::test]
async fn unroutable_address_falls_back_to_the_next() {
let unroutable: IpAddr = Ipv4Addr::new(127, 0, 0, 2).into();
let recursive = spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
("test", HickoryRecordType::NS) => MockReply::Answer(vec![ns_rdata("a.parent.test.")]),
(_, HickoryRecordType::A) => MockReply::Answer(vec![
HickoryRData::A(wire::A(Ipv4Addr::new(127, 0, 0, 2))),
loopback_a(),
]),
_ => MockReply::NoData,
})
.await;
let parent = spawn_parent_server(&["ns1.seer.test."]).await;
let ns1 = spawn_zone_server(&["ns1.seer.test."]).await;
let ports = HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
]);
let report = checker(recursive, ports.clone())
.with_unroutable(&[unroutable])
.check("seer.test")
.await
.expect("check must succeed");
assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
assert_eq!(report.zone_ns, vec!["ns1.seer.test"]);
assert!(report.in_sync, "{report:?}");
let err = checker(recursive, ports)
.with_unroutable(&[unroutable, Ipv4Addr::LOCALHOST.into()])
.check("seer.test")
.await
.expect_err("no parent server reachable");
assert!(err.to_string().contains("parent"), "{err}");
}
#[tokio::test]
async fn the_check_is_held_to_one_deadline() {
assert_eq!(CHECK_BUDGET_TIMEOUTS, 11);
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 started = std::time::Instant::now();
let report = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), parent),
("ns1.seer.test".to_string(), ns1),
("ns2.seer.test".to_string(), ns2),
]),
)
.with_budget(Duration::from_millis(300))
.check("seer.test")
.await
.expect("the parent answered in time");
assert!(started.elapsed() < Duration::from_millis(500));
assert!(report.lame.is_empty(), "{report:?}");
assert!(
report
.warnings
.iter()
.any(|w| w.contains("ns2.seer.test") && w.contains("budget")),
"{:?}",
report.warnings
);
assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
}
#[tokio::test]
async fn delegated_ns_reads_the_parent_referral_without_probing() {
let recursive = spawn_recursive_mock(&[]).await;
let parent = spawn_parent_server(&["NS2.seer.test.", "ns1.seer.test."]).await;
let delegated = checker(
recursive,
HashMap::from([("a.parent.test".to_string(), parent)]),
)
.delegated_ns("seer.test")
.await
.expect("parent answers the delegation");
assert_eq!(delegated, vec!["ns1.seer.test", "ns2.seer.test"]);
}
#[tokio::test]
async fn delegated_ns_errors_when_no_parent_server_answers() {
let recursive = spawn_recursive_mock(&[]).await;
let dead = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let port = dead.local_addr().unwrap().port();
drop(dead);
let result = checker(
recursive,
HashMap::from([("a.parent.test".to_string(), port)]),
)
.delegated_ns("seer.test")
.await;
assert!(result.is_err(), "{result:?}");
}
#[test]
fn dns_nameservers_compare_normalizes_and_diffs() {
let whois = vec!["NS1.nic.tg.".to_string(), "ns2.nic.tg".to_string()];
let delegated = ["ns1.nic.tg", "ns2.nic.tg", "ns4.nic.tg", "ns5.netmaster.tg"]
.map(String::from)
.to_vec();
let ns = DnsNameservers::compare(&whois, delegated);
assert!(!ns.in_sync);
assert_eq!(
ns.missing_from_whois,
vec!["ns4.nic.tg", "ns5.netmaster.tg"]
);
assert!(ns.missing_from_dns.is_empty());
let same = DnsNameservers::compare(&whois, vec!["ns2.nic.tg".into(), "ns1.nic.tg".into()]);
assert!(same.in_sync);
assert_eq!(same.delegated, vec!["ns1.nic.tg", "ns2.nic.tg"]);
}
async fn spawn_recursive_with_parents(parents: &'static [&'static str]) -> u16 {
spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
("test", HickoryRecordType::NS) => {
MockReply::Answer(parents.iter().map(|p| ns_rdata(&format!("{p}."))).collect())
}
(_, HickoryRecordType::A) => MockReply::Answer(vec![loopback_a()]),
_ => MockReply::NoData,
})
.await
}
#[tokio::test]
async fn lame_parents_do_not_count_toward_wanted() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let recursive =
spawn_recursive_with_parents(&["a.parent.test", "b.parent.test", "c.parent.test"])
.await;
let lame_a = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
let lame_b = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
zone: "elsewhere.test".to_string(),
servers: vec![("ns.elsewhere.test".to_string(), Vec::new())],
})
.await;
let asked = Arc::new(AtomicUsize::new(0));
let seen = asked.clone();
let healthy_c = spawn_mock_dns_fn(move |qname, qtype| {
seen.fetch_add(1, Ordering::SeqCst);
if qname == "seer.test" && qtype == HickoryRecordType::NS {
MockReply::Delegation {
zone: "seer.test".to_string(),
servers: vec![("ns1.seer.test".to_string(), Vec::new())],
}
} else {
MockReply::NoData
}
})
.await;
let checker = checker(
recursive,
HashMap::from([
("a.parent.test".to_string(), lame_a),
("b.parent.test".to_string(), lame_b),
("c.parent.test".to_string(), healthy_c),
]),
);
let delegated = checker.delegated_ns("seer.test").await.expect("c answers");
assert_eq!(delegated, vec!["ns1.seer.test"]);
assert!(asked.load(Ordering::SeqCst) >= 1);
let ParentDelegation {
servers_queried,
warnings,
..
} = checker
.parent_delegation(
"seer.test",
"test",
tokio::time::Instant::now() + Duration::from_secs(10),
Duration::from_secs(10),
)
.await
.expect("c answers");
assert_eq!(servers_queried, vec!["c.parent.test"]);
for lame in ["a.parent.test", "b.parent.test"] {
assert!(
warnings
.iter()
.any(|w| w.contains(lame) && w.contains("no delegation")),
"{warnings:?}"
);
}
}
#[tokio::test]
async fn parent_nxdomain_with_its_soa_counts_as_an_answer() {
let recursive = spawn_recursive_mock(&[]).await;
let parent = spawn_mock_dns_fn(|_, _| MockReply::AuthoritativeNxDomain("test")).await;
let report = checker(
recursive,
HashMap::from([("a.parent.test".to_string(), parent)]),
)
.check("seer.test")
.await
.expect("the parent answered");
assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
assert!(report.delegated_ns.is_empty());
assert!(
report.warnings.iter().any(|w| w.contains("NXDOMAIN")),
"{:?}",
report.warnings
);
}
#[test]
fn parent_answer_needs_a_delegation_or_the_zones_soa() {
let response = |code, authority: &[&str], soa: Option<&str>| DirectNs::Response {
authoritative: false,
response_code: code,
answer_ns: Vec::new(),
authority_ns: authority.iter().map(|s| s.to_string()).collect(),
soa_zone: soa.map(str::to_string),
};
let nx = ResponseCode::NXDomain;
let ok = ResponseCode::NoError;
assert_eq!(
parent_answer(response(ok, &["ns1.seer.test"], None), "seer.test"),
ParentAnswer::Delegation(vec!["ns1.seer.test".to_string()])
);
for soa in ["test", "", "seer.test"] {
assert_eq!(
parent_answer(response(nx, &[], Some(soa)), "seer.test"),
ParentAnswer::Negative(nx),
"{soa:?}"
);
}
assert_eq!(
parent_answer(response(ok, &[], Some("test")), "seer.test"),
ParentAnswer::Negative(ok)
);
for soa in [None, Some("other.test"), Some("eer.test")] {
assert!(
matches!(
parent_answer(response(ok, &[], soa), "seer.test"),
ParentAnswer::Lame(_)
),
"{soa:?}"
);
assert!(matches!(
parent_answer(response(nx, &[], soa), "seer.test"),
ParentAnswer::Lame(_)
));
}
assert!(matches!(
parent_answer(DirectNs::Rcode(ResponseCode::Refused), "seer.test"),
ParentAnswer::Lame(_)
));
}
}