use std::collections::BTreeMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use hickory_resolver::config::ConnectionConfig;
use hickory_resolver::net::NetError;
use hickory_resolver::proto::op::{Message, ResponseCode};
use hickory_resolver::proto::rr::{Name, RData as HickoryRData, RecordType as HickoryRecordType};
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use super::delegation::{build_recursive_resolver, is_local_no_route, partition_reserved};
use super::query::{duration_ms, DnsStatus};
use super::records::{DnsRecord, RecordType};
use super::resolver::{fqdn, prepare_query, to_dns_record, wire_query_name, wire_type};
use super::transport::{transport_reason, Transport};
use super::DEFAULT_DNS_TIMEOUT;
use crate::error::{Result, SeerError};
const MAX_HOPS: usize = 16;
const MAX_SERVERS_PER_LEVEL: usize = 3;
const MAX_GLUELESS_PER_LEVEL: usize = 3;
const TRACE_BUDGET_TIMEOUTS: u32 = (MAX_SERVERS_PER_LEVEL + MAX_GLUELESS_PER_LEVEL) as u32;
const ROOT_SERVERS: [(&str, &str, &str); 13] = [
("a.root-servers.net.", "198.41.0.4", "2001:503:ba3e::2:30"),
("b.root-servers.net.", "170.247.170.2", "2801:1b8:10::b"),
("c.root-servers.net.", "192.33.4.12", "2001:500:2::c"),
("d.root-servers.net.", "199.7.91.13", "2001:500:2d::d"),
("e.root-servers.net.", "192.203.230.10", "2001:500:a8::e"),
("f.root-servers.net.", "192.5.5.241", "2001:500:2f::f"),
("g.root-servers.net.", "192.112.36.4", "2001:500:12::d0d"),
("h.root-servers.net.", "198.97.190.53", "2001:500:1::53"),
("i.root-servers.net.", "192.36.148.17", "2001:7fe::53"),
("j.root-servers.net.", "192.58.128.30", "2001:503:c27::2:30"),
("k.root-servers.net.", "193.0.14.129", "2001:7fd::1"),
("l.root-servers.net.", "199.7.83.42", "2001:500:9f::42"),
("m.root-servers.net.", "202.12.27.33", "2001:dc3::35"),
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceHop {
pub zone: String,
pub server: String,
pub address: String,
pub query_time_ms: u64,
pub status: DnsStatus,
pub authoritative: bool,
pub referral_zone: Option<String>,
pub referral: Vec<String>,
pub answers: Vec<DnsRecord>,
pub failed_servers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsTrace {
pub name: String,
pub record_type: RecordType,
pub hops: Vec<TraceHop>,
pub status: DnsStatus,
pub answers: Vec<DnsRecord>,
pub error: Option<String>,
}
pub struct DnsTracer {
timeout: Duration,
transport: Transport,
recursive: TokioResolver,
#[cfg(test)]
recursive_upstream: Option<(IpAddr, u16)>,
#[cfg(test)]
root_hints: Option<Vec<NsCandidate>>,
#[cfg(test)]
port_map: Option<std::collections::HashMap<String, u16>>,
#[cfg(test)]
allow_private_hosts: bool,
#[cfg(test)]
unroutable: Vec<(IpAddr, fn() -> std::io::Error)>,
}
impl std::fmt::Debug for DnsTracer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DnsTracer")
.field("timeout", &self.timeout)
.finish()
}
}
impl Default for DnsTracer {
fn default() -> Self {
Self::new()
}
}
impl DnsTracer {
pub fn new() -> Self {
Self {
timeout: DEFAULT_DNS_TIMEOUT,
transport: Transport::new(DEFAULT_DNS_TIMEOUT),
recursive: build_recursive_resolver(DEFAULT_DNS_TIMEOUT, None),
#[cfg(test)]
recursive_upstream: None,
#[cfg(test)]
root_hints: None,
#[cfg(test)]
port_map: None,
#[cfg(test)]
allow_private_hosts: false,
#[cfg(test)]
unroutable: Vec::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.timeout = timeout;
self.transport = Transport::new(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_root_hints(mut self, hints: &[(&str, IpAddr)]) -> Self {
self.root_hints = Some(
hints
.iter()
.map(|(host, ip)| NsCandidate {
host: Name::from_ascii(fqdn(host)).expect("valid test root name"),
addrs: vec![*ip],
})
.collect(),
);
self
}
#[cfg(test)]
fn with_port_map(mut self, map: std::collections::HashMap<String, u16>) -> Self {
self.port_map = Some(
map.into_iter()
.map(|(host, port)| (host.trim_end_matches('.').to_ascii_lowercase(), port))
.collect(),
);
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], error: fn() -> std::io::Error) -> Self {
self.unroutable.extend(addrs.iter().map(|&ip| (ip, error)));
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 root_servers(&self) -> Vec<NsCandidate> {
self.root_hints.clone().unwrap_or_else(root_hints)
}
#[cfg(not(test))]
fn root_servers(&self) -> Vec<NsCandidate> {
root_hints()
}
#[cfg(test)]
fn direct_port(&self, host: &Name) -> u16 {
let key = name_text(host);
self.port_map
.as_ref()
.and_then(|map| map.get(key.trim_end_matches('.')).copied())
.unwrap_or(53)
}
#[cfg(not(test))]
fn direct_port(&self, _host: &Name) -> u16 {
53
}
#[cfg(test)]
fn simulated_no_route(&self, ip: IpAddr) -> Option<NetError> {
self.unroutable
.iter()
.find(|(addr, _)| *addr == ip)
.map(|(_, error)| NetError::from(error()))
}
#[cfg(not(test))]
fn simulated_no_route(&self, _ip: IpAddr) -> Option<NetError> {
None
}
#[instrument(skip(self), fields(name = %name, record_type = %record_type))]
pub async fn trace(&self, name: &str, record_type: RecordType) -> Result<DnsTrace> {
let qtype = trace_wire_type(record_type)?;
let (name, qname) = query_target(name, record_type)?;
let mut zone = Name::root();
let mut servers = self.root_servers();
let mut hops: Vec<TraceHop> = Vec::new();
let mut error = None;
let budget = self.timeout.saturating_mul(TRACE_BUDGET_TIMEOUTS);
let started = Instant::now();
loop {
let remaining = budget.saturating_sub(started.elapsed());
let level = self.ask_level(&zone, &servers, &qname, qtype);
let Ok(outcome) = tokio::time::timeout(remaining, level).await else {
let why = format!(
"gave up after {budget:?} without a final response, while asking the \
nameservers of {}",
name_text(&zone)
);
if hops.is_empty() {
return Err(SeerError::DnsError(format!(
"trace of {name} failed: {why}"
)));
}
error = Some(why);
break;
};
let (hop, step) = match outcome {
LevelOutcome::Answered { hop, step } => (hop, step),
LevelOutcome::Failed { final_hop, reason } => {
let why = format!(
"no nameserver for {} gave a usable response: {}",
name_text(&zone),
reason
);
match final_hop {
Some(hop) => hops.push(hop),
None if hops.is_empty() => {
return Err(SeerError::DnsError(format!(
"trace of {name} failed: {why}"
)))
}
None => {}
}
error = Some(why);
break;
}
};
let referral = match step {
Step::Final => {
hops.push(hop);
break;
}
Step::Referral(referral) => referral,
};
hops.push(hop);
if hops.len() >= MAX_HOPS {
error = Some(format!(
"gave up after {MAX_HOPS} delegation levels without a final response"
));
break;
}
zone = referral.zone;
servers = referral.servers;
}
let (status, answers) = match hops.last() {
Some(last) => (last.status, last.answers.clone()),
None => {
return Err(SeerError::DnsError(format!(
"trace of {name} produced no response"
)))
}
};
Ok(DnsTrace {
name,
record_type,
hops,
status,
answers,
error,
})
}
async fn ask_level(
&self,
zone: &Name,
servers: &[NsCandidate],
qname: &Name,
qtype: HickoryRecordType,
) -> LevelOutcome {
let (picks, mut failures) = plan_servers(servers, self.allow_private());
let mut queried = 0;
let mut resolved = 0;
let mut unusable_hop: Option<(TraceHop, usize)> = None;
for pick in picks {
if queried == MAX_SERVERS_PER_LEVEL {
break;
}
let (host, addrs) = match pick {
ServerPick::Addrs(host, addrs) => (host, addrs),
ServerPick::Glueless(host) => {
if resolved == MAX_GLUELESS_PER_LEVEL {
break;
}
resolved += 1;
let (addrs, notes) = self.resolve_glueless(&host).await;
failures.extend(notes);
(host, addrs)
}
};
let Some((ip, elapsed, result)) = self
.ask_server(&host, &addrs, qname, qtype, &mut failures)
.await
else {
continue;
};
queried += 1;
match result {
Ok(response) => {
let mut hop = hop_from_response(zone, &host, ip, elapsed, &response);
let reason = match classify_response(&response, zone) {
Ok(Step::Referral(referral)) => {
hop.referral_zone = Some(name_text(&referral.zone));
hop.referral = referral
.servers
.iter()
.map(|s| name_text(&s.host))
.collect();
match check_bailiwick(zone, &referral.zone, qname) {
Ok(()) => {
hop.failed_servers = failures;
let step = Step::Referral(referral);
return LevelOutcome::Answered { hop, step };
}
Err(fault) => format!(
"referred {fault} to {} (not toward the name)",
name_text(&referral.zone)
),
}
}
Ok(step) => {
hop.failed_servers = failures;
return LevelOutcome::Answered { hop, step };
}
Err(reason) => reason,
};
unusable_hop = Some((hop, failures.len()));
failures.push(server_note(&host, ip, &reason));
}
Err(e) => failures.push(server_note(&host, ip, &transport_reason(&e))),
}
}
let reason = if failures.is_empty() {
"no nameserver address to query".to_string()
} else {
failures.join("; ")
};
let final_hop = unusable_hop.map(|(mut hop, own_note)| {
failures.remove(own_note);
hop.failed_servers = failures;
hop
});
LevelOutcome::Failed { final_hop, reason }
}
async fn ask_server(
&self,
host: &Name,
addrs: &[IpAddr],
qname: &Name,
qtype: HickoryRecordType,
failures: &mut Vec<String>,
) -> Option<(IpAddr, Duration, std::result::Result<Message, NetError>)> {
for &ip in addrs {
debug!(server = %name_text(host), %ip, "trace: querying");
let started = Instant::now();
match self.exchange(host, ip, qname, qtype).await {
Err(e) if is_no_route(&e) => {
failures.push(server_note(host, ip, &transport_reason(&e)));
}
result => return Some((ip, started.elapsed(), result)),
}
}
None
}
async fn resolve_glueless(&self, host: &Name) -> (Vec<IpAddr>, Vec<String>) {
let text = name_text(host);
let lookup = tokio::time::timeout(self.timeout, self.recursive.lookup_ip(text.as_str()))
.await
.unwrap_or(Err(NetError::Timeout));
let ips: Vec<IpAddr> = match lookup {
Ok(lookup) => lookup.iter().collect(),
Err(e) if e.is_no_records_found() => Vec::new(),
Err(e) => {
return (
Vec::new(),
vec![format!(
"{text}: glueless nameserver lookup failed: {}",
transport_reason(&e)
)],
)
}
};
let (usable, refused) = partition_reserved(&ips, self.allow_private());
let mut notes: Vec<String> = refused
.iter()
.map(|(ip, reason)| server_note(host, *ip, &format!("refused, {reason}")))
.collect();
if usable.is_empty() && notes.is_empty() {
notes.push(format!("{text}: glueless nameserver has no address"));
}
let mut usable = usable;
crate::net::ipv4_first(&mut usable, |ip| *ip);
(usable, notes)
}
async fn exchange(
&self,
host: &Name,
ip: IpAddr,
qname: &Name,
qtype: HickoryRecordType,
) -> std::result::Result<Message, NetError> {
if let Some(no_route) = self.simulated_no_route(ip) {
return Err(no_route);
}
let mut udp = ConnectionConfig::udp();
udp.port = self.direct_port(host);
let request = self.transport.request(qname.clone(), qtype, false);
self.transport.exchange(ip, &udp, &request).await
}
}
#[derive(Debug, Clone, PartialEq)]
struct NsCandidate {
host: Name,
addrs: Vec<IpAddr>,
}
#[derive(Debug, PartialEq)]
struct Referral {
zone: Name,
servers: Vec<NsCandidate>,
}
#[derive(Debug, PartialEq)]
enum Step {
Final,
Referral(Referral),
}
enum LevelOutcome {
Answered { hop: TraceHop, step: Step },
Failed {
final_hop: Option<TraceHop>,
reason: String,
},
}
#[derive(Debug, PartialEq)]
enum ServerPick {
Addrs(Name, Vec<IpAddr>),
Glueless(Name),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ReferralFault {
Upward,
Sideways,
}
impl std::fmt::Display for ReferralFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ReferralFault::Upward => "upward",
ReferralFault::Sideways => "sideways",
})
}
}
fn root_hints() -> Vec<NsCandidate> {
ROOT_SERVERS
.iter()
.map(|(host, v4, v6)| NsCandidate {
host: Name::from_ascii(host).expect("root hint names are valid"),
addrs: [v4, v6]
.iter()
.map(|addr| addr.parse().expect("root hint addresses are valid"))
.collect(),
})
.collect()
}
fn trace_wire_type(record_type: RecordType) -> Result<HickoryRecordType> {
wire_type(record_type).ok_or_else(|| {
SeerError::InvalidInput(
"trace needs a single record type; ANY is a fan-out over several queries".to_string(),
)
})
}
fn query_target(name: &str, record_type: RecordType) -> Result<(String, Name)> {
let reported = wire_query_name(&prepare_query(name, record_type)?, record_type)?;
let qname = Name::from_ascii(fqdn(&reported))
.map_err(|e| SeerError::InvalidDomain(format!("{reported}: {e}")))?;
Ok((reported, qname))
}
fn plan_servers(servers: &[NsCandidate], allow_private: bool) -> (Vec<ServerPick>, Vec<String>) {
let mut picks = Vec::new();
let mut glueless = Vec::new();
let mut notes = Vec::new();
for server in servers {
if server.addrs.is_empty() {
glueless.push(ServerPick::Glueless(server.host.clone()));
continue;
}
let (usable, refused) = partition_reserved(&server.addrs, allow_private);
notes.extend(
refused
.iter()
.map(|(ip, reason)| server_note(&server.host, *ip, &format!("refused, {reason}"))),
);
if !usable.is_empty() {
let mut usable = usable;
crate::net::ipv4_first(&mut usable, |ip| *ip);
picks.push(ServerPick::Addrs(server.host.clone(), usable));
}
}
picks.extend(glueless);
(picks, notes)
}
fn classify_response(response: &Message, zone: &Name) -> std::result::Result<Step, String> {
let code = response.metadata.response_code;
if !matches!(code, ResponseCode::NoError | ResponseCode::NXDomain) {
return Err(DnsStatus::from(code).to_string());
}
if !response.answers.is_empty()
|| response.metadata.authoritative
|| response
.authorities
.iter()
.any(|record| record.record_type() == HickoryRecordType::SOA)
{
return Ok(Step::Final);
}
if code == ResponseCode::NXDomain {
return Err("non-authoritative NXDOMAIN without an SOA".to_string());
}
extract_referral(response, zone)
.map(Step::Referral)
.ok_or_else(|| "empty non-authoritative response (no answer, referral or SOA)".to_string())
}
fn extract_referral(response: &Message, zone: &Name) -> Option<Referral> {
let child = response
.authorities
.iter()
.find(|record| matches!(record.data, HickoryRData::NS(_)))?
.name
.to_lowercase();
let targets: BTreeMap<String, Name> = response
.authorities
.iter()
.filter(|record| record.name == child)
.filter_map(|record| match &record.data {
HickoryRData::NS(ns) => {
let host = ns.0.to_lowercase();
Some((name_text(&host), host))
}
_ => None,
})
.collect();
let servers = targets
.into_values()
.map(|host| {
let mut addrs: Vec<IpAddr> = Vec::new();
if zone.zone_of(&host) {
for record in response.additionals.iter().filter(|r| r.name == host) {
let ip = match &record.data {
HickoryRData::A(a) => IpAddr::V4(a.0),
HickoryRData::AAAA(aaaa) => IpAddr::V6(aaaa.0),
_ => continue,
};
if !addrs.contains(&ip) {
addrs.push(ip);
}
}
}
NsCandidate { host, addrs }
})
.collect();
Some(Referral {
zone: child,
servers,
})
}
fn check_bailiwick(
zone: &Name,
child: &Name,
qname: &Name,
) -> std::result::Result<(), ReferralFault> {
if child.zone_of(zone) {
return Err(ReferralFault::Upward);
}
if !zone.zone_of(child) || !child.zone_of(qname) {
return Err(ReferralFault::Sideways);
}
Ok(())
}
fn hop_from_response(
zone: &Name,
host: &Name,
ip: IpAddr,
elapsed: Duration,
response: &Message,
) -> TraceHop {
TraceHop {
zone: name_text(zone),
server: name_text(host),
address: ip.to_string(),
query_time_ms: duration_ms(elapsed),
status: DnsStatus::from(response.metadata.response_code),
authoritative: response.metadata.authoritative,
referral_zone: None,
referral: Vec::new(),
answers: response.answers.iter().filter_map(to_dns_record).collect(),
failed_servers: Vec::new(),
}
}
fn server_note(host: &Name, ip: IpAddr, reason: &str) -> String {
format!("{} ({}): {}", name_text(host), ip, reason)
}
fn is_no_route(err: &NetError) -> bool {
matches!(err, NetError::Io(io) if is_local_no_route(io))
}
fn name_text(name: &Name) -> String {
name.to_ascii().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use hickory_resolver::proto::op::OpCode;
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::Record;
use super::*;
use crate::dns::delegation::ADDRESS_FAMILY_UNSUPPORTED;
use crate::dns::records::RecordData;
use crate::dns::test_support::{
a_rdata, cname_rdata, fq_name, record, soa_rdata, spawn_mock_dns, spawn_mock_dns_fn,
spawn_mock_dns_fn_with_tcp, MockMode, MockReply,
};
const LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
fn network_unreachable() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::NetworkUnreachable,
"Network is unreachable",
)
}
fn address_family_unsupported() -> std::io::Error {
let code = ADDRESS_FAMILY_UNSUPPORTED.expect("a Unix or Windows target");
std::io::Error::from_raw_os_error(code)
}
#[test]
fn root_hints_are_complete_and_public() {
let hints = root_hints();
assert_eq!(hints.len(), 13);
for (letter, hint) in ('a'..='m').zip(&hints) {
assert_eq!(name_text(&hint.host), format!("{letter}.root-servers.net."));
assert_eq!(hint.addrs.len(), 2);
assert!(hint.addrs[0].is_ipv4() && hint.addrs[1].is_ipv6());
let (usable, refused) = partition_reserved(&hint.addrs, false);
assert_eq!(usable, hint.addrs);
assert!(refused.is_empty(), "{refused:?}");
}
}
#[test]
fn name_text_is_fully_qualified_lowercase_ascii() {
assert_eq!(name_text(&Name::root()), ".");
assert_eq!(name_text(&fq_name("Example.COM")), "example.com.");
assert_eq!(
name_text(&Name::from_utf8("münchen.de.").unwrap()),
"xn--mnchen-3ya.de."
);
}
#[test]
fn bailiwick_accepts_only_strict_descendants_on_the_path() {
let qname = fq_name("www.example.com");
assert_eq!(
check_bailiwick(&Name::root(), &fq_name("com"), &qname),
Ok(())
);
assert_eq!(
check_bailiwick(&fq_name("com"), &fq_name("example.com"), &qname),
Ok(())
);
assert_eq!(
check_bailiwick(&fq_name("example.com"), &fq_name("www.example.com"), &qname),
Ok(())
);
assert_eq!(
check_bailiwick(&Name::root(), &fq_name("example.com"), &qname),
Ok(())
);
assert_eq!(
check_bailiwick(&fq_name("com"), &fq_name("EXAMPLE.com"), &qname),
Ok(())
);
assert_eq!(
check_bailiwick(&fq_name("com"), &Name::root(), &qname),
Err(ReferralFault::Upward)
);
assert_eq!(
check_bailiwick(&fq_name("com"), &fq_name("com"), &qname),
Err(ReferralFault::Upward)
);
assert_eq!(
check_bailiwick(&fq_name("example.com"), &fq_name("com"), &qname),
Err(ReferralFault::Upward)
);
assert_eq!(
check_bailiwick(&fq_name("com"), &fq_name("other.com"), &qname),
Err(ReferralFault::Sideways)
);
assert_eq!(
check_bailiwick(&fq_name("com"), &fq_name("example.net"), &qname),
Err(ReferralFault::Sideways)
);
assert_eq!(
check_bailiwick(
&fq_name("example.com"),
&fq_name("a.www.example.com"),
&qname
),
Err(ReferralFault::Sideways)
);
}
fn message() -> Message {
Message::response(1, OpCode::Query)
}
fn ns(owner: &str, target: &str) -> Record {
record(owner, 300, HickoryRData::NS(wire::NS(fq_name(target))))
}
fn a(owner: &str, ip: Ipv4Addr) -> Record {
record(owner, 300, a_rdata(ip))
}
fn aaaa(owner: &str, ip: Ipv6Addr) -> Record {
record(owner, 300, HickoryRData::AAAA(wire::AAAA(ip)))
}
#[test]
fn referral_extraction_reads_ns_and_in_bailiwick_glue() {
let mut response = message();
response.add_authority(ns("Example.COM", "ns2.example.com"));
response.add_authority(ns("example.com", "NS1.example.com"));
response.add_authority(ns("example.com", "ns1.example.com"));
response.add_authority(ns("example.com", "ns.dns-host.net"));
response.add_authority(ns("other.com", "ns.other.com"));
let v4 = Ipv4Addr::new(192, 0, 2, 1);
let v6 = "2001:db8::1".parse().unwrap();
response.add_additional(aaaa("ns1.example.com", v6));
response.add_additional(a("ns1.example.com", v4));
response.add_additional(a("ns1.example.com", v4));
response.add_additional(a("ns.dns-host.net", Ipv4Addr::new(198, 51, 100, 7)));
response.add_additional(a("unrelated.example.com", v4));
let referral = extract_referral(&response, &fq_name("com")).expect("a referral");
assert_eq!(referral.zone, fq_name("example.com"));
assert_eq!(name_text(&referral.zone), "example.com.");
let servers: Vec<(String, Vec<IpAddr>)> = referral
.servers
.iter()
.map(|s| (name_text(&s.host), s.addrs.clone()))
.collect();
assert_eq!(
servers,
vec![
("ns.dns-host.net.".to_string(), vec![]),
(
"ns1.example.com.".to_string(),
vec![IpAddr::V6(v6), IpAddr::V4(v4)]
),
("ns2.example.com.".to_string(), vec![]),
]
);
let from_root = extract_referral(&response, &Name::root()).expect("a referral");
assert_eq!(from_root.servers[0].addrs.len(), 1);
assert_eq!(extract_referral(&message(), &fq_name("com")), None);
}
#[test]
fn classification_separates_answers_negatives_referrals_and_lame_replies() {
let zone = fq_name("com");
let mut referral = message();
referral.add_authority(ns("example.com", "ns1.example.com"));
assert!(matches!(
classify_response(&referral, &zone),
Ok(Step::Referral(_))
));
let mut authoritative = referral.clone();
authoritative.metadata.authoritative = true;
assert_eq!(classify_response(&authoritative, &zone), Ok(Step::Final));
let mut nodata = referral.clone();
nodata.add_authority(record("com", 300, soa_rdata("com")));
assert_eq!(classify_response(&nodata, &zone), Ok(Step::Final));
let mut nxdomain = nodata.clone();
nxdomain.metadata.response_code = ResponseCode::NXDomain;
assert_eq!(classify_response(&nxdomain, &zone), Ok(Step::Final));
let mut aa_nxdomain = message();
aa_nxdomain.metadata.authoritative = true;
aa_nxdomain.metadata.response_code = ResponseCode::NXDomain;
assert_eq!(classify_response(&aa_nxdomain, &zone), Ok(Step::Final));
let mut answer = referral.clone();
answer.add_answer(record("www.example.com", 300, cname_rdata("edge.cdn.net")));
assert_eq!(classify_response(&answer, &zone), Ok(Step::Final));
let mut servfail = answer;
servfail.metadata.response_code = ResponseCode::ServFail;
assert_eq!(
classify_response(&servfail, &zone),
Err("SERVFAIL".to_string())
);
let lame = classify_response(&message(), &zone).expect_err("a lame reply");
assert!(lame.contains("empty non-authoritative"), "{lame}");
let mut bare_nxdomain = referral;
bare_nxdomain.metadata.response_code = ResponseCode::NXDomain;
let lame = classify_response(&bare_nxdomain, &zone).expect_err("a lame reply");
assert!(lame.contains("NXDOMAIN without an SOA"), "{lame}");
}
#[test]
fn server_plan_orders_glued_first_prefers_ipv4_and_refuses_reserved() {
let public_v4: IpAddr = "192.5.6.30".parse().unwrap();
let public_v6: IpAddr = "2001:503:a83e::2:30".parse().unwrap();
let private: IpAddr = "10.0.0.53".parse().unwrap();
let metadata: IpAddr = "169.254.169.254".parse().unwrap();
let servers = vec![
NsCandidate {
host: fq_name("glueless.example.net"),
addrs: vec![],
},
NsCandidate {
host: fq_name("ns1.example.com"),
addrs: vec![public_v6, public_v4],
},
NsCandidate {
host: fq_name("ns2.example.com"),
addrs: vec![private, public_v6],
},
NsCandidate {
host: fq_name("ns3.example.com"),
addrs: vec![metadata],
},
];
let (picks, notes) = plan_servers(&servers, false);
assert_eq!(
picks,
vec![
ServerPick::Addrs(fq_name("ns1.example.com"), vec![public_v4, public_v6]),
ServerPick::Addrs(fq_name("ns2.example.com"), vec![public_v6]),
ServerPick::Glueless(fq_name("glueless.example.net")),
]
);
assert_eq!(
notes,
vec![
"ns2.example.com. (10.0.0.53): refused, private network (RFC 1918)",
"ns3.example.com. (169.254.169.254): refused, cloud metadata endpoint (169.254.169.254)",
]
);
let (picks, notes) = plan_servers(&servers, true);
assert!(picks.contains(&ServerPick::Addrs(
fq_name("ns3.example.com"),
vec![metadata]
)));
assert!(notes.is_empty());
}
#[test]
fn query_target_follows_record_query_normalization() {
let (name, qname) = query_target("WWW.Example.com", RecordType::A).unwrap();
assert_eq!(name, "www.example.com");
assert_eq!(qname, fq_name("www.example.com"));
assert!(qname.is_fqdn());
let (name, qname) = query_target("192.0.2.1", RecordType::PTR).unwrap();
assert_eq!(name, "1.2.0.192.in-addr.arpa");
assert_eq!(qname, fq_name("1.2.0.192.in-addr.arpa"));
let (name, _) = query_target("2606:4700:4700::1111", RecordType::PTR).unwrap();
assert_eq!(
name,
"1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.4.0.0.7.4.6.0.6.2.ip6.arpa"
);
assert!(query_target("_sip._tcp.example.com", RecordType::SRV).is_ok());
assert!(matches!(
query_target("example.com", RecordType::SRV),
Err(SeerError::InvalidInput(_))
));
assert!(query_target("not a name", RecordType::A).is_err());
}
#[test]
fn query_target_applies_the_resolvers_srv_label_rule() {
let err = query_target("_sip_x._tcp.example.com", RecordType::SRV).unwrap_err();
assert!(
matches!(&err, SeerError::InvalidInput(m) if m.contains("invalid SRV service")),
"{err:?}"
);
}
#[test]
fn any_is_not_traceable() {
assert!(matches!(
trace_wire_type(RecordType::ANY),
Err(SeerError::InvalidInput(_))
));
assert_eq!(
trace_wire_type(RecordType::SRV).unwrap(),
HickoryRecordType::SRV
);
assert_eq!(
trace_wire_type(RecordType::MX).unwrap(),
HickoryRecordType::MX
);
}
#[test]
fn trace_serializes_to_the_documented_shape() {
let hop = TraceHop {
zone: ".".to_string(),
server: "a.root-servers.net.".to_string(),
address: "198.41.0.4".to_string(),
query_time_ms: 21,
status: DnsStatus::NoError,
authoritative: false,
referral_zone: Some("com.".to_string()),
referral: vec!["a.gtld-servers.net.".to_string()],
answers: vec![],
failed_servers: vec![],
};
let trace = DnsTrace {
name: "www.example.com".to_string(),
record_type: RecordType::A,
hops: vec![hop],
status: DnsStatus::NxDomain,
answers: vec![],
error: None,
};
let json = serde_json::to_value(&trace).unwrap();
assert_eq!(
json,
serde_json::json!({
"name": "www.example.com",
"record_type": "A",
"hops": [{
"zone": ".",
"server": "a.root-servers.net.",
"address": "198.41.0.4",
"query_time_ms": 21,
"status": "NOERROR",
"authoritative": false,
"referral_zone": "com.",
"referral": ["a.gtld-servers.net."],
"answers": [],
"failed_servers": []
}],
"status": "NXDOMAIN",
"answers": [],
"error": null
})
);
let back: DnsTrace = serde_json::from_value(json).unwrap();
assert_eq!(back.hops[0].referral_zone.as_deref(), Some("com."));
assert_eq!(back.status, DnsStatus::NxDomain);
}
#[test]
fn transport_reasons_are_short_and_name_local_routing_failures() {
assert_eq!(transport_reason(&NetError::Timeout), "timed out");
let no_route = NetError::from(network_unreachable());
assert!(is_no_route(&no_route));
assert_eq!(
transport_reason(&no_route),
"no route from this host (Network is unreachable)"
);
let no_socket = NetError::from(address_family_unsupported());
assert!(is_no_route(&no_socket));
assert_eq!(
transport_reason(&no_socket),
format!("no route from this host ({})", address_family_unsupported())
);
let refused = NetError::from(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"refused",
));
assert!(transport_reason(&refused).contains("refused"));
}
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
let tracer = DnsTracer::from_config(&config);
assert_eq!(tracer.timeout, Duration::from_secs(9));
}
fn delegation(zone: &str, servers: &[&str]) -> MockReply {
MockReply::Delegation {
zone: zone.to_string(),
servers: servers
.iter()
.map(|s| (s.to_string(), vec![LOOPBACK]))
.collect(),
}
}
fn tracer(roots: &[&str], ports: &[(&str, u16)]) -> DnsTracer {
let hints: Vec<(&str, IpAddr)> = roots.iter().map(|root| (*root, LOOPBACK)).collect();
DnsTracer::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts()
.with_root_hints(&hints)
.with_port_map(
ports
.iter()
.map(|(host, port)| (host.to_string(), *port))
.collect(),
)
}
async fn three_level_tracer(answer: fn(&str) -> MockReply) -> DnsTracer {
let root = spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test"])).await;
let tld = spawn_mock_dns_fn(|_, _| {
delegation("example.test", &["ns2.example.test", "ns1.example.test"])
})
.await;
let auth = spawn_mock_dns_fn(move |qname, _| answer(qname)).await;
tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.nic.test", tld),
("ns1.example.test", auth),
("ns2.example.test", auth),
],
)
}
#[tokio::test]
async fn walks_root_tld_and_authoritative_server() {
let tracer = three_level_tracer(|_| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 10))])
})
.await;
let trace = tracer
.trace("WWW.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.name, "www.example.test");
assert_eq!(trace.record_type, RecordType::A);
assert_eq!(trace.hops.len(), 3, "{trace:#?}");
assert!(trace.error.is_none(), "{trace:#?}");
let root = &trace.hops[0];
assert_eq!(root.zone, ".");
assert_eq!(root.server, "a.root.test.");
assert_eq!(root.address, "127.0.0.1");
assert_eq!(root.status, DnsStatus::NoError);
assert!(!root.authoritative);
assert_eq!(root.referral_zone.as_deref(), Some("test."));
assert_eq!(root.referral, vec!["ns1.nic.test."]);
assert!(root.answers.is_empty());
let tld = &trace.hops[1];
assert_eq!(tld.zone, "test.");
assert_eq!(tld.server, "ns1.nic.test.");
assert_eq!(tld.referral_zone.as_deref(), Some("example.test."));
assert_eq!(tld.referral, vec!["ns1.example.test.", "ns2.example.test."]);
let auth = &trace.hops[2];
assert_eq!(auth.zone, "example.test.");
assert_eq!(auth.server, "ns1.example.test.");
assert!(auth.authoritative);
assert_eq!(auth.status, DnsStatus::NoError);
assert_eq!(auth.referral_zone, None);
assert!(auth.referral.is_empty());
assert!(auth.failed_servers.is_empty());
assert_eq!(trace.status, DnsStatus::NoError);
assert_eq!(trace.answers.len(), 1);
assert_eq!(trace.answers[0].name, "www.example.test");
assert_eq!(trace.answers[0].record_type, RecordType::A);
assert!(matches!(
&trace.answers[0].data,
RecordData::A { address } if address == "192.0.2.10"
));
}
#[tokio::test]
async fn nxdomain_at_the_authoritative_level_ends_the_walk() {
let tracer = three_level_tracer(|_| MockReply::AuthoritativeNxDomain("example.test")).await;
let trace = tracer
.trace("gone.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 3);
assert_eq!(trace.status, DnsStatus::NxDomain);
assert!(trace.answers.is_empty());
assert!(trace.error.is_none(), "a negative answer is not an error");
let last = &trace.hops[2];
assert_eq!(last.status, DnsStatus::NxDomain);
assert!(last.authoritative, "the AA bit survives a negative answer");
assert_eq!(last.referral_zone, None);
}
#[tokio::test]
async fn nodata_at_the_authoritative_level_ends_the_walk() {
let tracer = three_level_tracer(|_| MockReply::AuthoritativeAnswer(vec![])).await;
let trace = tracer
.trace("www.example.test", RecordType::MX)
.await
.unwrap();
assert_eq!(trace.hops.len(), 3);
assert_eq!(trace.status, DnsStatus::NoError);
assert!(trace.answers.is_empty());
assert!(trace.hops[2].authoritative);
assert!(trace.error.is_none());
}
#[tokio::test]
async fn cname_answer_is_reported_not_chased() {
let root = spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test"])).await;
let tld = spawn_mock_dns_fn(|_, _| delegation("example.test", &["ns1.example.test"])).await;
let asked = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&asked);
let auth = spawn_mock_dns_fn(move |qname, qtype| {
log.lock().unwrap().push((qname.to_string(), qtype));
MockReply::AuthoritativeAnswer(vec![cname_rdata("edge.cdn.test")])
})
.await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.nic.test", tld),
("ns1.example.test", auth),
],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 3);
assert!(trace.error.is_none());
assert_eq!(trace.answers.len(), 1);
let cname = &trace.answers[0];
assert_eq!(cname.name, "www.example.test");
assert_eq!(cname.record_type, RecordType::CNAME);
assert!(matches!(
&cname.data,
RecordData::CNAME { target } if target == "edge.cdn.test."
));
let asked = asked.lock().unwrap();
assert!(!asked.is_empty());
assert!(
asked
.iter()
.all(|q| *q == ("www.example.test".to_string(), HickoryRecordType::A)),
"{asked:?}"
);
}
#[tokio::test]
async fn glueless_referral_is_resolved_through_the_recursive_resolver() {
let root = spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test"])).await;
let tld = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
zone: "example.test".to_string(),
servers: vec![("ns.dns-host.test".to_string(), vec![])],
})
.await;
let auth = spawn_mock_dns_fn(|_, _| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 20))])
})
.await;
let recursive = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("ns.dns-host.test", HickoryRecordType::A) => {
MockReply::Answer(vec![a_rdata(Ipv4Addr::LOCALHOST)])
}
_ => MockReply::NoData,
})
.await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.nic.test", tld),
("ns.dns-host.test", auth),
],
)
.with_recursive_upstream(LOOPBACK, recursive)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 3, "{trace:#?}");
assert_eq!(trace.hops[1].referral, vec!["ns.dns-host.test."]);
assert_eq!(trace.hops[2].server, "ns.dns-host.test.");
assert_eq!(trace.hops[2].address, "127.0.0.1");
assert_eq!(trace.answers.len(), 1);
assert!(trace.error.is_none());
}
#[tokio::test]
async fn unresolvable_glueless_nameserver_ends_the_walk_with_an_error() {
let root = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
zone: "test".to_string(),
servers: vec![("ns.nowhere.test".to_string(), vec![])],
})
.await;
let recursive = spawn_mock_dns(MockMode::Nxdomain).await;
let trace = tracer(&["a.root.test"], &[("a.root.test", root)])
.with_recursive_upstream(LOOPBACK, recursive)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 1);
let error = trace.error.expect("the walk must stop with an error");
assert!(error.contains("test."), "{error}");
assert!(error.contains("ns.nowhere.test."), "{error}");
}
#[tokio::test]
async fn reserved_root_hints_are_refused_without_the_test_seam() {
let asked = Arc::new(Mutex::new(0usize));
let count = Arc::clone(&asked);
let root = spawn_mock_dns_fn(move |_, _| {
*count.lock().unwrap() += 1;
MockReply::AuthoritativeAnswer(vec![])
})
.await;
let err = DnsTracer::new()
.with_timeout(Duration::from_millis(500))
.with_root_hints(&[("a.root.test", LOOPBACK)])
.with_port_map(HashMap::from([("a.root.test".to_string(), root)]))
.trace("www.example.test", RecordType::A)
.await
.expect_err("a loopback root must be refused");
assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
assert!(err.to_string().contains("loopback"), "{err}");
assert_eq!(*asked.lock().unwrap(), 0, "no query may reach the server");
}
#[tokio::test]
async fn glueless_addresses_are_vetted_without_the_test_seam() {
let public_v4 = Ipv4Addr::new(9, 9, 9, 9);
let public_v6: Ipv6Addr = "2620:fe::fe".parse().unwrap();
let private = Ipv4Addr::new(10, 0, 0, 53);
let recursive = spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
("ns.evil.test", HickoryRecordType::A) => MockReply::Answer(vec![
a_rdata(private),
a_rdata(Ipv4Addr::new(169, 254, 169, 254)),
]),
("ns.evil.test", HickoryRecordType::AAAA) => {
MockReply::Answer(vec![HickoryRData::AAAA(wire::AAAA(Ipv6Addr::LOCALHOST))])
}
("ns.mixed.test", HickoryRecordType::A) => {
MockReply::Answer(vec![a_rdata(private), a_rdata(public_v4)])
}
("ns.mixed.test", HickoryRecordType::AAAA) => {
MockReply::Answer(vec![HickoryRData::AAAA(wire::AAAA(public_v6))])
}
_ => MockReply::NoData,
})
.await;
let tracer = DnsTracer::new()
.with_timeout(Duration::from_millis(500))
.with_recursive_upstream(LOOPBACK, recursive);
let (addrs, mut notes) = tracer.resolve_glueless(&fq_name("ns.evil.test")).await;
assert!(addrs.is_empty(), "{addrs:?}");
notes.sort();
assert_eq!(
notes,
vec![
"ns.evil.test. (10.0.0.53): refused, private network (RFC 1918)",
"ns.evil.test. (169.254.169.254): refused, cloud metadata endpoint (169.254.169.254)",
"ns.evil.test. (::1): refused, IPv6 loopback (::1)",
]
);
let (addrs, notes) = tracer.resolve_glueless(&fq_name("ns.mixed.test")).await;
assert_eq!(addrs, vec![IpAddr::V4(public_v4), IpAddr::V6(public_v6)]);
assert_eq!(
notes,
vec!["ns.mixed.test. (10.0.0.53): refused, private network (RFC 1918)"]
);
}
#[tokio::test]
async fn a_glueless_lookup_takes_at_most_one_timeout() {
let recursive = spawn_mock_dns(MockMode::Ignore).await;
let timeout = Duration::from_millis(300);
let tracer = DnsTracer::new()
.with_timeout(timeout)
.with_recursive_upstream(LOOPBACK, recursive);
let started = Instant::now();
let (addrs, notes) = tracer.resolve_glueless(&fq_name("ns.dark.test")).await;
let elapsed = started.elapsed();
assert!(addrs.is_empty(), "{addrs:?}");
assert_eq!(
notes,
vec!["ns.dark.test.: glueless nameserver lookup failed: timed out"]
);
assert!(elapsed < timeout * 2, "took {elapsed:?}");
}
#[tokio::test]
async fn addresses_with_no_route_are_skipped_without_using_up_the_level() {
let unroutable: Vec<IpAddr> = (1..=4)
.map(|i| IpAddr::V4(Ipv4Addr::new(192, 0, 2, i)))
.collect();
let first_glue = unroutable[3];
let root = spawn_mock_dns_fn(move |_, _| MockReply::Delegation {
zone: "example.test".to_string(),
servers: vec![("ns1.example.test".to_string(), vec![first_glue, LOOPBACK])],
})
.await;
let auth = spawn_mock_dns_fn(|_, _| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 50))])
})
.await;
let trace = DnsTracer::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts()
.with_root_hints(&[
("a.root.test", unroutable[0]),
("b.root.test", unroutable[1]),
("c.root.test", unroutable[2]),
("d.root.test", LOOPBACK),
])
.with_port_map(HashMap::from([
("d.root.test".to_string(), root),
("ns1.example.test".to_string(), auth),
]))
.with_unroutable(&unroutable, network_unreachable)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert!(trace.error.is_none(), "{trace:#?}");
assert_eq!(trace.hops.len(), 2, "{trace:#?}");
let no_route = "no route from this host (Network is unreachable)";
let root = &trace.hops[0];
assert_eq!(root.server, "d.root.test.");
assert_eq!(
root.failed_servers,
vec![
format!("a.root.test. (192.0.2.1): {no_route}"),
format!("b.root.test. (192.0.2.2): {no_route}"),
format!("c.root.test. (192.0.2.3): {no_route}"),
]
);
let auth = &trace.hops[1];
assert_eq!(auth.server, "ns1.example.test.");
assert_eq!(auth.address, "127.0.0.1", "the server's next address");
assert_eq!(
auth.failed_servers,
vec![format!("ns1.example.test. (192.0.2.4): {no_route}")]
);
assert_eq!(trace.answers.len(), 1);
}
#[tokio::test]
async fn ipv6_only_servers_without_local_ipv6_do_not_use_up_the_level() {
let v6_only: Vec<IpAddr> = (1..=3)
.map(|i| IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, i)))
.collect();
let root =
spawn_mock_dns_fn(|_, _| delegation("example.test", &["ns1.example.test"])).await;
let auth = spawn_mock_dns_fn(|_, _| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 50))])
})
.await;
let trace = DnsTracer::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts()
.with_root_hints(&[
("a.root.test", v6_only[0]),
("b.root.test", v6_only[1]),
("c.root.test", v6_only[2]),
("d.root.test", LOOPBACK),
])
.with_port_map(HashMap::from([
("d.root.test".to_string(), root),
("ns1.example.test".to_string(), auth),
]))
.with_unroutable(&v6_only, address_family_unsupported)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert!(trace.error.is_none(), "{trace:#?}");
assert_eq!(trace.hops.len(), 2, "{trace:#?}");
let no_socket = format!("no route from this host ({})", address_family_unsupported());
let root = &trace.hops[0];
assert_eq!(root.server, "d.root.test.");
assert_eq!(
root.failed_servers,
vec![
format!("a.root.test. (2001:db8::1): {no_socket}"),
format!("b.root.test. (2001:db8::2): {no_socket}"),
format!("c.root.test. (2001:db8::3): {no_socket}"),
]
);
assert_eq!(trace.answers.len(), 1);
}
#[tokio::test]
async fn a_lame_servers_empty_reply_falls_back_to_the_next_server() {
let root = spawn_mock_dns_fn(|_, _| {
delegation("example.test", &["ns1.example.test", "ns2.example.test"])
})
.await;
let lame = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
let auth = spawn_mock_dns_fn(|_, _| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 60))])
})
.await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.example.test", lame),
("ns2.example.test", auth),
],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert!(trace.error.is_none(), "{trace:#?}");
assert_eq!(trace.hops.len(), 2);
let last = &trace.hops[1];
assert_eq!(last.server, "ns2.example.test.");
assert!(last.authoritative);
assert_eq!(
last.failed_servers,
vec![
"ns1.example.test. (127.0.0.1): empty non-authoritative response (no answer, referral or SOA)"
]
);
assert_eq!(trace.status, DnsStatus::NoError);
assert_eq!(trace.answers.len(), 1);
}
#[tokio::test]
async fn a_zone_of_lame_servers_is_an_error_not_a_negative_answer() {
let root = spawn_mock_dns_fn(|_, _| {
delegation("example.test", &["ns1.example.test", "ns2.example.test"])
})
.await;
let empty = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
let bare_nxdomain = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.example.test", empty),
("ns2.example.test", bare_nxdomain),
],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 2);
let last = &trace.hops[1];
assert_eq!(last.server, "ns2.example.test.");
assert_eq!(last.status, DnsStatus::NxDomain);
assert_eq!(
last.failed_servers,
vec![
"ns1.example.test. (127.0.0.1): empty non-authoritative response (no answer, referral or SOA)"
]
);
let error = trace
.error
.expect("a lame zone proves nothing about the name");
assert!(error.contains("example.test."), "{error}");
assert!(error.contains("NXDOMAIN without an SOA"), "{error}");
}
#[tokio::test]
async fn upward_referral_from_every_server_stops_the_walk() {
let root = spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test"])).await;
let tld = spawn_mock_dns_fn(|_, _| delegation(".", &["a.root.test"])).await;
let trace = tracer(
&["a.root.test"],
&[("a.root.test", root), ("ns1.nic.test", tld)],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 2);
assert_eq!(trace.hops[1].referral_zone.as_deref(), Some("."));
assert_eq!(trace.status, DnsStatus::NoError);
assert!(trace.answers.is_empty());
let error = trace.error.expect("an upward referral is an error");
assert!(error.contains("upward"), "{error}");
assert!(error.contains("ns1.nic.test."), "{error}");
}
#[tokio::test]
async fn upward_referral_from_a_lame_server_asks_the_next() {
let root =
spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test", "ns2.nic.test"])).await;
let lame = spawn_mock_dns_fn(|_, _| delegation(".", &["a.root.test"])).await;
let healthy =
spawn_mock_dns_fn(|_, _| delegation("example.test", &["ns1.example.test"])).await;
let auth =
spawn_mock_dns_fn(|_, _| MockReply::AuthoritativeAnswer(vec![a_rdata([192, 0, 2, 1])]))
.await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.nic.test", lame),
("ns2.nic.test", healthy),
("ns1.example.test", auth),
],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert!(trace.error.is_none(), "{:?}", trace.error);
assert_eq!(trace.hops.len(), 3);
let tld_hop = &trace.hops[1];
assert_eq!(tld_hop.server, "ns2.nic.test.");
assert_eq!(tld_hop.referral_zone.as_deref(), Some("example.test."));
assert!(
tld_hop
.failed_servers
.iter()
.any(|f| f.starts_with("ns1.nic.test.") && f.contains("referred upward")),
"{:?}",
tld_hop.failed_servers
);
assert_eq!(trace.answers.len(), 1);
}
#[tokio::test]
async fn sideways_referral_stops_the_walk() {
let root = spawn_mock_dns_fn(|_, _| delegation("test", &["ns1.nic.test"])).await;
let tld = spawn_mock_dns_fn(|_, _| delegation("other.test", &["ns1.other.test"])).await;
let trace = tracer(
&["a.root.test"],
&[("a.root.test", root), ("ns1.nic.test", tld)],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 2);
assert_eq!(trace.hops[1].referral_zone.as_deref(), Some("other.test."));
let error = trace.error.expect("a sideways referral is an error");
assert!(error.contains("sideways"), "{error}");
}
#[tokio::test]
async fn walk_gives_up_after_the_hop_limit() {
let labels: Vec<String> = (1..=20)
.map(|i| format!("l{i}"))
.chain(["test".into()])
.collect();
let qname = labels.join(".");
let zone_at = |depth: usize| labels[labels.len() - depth..].join(".");
let zones: Vec<String> = (1..=labels.len()).map(zone_at).collect();
let mut depth = 0;
let chain = zones.clone();
let server = spawn_mock_dns_fn(move |_, _| {
let zone = chain[depth.min(chain.len() - 1)].clone();
depth += 1;
MockReply::Delegation {
servers: vec![(format!("ns.{zone}"), vec![LOOPBACK])],
zone,
}
})
.await;
let ns_hosts: Vec<String> = zones.iter().map(|zone| format!("ns.{zone}")).collect();
let mut ports: Vec<(&str, u16)> = ns_hosts.iter().map(|h| (h.as_str(), server)).collect();
ports.push(("a.root.test", server));
let trace = tracer(&["a.root.test"], &ports)
.trace(&qname, RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), MAX_HOPS);
let error = trace.error.expect("the walk must give up");
assert!(error.contains(&MAX_HOPS.to_string()), "{error}");
assert_eq!(trace.hops[0].zone, ".");
assert_eq!(trace.hops[1].zone, "test.");
assert_eq!(trace.hops[2].zone, "l20.test.");
}
#[tokio::test]
async fn the_whole_walk_is_held_to_one_deadline() {
let timeout = Duration::from_millis(100);
let depth = TRACE_BUDGET_TIMEOUTS as usize + 2;
let labels: Vec<String> = (1..=depth)
.map(|i| format!("l{i}"))
.chain(["test".into()])
.collect();
let qname = labels.join(".");
let zones: Vec<String> = (1..=labels.len())
.map(|n| labels[labels.len() - n..].join("."))
.collect();
let dark = spawn_mock_dns(MockMode::Ignore).await;
let mut asked = 0;
let chain = zones.clone();
let live = spawn_mock_dns_fn(move |_, _| {
let reply = match chain.get(asked) {
Some(zone) => MockReply::Delegation {
zone: zone.clone(),
servers: vec![
(format!("ns1.{zone}"), vec![LOOPBACK]),
(format!("ns2.{zone}"), vec![LOOPBACK]),
],
},
None => MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 40))]),
};
asked += 1;
reply
})
.await;
let ns_hosts: Vec<(String, u16)> = zones
.iter()
.flat_map(|zone| [(format!("ns1.{zone}"), dark), (format!("ns2.{zone}"), live)])
.collect();
let mut ports: Vec<(&str, u16)> = ns_hosts.iter().map(|(h, p)| (h.as_str(), *p)).collect();
ports.extend([("a.root.test", dark), ("b.root.test", live)]);
let started = Instant::now();
let trace = tracer(&["a.root.test", "b.root.test"], &ports)
.with_timeout(timeout)
.trace(&qname, RecordType::A)
.await
.unwrap();
let elapsed = started.elapsed();
let budget = timeout * TRACE_BUDGET_TIMEOUTS;
assert!(elapsed < budget + timeout, "took {elapsed:?}");
let error = trace.error.expect("the walk must run out of time");
assert!(error.starts_with("gave up after 600ms"), "{error}");
assert!(!trace.hops.is_empty());
assert!(trace.hops.iter().all(|hop| hop.referral_zone.is_some()));
assert!(trace.answers.is_empty());
let asking = zones[trace.hops.len() - 1].clone();
assert!(
error.ends_with(&format!("nameservers of {asking}.")),
"{error}"
);
}
#[tokio::test]
async fn unresponsive_server_falls_back_to_the_next_one() {
let dark = spawn_mock_dns(MockMode::Ignore).await;
let live = spawn_mock_dns_fn(|_, _| {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 30))])
})
.await;
let trace = tracer(
&["a.root.test", "b.root.test"],
&[("a.root.test", dark), ("b.root.test", live)],
)
.trace("example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 1);
let hop = &trace.hops[0];
assert_eq!(hop.server, "b.root.test.");
assert_eq!(
hop.failed_servers,
vec!["a.root.test. (127.0.0.1): timed out"]
);
assert_eq!(trace.answers.len(), 1);
assert!(trace.error.is_none());
}
#[tokio::test]
async fn every_server_refusing_ends_with_the_refusal_as_the_final_hop() {
let root = spawn_mock_dns_fn(|_, _| {
delegation("example.test", &["ns1.example.test", "ns2.example.test"])
})
.await;
let refused = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::Refused)).await;
let servfail = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::ServFail)).await;
let trace = tracer(
&["a.root.test"],
&[
("a.root.test", root),
("ns1.example.test", servfail),
("ns2.example.test", refused),
],
)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 2);
let last = &trace.hops[1];
assert_eq!(last.server, "ns2.example.test.");
assert_eq!(last.status, DnsStatus::Refused);
assert_eq!(
last.failed_servers,
vec!["ns1.example.test. (127.0.0.1): SERVFAIL"]
);
assert_eq!(trace.status, DnsStatus::Refused);
let error = trace.error.expect("no usable response is an error");
assert!(error.contains("example.test."), "{error}");
assert!(
error.contains("REFUSED") && error.contains("SERVFAIL"),
"{error}"
);
}
#[tokio::test]
async fn no_responsive_root_is_an_error() {
let dark = spawn_mock_dns(MockMode::Ignore).await;
let err = tracer(&["a.root.test"], &[("a.root.test", dark)])
.trace("example.test", RecordType::A)
.await
.expect_err("nothing to trace without a root response");
assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
assert!(err.to_string().contains("timed out"), "{err}");
}
#[tokio::test]
async fn ptr_trace_of_an_ip_literal_uses_the_reverse_name() {
let asked = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&asked);
let root = spawn_mock_dns_fn(move |qname, _| {
log.lock().unwrap().push(qname.to_string());
MockReply::AuthoritativeAnswer(vec![HickoryRData::PTR(wire::PTR(fq_name(
"ptr.seer.test",
)))])
})
.await;
let trace = tracer(&["a.root.test"], &[("a.root.test", root)])
.trace("192.0.2.1", RecordType::PTR)
.await
.unwrap();
assert_eq!(trace.name, "1.2.0.192.in-addr.arpa");
assert_eq!(trace.answers.len(), 1);
assert_eq!(trace.answers[0].name, "1.2.0.192.in-addr.arpa");
assert_eq!(trace.answers[0].record_type, RecordType::PTR);
assert!(asked
.lock()
.unwrap()
.iter()
.all(|q| q == "1.2.0.192.in-addr.arpa"));
}
#[tokio::test]
async fn truncated_reply_is_retried_over_tcp() {
let mut served = 0;
let root = spawn_mock_dns_fn_with_tcp(move |_, _| {
served += 1;
if served == 1 {
MockReply::Truncated
} else {
MockReply::AuthoritativeAnswer(vec![a_rdata(Ipv4Addr::new(192, 0, 2, 40))])
}
})
.await;
let trace = tracer(&["a.root.test"], &[("a.root.test", root)])
.trace("example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 1);
assert!(trace.hops[0].authoritative);
assert_eq!(trace.answers.len(), 1, "{trace:#?}");
}
#[tokio::test]
async fn answers_convert_the_service_binding_and_child_dnssec_types() {
use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, CDS};
use hickory_resolver::proto::dnssec::{Algorithm, DigestType};
use hickory_resolver::proto::rr::rdata::svcb::{Alpn, SvcParamKey, SvcParamValue, SVCB};
let tracer = three_level_tracer(|qname| match qname {
"www.example.test" => {
MockReply::AuthoritativeAnswer(vec![HickoryRData::HTTPS(wire::HTTPS(SVCB::new(
1,
Name::root(),
vec![(
SvcParamKey::Alpn,
SvcParamValue::Alpn(Alpn(vec!["h2".to_string()])),
)],
)))])
}
_ => MockReply::AuthoritativeAnswer(vec![HickoryRData::DNSSEC(DNSSECRData::CDS(
CDS::new(
2371,
Some(Algorithm::ED25519),
DigestType::SHA256,
vec![0xAB],
),
))]),
})
.await;
let https = tracer
.trace("www.example.test", RecordType::HTTPS)
.await
.unwrap();
assert_eq!(https.answers.len(), 1, "{https:#?}");
assert_eq!(https.answers[0].name, "www.example.test");
assert_eq!(https.answers[0].record_type, RecordType::HTTPS);
assert_eq!(https.answers[0].data.to_string(), "1 . alpn=\"h2\"");
assert_eq!(https.hops[2].answers, https.answers);
let cds = tracer.trace("example.test", RecordType::CDS).await.unwrap();
assert_eq!(cds.answers.len(), 1, "{cds:#?}");
assert_eq!(cds.answers[0].record_type, RecordType::CDS);
assert_eq!(cds.answers[0].data.to_string(), "2371 15 2 AB");
}
#[tokio::test]
async fn at_most_three_servers_are_asked_per_level() {
let roots = ["a.root.test", "b.root.test", "c.root.test", "d.root.test"];
let mut ports = Vec::new();
let mut hits = Vec::new();
for _ in roots {
let count = Arc::new(Mutex::new(0usize));
let log = Arc::clone(&count);
ports.push(
spawn_mock_dns_fn(move |_, _| {
*log.lock().unwrap() += 1;
MockReply::NoReply
})
.await,
);
hits.push(count);
}
let map: Vec<(&str, u16)> = roots.iter().copied().zip(ports).collect();
let err = tracer(&roots, &map)
.with_timeout(Duration::from_millis(200))
.trace("example.test", RecordType::A)
.await
.expect_err("no root answered");
let message = err.to_string();
for root in &roots[..3] {
assert!(message.contains(root), "{message}");
}
assert!(!message.contains("d.root.test"), "{message}");
let hits: Vec<usize> = hits.iter().map(|count| *count.lock().unwrap()).collect();
assert!(hits[..3].iter().all(|&n| n > 0), "{hits:?}");
assert_eq!(hits[3], 0, "the fourth server must not be asked: {hits:?}");
}
#[tokio::test]
async fn at_most_three_glueless_nameservers_are_looked_up_per_level() {
let root = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
zone: "test".to_string(),
servers: (1..=4)
.map(|i| (format!("ns{i}.nowhere.test"), vec![]))
.collect(),
})
.await;
let looked_up = Arc::new(Mutex::new(std::collections::BTreeSet::new()));
let log = Arc::clone(&looked_up);
let recursive = spawn_mock_dns_fn(move |qname, _| {
log.lock().unwrap().insert(qname.to_string());
MockReply::NxDomain
})
.await;
let trace = tracer(&["a.root.test"], &[("a.root.test", root)])
.with_recursive_upstream(LOOPBACK, recursive)
.trace("www.example.test", RecordType::A)
.await
.unwrap();
assert_eq!(trace.hops.len(), 1);
let error = trace
.error
.expect("no nameserver of `test.` has an address");
assert!(!error.contains("ns4.nowhere.test"), "{error}");
let looked_up: Vec<String> = looked_up.lock().unwrap().iter().cloned().collect();
assert_eq!(
looked_up,
["ns1.nowhere.test", "ns2.nowhere.test", "ns3.nowhere.test"],
"sorted referral order, capped at three"
);
}
#[tokio::test]
async fn trace_rejects_any_and_bad_names_before_sending() {
let tracer = tracer(&["a.root.test"], &[]);
assert!(matches!(
tracer.trace("example.test", RecordType::ANY).await,
Err(SeerError::InvalidInput(_))
));
assert!(tracer.trace("not a name", RecordType::A).await.is_err());
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn test_live_trace_example_com() {
let trace = DnsTracer::new()
.trace("example.com", RecordType::A)
.await
.unwrap();
assert!(trace.error.is_none(), "{trace:#?}");
assert!(trace.hops.len() >= 3, "{trace:#?}");
assert_eq!(trace.hops[0].zone, ".");
assert_eq!(trace.hops[1].zone, "com.");
let last = trace.hops.last().unwrap();
assert_eq!(last.zone, "example.com.");
assert!(last.authoritative);
assert_eq!(trace.status, DnsStatus::NoError);
assert!(!trace.answers.is_empty());
}
}