use std::borrow::Cow;
use std::net::IpAddr;
use std::pin::pin;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::future::{self, Either};
use hickory_resolver::config::{
NameServerConfig, ResolveHosts, ResolverConfig, ResolverOpts, ServerOrderingStrategy, GOOGLE,
};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::NetError;
use hickory_resolver::proto::dnssec::PublicKey;
use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue, SVCB};
use hickory_resolver::proto::rr::rdata::CAA;
use hickory_resolver::proto::rr::{
Name, RData as HickoryRData, Record, RecordType as HickoryRecordType,
};
use hickory_resolver::proto::serialize::binary::BinEncodable;
use hickory_resolver::TokioResolver;
use tracing::{debug, instrument};
use super::nameserver::{NameserverProtocol, NameserverSpec};
use super::query::{
answered_locally, dedupe_records, merge_any, random_probe_label, wildcard_outcome,
wildcard_probe_name, DnsQueryResult, Exchange, WildcardProbe,
};
use super::records::{DnsRecord, RecordData, RecordType, SvcParam};
use super::transport::Transport;
use crate::error::{Result, SeerError};
use crate::validation::{normalize_domain, normalize_query_name};
fn dns_lookup_or_empty<T>(
result: std::result::Result<T, NetError>,
record_type: &str,
) -> Result<Option<T>> {
match result {
Ok(response) => Ok(Some(response)),
Err(e) if e.is_no_records_found() => Ok(None),
Err(e) => Err(SeerError::DnsError(format!(
"{} lookup failed: {}",
record_type, e
))),
}
}
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const ANY_TYPES: [RecordType; 11] = [
RecordType::A,
RecordType::AAAA,
RecordType::CNAME,
RecordType::MX,
RecordType::NS,
RecordType::TXT,
RecordType::SOA,
RecordType::CAA,
RecordType::HTTPS,
RecordType::DS,
RecordType::DNSKEY,
];
pub(crate) fn apply_standard_opts(opts: &mut ResolverOpts, timeout: Duration) {
opts.timeout = timeout;
opts.attempts = 2;
opts.use_hosts_file = ResolveHosts::Never;
opts.server_ordering_strategy = ServerOrderingStrategy::UserProvidedOrder;
}
fn build_resolver(config: ResolverConfig, timeout: Duration) -> Result<TokioResolver> {
let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
apply_standard_opts(builder.options_mut(), timeout);
builder
.build()
.map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
}
fn build_default_resolver(timeout: Duration) -> TokioResolver {
build_resolver(default_upstream_config(), timeout)
.expect("default resolver build cannot fail with the bundled webpki root store")
}
fn default_upstream_config() -> ResolverConfig {
ResolverConfig::udp_and_tcp(&GOOGLE)
}
pub(crate) fn single_server_config(ip: IpAddr, port: u16, tcp_fallback: bool) -> ResolverConfig {
let mut ns = if tcp_fallback {
NameServerConfig::udp_and_tcp(ip)
} else {
NameServerConfig::udp(ip)
};
for connection in &mut ns.connections {
connection.port = port;
}
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
config.add_name_server(ns);
config
}
pub(crate) fn google_or_pinned(upstream: Option<(IpAddr, u16)>) -> ResolverConfig {
match upstream {
None => default_upstream_config(),
Some((ip, port)) => single_server_config(ip, port, false),
}
}
pub(crate) fn fqdn(name: &str) -> String {
if name.ends_with('.') {
name.to_string()
} else {
format!("{name}.")
}
}
fn build_upstream_config(
spec: &NameserverSpec,
ips: &[IpAddr],
port_override: Option<u16>,
) -> ResolverConfig {
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
let port = port_override.unwrap_or(spec.port);
let ordered = ips
.iter()
.filter(|ip| ip.is_ipv4())
.chain(ips.iter().filter(|ip| ip.is_ipv6()));
for ip in ordered {
let mut ns = match spec.protocol {
NameserverProtocol::Udp => NameServerConfig::udp(*ip),
NameserverProtocol::Tls => NameServerConfig::tls(*ip, Arc::from(spec.tls_name())),
NameserverProtocol::Https => NameServerConfig::https(
*ip,
Arc::from(spec.tls_name()),
spec.path.as_deref().map(Arc::from),
),
};
for connection in &mut ns.connections {
connection.port = port;
}
config.add_name_server(ns);
}
config
}
#[derive(Clone)]
pub struct DnsResolver {
timeout: Duration,
default_resolver: TokioResolver,
port_override: Option<u16>,
allow_private_hosts: bool,
#[cfg(test)]
test_default_nameserver: Option<String>,
}
impl std::fmt::Debug for DnsResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DnsResolver")
.field("timeout", &self.timeout)
.finish()
}
}
impl Default for DnsResolver {
fn default() -> Self {
Self::new()
}
}
impl DnsResolver {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
default_resolver: build_default_resolver(DEFAULT_TIMEOUT),
port_override: None,
allow_private_hosts: false,
#[cfg(test)]
test_default_nameserver: None,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.dns_timeout())
}
#[cfg(test)]
pub(crate) fn timeout(&self) -> Duration {
self.timeout
}
#[cfg(test)]
pub(crate) fn allowing_private_hosts(mut self) -> Self {
self.allow_private_hosts = true;
self
}
#[cfg(test)]
pub(crate) fn with_port(mut self, port: u16) -> Self {
self.port_override = Some(port);
self
}
#[cfg(test)]
pub(crate) fn with_default_nameserver(mut self, nameserver: &str) -> Self {
self.test_default_nameserver = Some(nameserver.to_string());
self
}
#[cfg(test)]
fn effective_nameserver<'a>(&'a self, nameserver: Option<&'a str>) -> Option<&'a str> {
nameserver.or(self.test_default_nameserver.as_deref())
}
#[cfg(not(test))]
fn effective_nameserver<'a>(&self, nameserver: Option<&'a str>) -> Option<&'a str> {
nameserver
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.default_resolver = build_default_resolver(timeout);
self
}
async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
build_resolver(self.custom_upstream_config(nameserver).await?, self.timeout)
}
async fn custom_upstream_config(&self, nameserver: &str) -> Result<ResolverConfig> {
let spec = NameserverSpec::parse(nameserver)?;
let ips: Vec<IpAddr> = if let Ok(ip) = spec.host.parse::<IpAddr>() {
vec![ip]
} else {
let response = self
.default_resolver
.lookup_ip(spec.host.as_str())
.await
.map_err(|e| {
SeerError::DnsError(format!(
"failed to resolve nameserver hostname {}: {}",
spec.host, e
))
})?;
let resolved: Vec<IpAddr> = response.iter().collect();
if resolved.is_empty() {
return Err(SeerError::DnsError(format!(
"nameserver {} did not resolve to any addresses",
spec.host
)));
}
resolved
};
if !self.allow_private_hosts {
for ip in &ips {
if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
return Err(SeerError::DnsError(format!(
"nameserver {} blocked: {}",
nameserver, reason
)));
}
}
}
Ok(build_upstream_config(&spec, &ips, self.port_override))
}
async fn upstream(&self, nameserver: Option<&str>) -> Result<Cow<'_, TokioResolver>> {
Ok(match self.effective_nameserver(nameserver) {
Some(ns) => Cow::Owned(self.create_custom_resolver(ns).await?),
None => Cow::Borrowed(&self.default_resolver),
})
}
async fn upstream_config(&self, nameserver: Option<&str>) -> Result<ResolverConfig> {
match self.effective_nameserver(nameserver) {
Some(ns) => self.custom_upstream_config(ns).await,
None => Ok(default_upstream_config()),
}
}
#[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
pub async fn query(
&self,
domain: &str,
record_type: RecordType,
nameserver: Option<&str>,
) -> Result<DnsQueryResult> {
let domain = prepare_query(domain, record_type)?;
let name = wire_query_name(&domain, record_type)?;
let resolver;
let upstream = if answered_locally(&name) {
resolver = self.upstream(nameserver).await?;
Upstream::Local(&resolver)
} else {
Upstream::Direct {
config: self.upstream_config(nameserver).await?,
transport: Transport::new(self.timeout),
}
};
debug!(nameserver = nameserver.unwrap_or("system"), "Querying DNS");
let (exchange, query_time, wildcard) = if record_type == RecordType::ANY {
let started = Instant::now();
let exchange = exchange_any(&upstream, &name).await?;
(exchange, started.elapsed(), None)
} else {
exchange_with_probe(&upstream, &name, record_type).await?
};
Ok(exchange.into_result(name, record_type, nameserver, wildcard, query_time))
}
#[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
pub async fn resolve(
&self,
domain: &str,
record_type: RecordType,
nameserver: Option<&str>,
) -> Result<Vec<DnsRecord>> {
let resolver = self.upstream(nameserver).await?;
let domain = prepare_query(domain, record_type)?;
debug!(nameserver = nameserver.unwrap_or("system"), "Resolving DNS");
match record_type {
RecordType::SRV => match parse_srv_query(&domain) {
Some((service, protocol, name)) => {
self.resolve_srv_core(&resolver, &service, &protocol, &name)
.await
}
None => Err(srv_format_error()),
},
RecordType::ANY => self.resolve_any(&resolver, &domain).await,
single => self.resolve_type(&resolver, &domain, single).await,
}
}
async fn resolve_srv_core(
&self,
resolver: &TokioResolver,
service: &str,
protocol: &str,
domain: &str,
) -> Result<Vec<DnsRecord>> {
let query_name = srv_query_name(service, protocol, domain)?;
self.resolve_records(resolver, &query_name, RecordType::SRV)
.await
}
async fn resolve_type(
&self,
resolver: &TokioResolver,
domain: &str,
record_type: RecordType,
) -> Result<Vec<DnsRecord>> {
match record_type {
RecordType::SRV | RecordType::ANY => Err(unsupported_record_type(record_type)),
RecordType::PTR => {
self.resolve_records(resolver, &ptr_query_name(domain), RecordType::PTR)
.await
}
single => self.resolve_records(resolver, domain, single).await,
}
}
async fn resolve_records(
&self,
resolver: &TokioResolver,
domain: &str,
record_type: RecordType,
) -> Result<Vec<DnsRecord>> {
let Some(wire_type) = wire_type(record_type) else {
return Err(unsupported_record_type(record_type));
};
let Some(response) = dns_lookup_or_empty(
resolver.lookup(domain, wire_type).await,
&record_type.to_string(),
)?
else {
return Ok(vec![]);
};
let mut records: Vec<DnsRecord> = response
.answers()
.iter()
.filter_map(|record| {
convert_rdata(record_type, &record.data, NameSpelling::Display).map(|data| {
DnsRecord {
name: domain.to_string(),
record_type,
ttl: record.ttl,
data,
}
})
})
.collect();
if record_type == RecordType::MX {
records.sort_by_key(|r| match &r.data {
RecordData::MX { preference, .. } => *preference,
_ => 0,
});
}
Ok(records)
}
async fn resolve_any(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
let results = future::join_all(
ANY_TYPES
.into_iter()
.map(|record_type| self.resolve_type(resolver, domain, record_type)),
)
.await;
let mut all_records = Vec::new();
let mut any_ok = false;
let mut last_err = None;
for result in results {
match result {
Ok(records) => {
any_ok = true;
all_records.extend(records);
}
Err(e) => last_err = Some(e),
}
}
match last_err {
Some(e) if !any_ok => Err(e),
_ => Ok(dedupe_records(all_records)),
}
}
}
enum Upstream<'a> {
Local(&'a TokioResolver),
Direct {
config: ResolverConfig,
transport: Transport,
},
}
impl Upstream<'_> {
async fn exchange(&self, name: &str, record_type: RecordType) -> Result<Exchange> {
let wire = wire_type(record_type).ok_or_else(|| unsupported_record_type(record_type))?;
let exchange = match self {
Upstream::Local(resolver) => {
Exchange::from_local(resolver.lookup(name, wire).await, record_type)?
}
Upstream::Direct { config, transport } => {
let qname = Name::from_ascii(fqdn(name))
.map_err(|e| SeerError::InvalidDomain(format!("{name}: {e}")))?;
let request = transport.request(qname, wire, true);
let response = transport
.query(config.name_servers(), &request)
.await
.map_err(|why| {
SeerError::DnsError(format!("{record_type} lookup failed: {why}"))
})?;
Exchange::from_message(&response)
}
};
Ok(exchange.ordered(name, record_type))
}
}
async fn exchange_any(upstream: &Upstream<'_>, name: &str) -> Result<Exchange> {
let results = future::join_all(
ANY_TYPES
.into_iter()
.map(|record_type| upstream.exchange(name, record_type)),
)
.await;
merge_any(results)
}
async fn exchange_with_probe(
upstream: &Upstream<'_>,
name: &str,
record_type: RecordType,
) -> Result<(Exchange, Duration, Option<WildcardProbe>)> {
let main = async {
let started = Instant::now();
let result = upstream.exchange(name, record_type).await;
(result, started.elapsed())
};
let Some(probe_name) = wildcard_probe_name(name, record_type, &random_probe_label()) else {
let (result, elapsed) = main.await;
return Ok((result?, elapsed, None));
};
let probe = upstream.exchange(&probe_name, record_type);
let main = pin!(main);
let probe = pin!(probe);
let ((result, elapsed), probe) = match future::select(main, probe).await {
Either::Left(((result, elapsed), probe)) => {
let attachable = matches!(&result, Ok(answer) if answer.has_records(record_type));
let probe = if attachable { Some(probe.await) } else { None };
((result, elapsed), probe)
}
Either::Right((probe, main)) => (main.await, Some(probe)),
};
let answer = result?;
let wildcard =
probe.and_then(|probe| wildcard_outcome(&probe_name, record_type, &answer, probe));
Ok((answer, elapsed, wildcard))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DnsPresence {
Present,
Absent,
Unknown,
}
fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
match result {
Ok(records) if records.is_empty() => DnsPresence::Absent,
Ok(_) => DnsPresence::Present,
Err(_) => DnsPresence::Unknown,
}
}
impl DnsResolver {
pub async fn presence(&self, domain: &str) -> DnsPresence {
let Ok(apex) = normalize_domain(domain) else {
return DnsPresence::Unknown;
};
classify_ns_presence(&self.resolve(&apex, RecordType::NS, None).await)
}
}
pub(crate) fn prepare_query(domain: &str, record_type: RecordType) -> Result<String> {
prepare_query_with(domain, record_type, normalize_query_name)
}
fn prepare_query_with(
domain: &str,
record_type: RecordType,
normalize: impl Fn(&str) -> Result<String>,
) -> Result<String> {
if record_type == RecordType::PTR {
if let Ok(ip) = IpAddr::from_str(domain.trim()) {
normalize(&reverse_dns_name(&ip))?;
return Ok(ip.to_string());
}
}
normalize(domain)
}
pub(crate) fn parse_srv_query(name: &str) -> Option<(String, String, String)> {
let mut parts = name.splitn(3, '.');
let service = parts.next()?.strip_prefix('_')?;
let protocol = parts.next()?.strip_prefix('_')?;
let rest = parts.next()?;
if service.is_empty() || protocol.is_empty() || rest.is_empty() {
return None;
}
Some((service.to_string(), protocol.to_string(), rest.to_string()))
}
fn srv_query_name(service: &str, protocol: &str, domain: &str) -> Result<String> {
if !is_valid_srv_label(service) {
return Err(SeerError::InvalidInput(format!(
"invalid SRV service name: {}",
service
)));
}
if !is_valid_srv_label(protocol) {
return Err(SeerError::InvalidInput(format!(
"invalid SRV protocol name: {}",
protocol
)));
}
Ok(format!("_{}._{}.{}", service, protocol, domain))
}
fn ptr_query_name(domain: &str) -> String {
match IpAddr::from_str(domain) {
Ok(ip) => reverse_dns_name(&ip),
Err(_) => domain.to_string(),
}
}
pub(crate) fn wire_query_name(domain: &str, record_type: RecordType) -> Result<String> {
match record_type {
RecordType::SRV => {
let (service, protocol, name) = parse_srv_query(domain).ok_or_else(srv_format_error)?;
srv_query_name(&service, &protocol, &name)
}
RecordType::PTR => Ok(ptr_query_name(domain)),
_ => Ok(domain.to_string()),
}
}
pub(crate) fn srv_format_error() -> SeerError {
SeerError::InvalidInput(
"SRV records require service name format: _service._proto.name".to_string(),
)
}
fn reverse_dns_name(ip: &IpAddr) -> String {
match ip {
IpAddr::V4(addr) => {
let octets = addr.octets();
format!(
"{}.{}.{}.{}.in-addr.arpa",
octets[3], octets[2], octets[1], octets[0]
)
}
IpAddr::V6(addr) => {
let segments = addr.segments();
let mut result = String::with_capacity(72);
let mut first = true;
for segment in segments.iter().rev() {
for shift in [0, 4, 8, 12] {
if !first {
result.push('.');
}
first = false;
let nibble = (segment >> shift) & 0xF;
result
.push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
}
}
result.push_str(".ip6.arpa");
result
}
}
}
fn parse_caa(caa: &CAA) -> (u8, String, String) {
let flags = caa.flags();
let tag = caa.tag.clone();
let value = String::from_utf8_lossy(&caa.value).to_string();
(flags, tag, value)
}
fn parse_svcb(svcb: &SVCB, names: NameSpelling) -> (u16, String, Vec<SvcParam>) {
let params = svcb
.svc_params
.iter()
.map(|(key, value)| SvcParam {
key: svc_param_key_name(*key),
value: svc_param_value(value),
})
.collect();
(svcb.svc_priority, names.spell(&svcb.target_name), params)
}
fn svc_param_key_name(key: SvcParamKey) -> String {
match key {
SvcParamKey::Mandatory => "mandatory".to_string(),
SvcParamKey::Alpn => "alpn".to_string(),
SvcParamKey::NoDefaultAlpn => "no-default-alpn".to_string(),
SvcParamKey::Port => "port".to_string(),
SvcParamKey::Ipv4Hint => "ipv4hint".to_string(),
SvcParamKey::EchConfigList => "ech".to_string(),
SvcParamKey::Ipv6Hint => "ipv6hint".to_string(),
SvcParamKey::Key(_) | SvcParamKey::Key65535 | SvcParamKey::Unknown(_) => {
format!("key{}", u16::from(key))
}
}
}
fn svc_param_value(value: &SvcParamValue) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
fn join<T: std::fmt::Display>(items: impl Iterator<Item = T>) -> String {
items
.map(|item| item.to_string())
.collect::<Vec<_>>()
.join(",")
}
match value {
SvcParamValue::Mandatory(keys) => join(keys.0.iter().map(|k| svc_param_key_name(*k))),
SvcParamValue::Alpn(alpn) => join(alpn.0.iter().map(|id| {
let listed = id.replace('\\', "\\\\").replace(',', "\\,");
escape_char_string(listed.as_bytes())
})),
SvcParamValue::NoDefaultAlpn => String::new(),
SvcParamValue::Port(port) => port.to_string(),
SvcParamValue::Ipv4Hint(hint) => join(hint.0.iter().map(|a| a.0)),
SvcParamValue::EchConfigList(ech) => STANDARD.encode(&ech.0),
SvcParamValue::Ipv6Hint(hint) => join(hint.0.iter().map(|aaaa| aaaa.0)),
SvcParamValue::Unknown(opaque) => escape_char_string(&opaque.0),
}
}
fn escape_char_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
match b {
b'"' | b'\\' => {
out.push('\\');
out.push(char::from(b));
}
0x21..=0x7E => out.push(char::from(b)),
_ => out.push_str(&format!("\\{b:03}")),
}
}
out
}
pub(crate) fn wire_type(record_type: RecordType) -> Option<HickoryRecordType> {
Some(match record_type {
RecordType::A => HickoryRecordType::A,
RecordType::AAAA => HickoryRecordType::AAAA,
RecordType::CNAME => HickoryRecordType::CNAME,
RecordType::MX => HickoryRecordType::MX,
RecordType::NS => HickoryRecordType::NS,
RecordType::TXT => HickoryRecordType::TXT,
RecordType::SOA => HickoryRecordType::SOA,
RecordType::PTR => HickoryRecordType::PTR,
RecordType::SRV => HickoryRecordType::SRV,
RecordType::CAA => HickoryRecordType::CAA,
RecordType::DNSKEY => HickoryRecordType::DNSKEY,
RecordType::DS => HickoryRecordType::DS,
RecordType::CDS => HickoryRecordType::CDS,
RecordType::CDNSKEY => HickoryRecordType::CDNSKEY,
RecordType::HTTPS => HickoryRecordType::HTTPS,
RecordType::SVCB => HickoryRecordType::SVCB,
RecordType::TLSA => HickoryRecordType::TLSA,
RecordType::SSHFP => HickoryRecordType::SSHFP,
RecordType::NAPTR => HickoryRecordType::NAPTR,
RecordType::ANY => return None,
})
}
fn unsupported_record_type(record_type: RecordType) -> SeerError {
SeerError::DnsError(format!("unsupported record type: {}", record_type))
}
fn hex_upper(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02X}", b)).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NameSpelling {
Display,
Ascii,
}
impl NameSpelling {
fn spell(self, name: &Name) -> String {
match self {
NameSpelling::Display => name.to_string(),
NameSpelling::Ascii => name.to_ascii(),
}
}
}
pub(crate) fn convert_rdata(
record_type: RecordType,
data: &HickoryRData,
names: NameSpelling,
) -> Option<RecordData> {
use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
match (record_type, data) {
(RecordType::A, HickoryRData::A(addr)) => Some(RecordData::A {
address: addr.0.to_string(),
}),
(RecordType::AAAA, HickoryRData::AAAA(addr)) => Some(RecordData::AAAA {
address: addr.0.to_string(),
}),
(RecordType::CNAME, HickoryRData::CNAME(cname)) => Some(RecordData::CNAME {
target: names.spell(&cname.0),
}),
(RecordType::MX, HickoryRData::MX(mx)) => Some(RecordData::MX {
preference: mx.preference,
exchange: names.spell(&mx.exchange),
}),
(RecordType::NS, HickoryRData::NS(ns)) => Some(RecordData::NS {
nameserver: names.spell(&ns.0),
}),
(RecordType::TXT, HickoryRData::TXT(txt)) => Some(RecordData::TXT {
text: txt
.txt_data
.iter()
.map(|data| String::from_utf8_lossy(data).to_string())
.collect::<Vec<_>>()
.join(""),
}),
(RecordType::SOA, HickoryRData::SOA(soa)) => Some(RecordData::SOA {
mname: names.spell(&soa.mname),
rname: names.spell(&soa.rname),
serial: soa.serial,
refresh: soa.refresh as u32,
retry: soa.retry as u32,
expire: soa.expire as u32,
minimum: soa.minimum,
}),
(RecordType::PTR, HickoryRData::PTR(ptr)) => Some(RecordData::PTR {
target: names.spell(&ptr.0),
}),
(RecordType::SRV, HickoryRData::SRV(srv)) => Some(RecordData::SRV {
priority: srv.priority,
weight: srv.weight,
port: srv.port,
target: names.spell(&srv.target),
}),
(RecordType::CAA, HickoryRData::CAA(caa)) => {
let (flags, tag, value) = parse_caa(caa);
Some(RecordData::CAA { flags, tag, value })
}
(RecordType::DNSKEY, HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey))) => {
use base64::{engine::general_purpose::STANDARD, Engine};
let public_key_buf = dnskey.public_key();
Some(RecordData::DNSKEY {
flags: dnskey.flags(),
protocol: 3,
algorithm: u8::from(public_key_buf.algorithm()),
public_key: STANDARD.encode(public_key_buf.public_bytes()),
})
}
(RecordType::DS, HickoryRData::DNSSEC(DNSSECRData::DS(ds))) => Some(RecordData::DS {
key_tag: ds.key_tag(),
algorithm: u8::from(ds.algorithm()),
digest_type: u8::from(ds.digest_type()),
digest: hex_upper(ds.digest()),
}),
(RecordType::CDS, HickoryRData::DNSSEC(DNSSECRData::CDS(cds))) => Some(RecordData::CDS {
key_tag: cds.key_tag(),
algorithm: cds.algorithm().map_or(0, u8::from),
digest_type: u8::from(cds.digest_type()),
digest: hex_upper(cds.digest()),
}),
(RecordType::CDNSKEY, HickoryRData::DNSSEC(DNSSECRData::CDNSKEY(cdnskey))) => {
use base64::{engine::general_purpose::STANDARD, Engine};
let rdata = cdnskey.to_bytes().ok()?;
Some(RecordData::CDNSKEY {
flags: cdnskey.flags(),
protocol: 3,
algorithm: cdnskey.algorithm().map_or(0, u8::from),
public_key: STANDARD.encode(rdata.get(4..)?),
})
}
(RecordType::HTTPS, HickoryRData::HTTPS(https)) => {
let (priority, target, params) = parse_svcb(&https.0, names);
Some(RecordData::HTTPS {
priority,
target,
params,
})
}
(RecordType::SVCB, HickoryRData::SVCB(svcb)) => {
let (priority, target, params) = parse_svcb(svcb, names);
Some(RecordData::SVCB {
priority,
target,
params,
})
}
(RecordType::TLSA, HickoryRData::TLSA(tlsa)) => Some(RecordData::TLSA {
cert_usage: u8::from(tlsa.cert_usage),
selector: u8::from(tlsa.selector),
matching: u8::from(tlsa.matching),
cert_data: hex_upper(&tlsa.cert_data),
}),
(RecordType::SSHFP, HickoryRData::SSHFP(sshfp)) => Some(RecordData::SSHFP {
algorithm: u8::from(sshfp.algorithm),
fingerprint_type: u8::from(sshfp.fingerprint_type),
fingerprint: hex_upper(&sshfp.fingerprint),
}),
(RecordType::NAPTR, HickoryRData::NAPTR(naptr)) => Some(RecordData::NAPTR {
order: naptr.order,
preference: naptr.preference,
flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
services: String::from_utf8_lossy(&naptr.services).into_owned(),
regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
replacement: names.spell(&naptr.replacement),
}),
_ => None,
}
}
pub(crate) fn from_wire_type(wire: HickoryRecordType) -> Option<RecordType> {
RecordType::ALL
.iter()
.copied()
.find(|t| wire_type(*t) == Some(wire))
}
pub(crate) fn to_dns_record(record: &Record) -> Option<DnsRecord> {
let record_type = from_wire_type(record.record_type())?;
let data = convert_rdata(record_type, &record.data, NameSpelling::Ascii)?;
Some(DnsRecord {
name: owner_name(&record.name),
record_type,
ttl: record.ttl,
data,
})
}
fn owner_name(name: &Name) -> String {
let text = name.to_ascii();
match text.strip_suffix('.') {
Some(rest) if !rest.is_empty() => rest.to_string(),
_ => text,
}
}
fn is_valid_srv_label(label: &str) -> bool {
!label.is_empty()
&& label.len() <= 63
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !label.starts_with('-')
&& !label.ends_with('-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_opts_pin_server_order_so_ipv4_is_tried_first() {
let mut opts = ResolverOpts::default();
apply_standard_opts(&mut opts, Duration::from_secs(5));
assert_eq!(
opts.server_ordering_strategy,
ServerOrderingStrategy::UserProvidedOrder,
"QueryStatistics ordering is nondeterministic in a short-lived \
process and can draw only black-holed IPv6 servers"
);
}
#[test]
fn default_config_lists_enough_ipv4_servers_before_any_ipv6() {
let config = ResolverConfig::udp_and_tcp(&GOOGLE);
let ips: Vec<_> = config.name_servers().iter().map(|ns| ns.ip).collect();
let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
assert!(
ips.len() > concurrent,
"expected more servers than the concurrency window: {ips:?}"
);
for (i, ip) in ips.iter().take(concurrent).enumerate() {
assert!(
ip.is_ipv4(),
"server {i} in the concurrency window is not IPv4: {ips:?}"
);
}
}
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
let resolver = DnsResolver::from_config(&config);
assert_eq!(resolver.timeout, Duration::from_secs(9));
}
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn record_type_from_str_accepts_lowercase() {
assert_eq!(RecordType::from_str("a").unwrap(), RecordType::A);
assert_eq!(RecordType::from_str("mx").unwrap(), RecordType::MX);
assert_eq!(RecordType::from_str("cname").unwrap(), RecordType::CNAME);
assert_eq!(RecordType::from_str("dnskey").unwrap(), RecordType::DNSKEY);
}
#[test]
fn record_type_from_str_accepts_mixed_case() {
assert_eq!(RecordType::from_str("Mx").unwrap(), RecordType::MX);
assert_eq!(RecordType::from_str("cNaMe").unwrap(), RecordType::CNAME);
}
#[test]
fn record_type_from_str_rejects_whitespace_padded() {
assert!(RecordType::from_str(" A").is_err());
assert!(RecordType::from_str("A ").is_err());
assert!(RecordType::from_str("\tA\n").is_err());
}
#[test]
fn record_type_from_str_rejects_unknown() {
assert!(RecordType::from_str("NOTAREAL").is_err());
assert!(RecordType::from_str("A1").is_err());
assert!(RecordType::from_str("").is_err());
}
#[test]
fn record_type_from_str_accepts_star_as_any() {
assert_eq!(RecordType::from_str("*").unwrap(), RecordType::ANY);
assert_eq!(RecordType::from_str("ANY").unwrap(), RecordType::ANY);
assert_eq!(RecordType::from_str("any").unwrap(), RecordType::ANY);
}
#[test]
fn srv_label_accepts_alphanumeric_and_hyphen() {
assert!(is_valid_srv_label("http"));
assert!(is_valid_srv_label("ldap-tls"));
assert!(is_valid_srv_label("a1"));
assert!(is_valid_srv_label("tcp"));
}
#[test]
fn srv_label_rejects_empty() {
assert!(!is_valid_srv_label(""));
}
#[test]
fn srv_label_rejects_leading_or_trailing_hyphen() {
assert!(!is_valid_srv_label("-http"));
assert!(!is_valid_srv_label("http-"));
assert!(!is_valid_srv_label("-"));
}
#[test]
fn srv_label_rejects_dots() {
assert!(!is_valid_srv_label("http.evil"));
assert!(!is_valid_srv_label("a.b"));
}
#[test]
fn srv_label_rejects_special_chars() {
assert!(!is_valid_srv_label("http evil"));
assert!(!is_valid_srv_label("http/evil"));
assert!(!is_valid_srv_label("http\0"));
assert!(!is_valid_srv_label("http\n"));
}
#[test]
fn srv_label_rejects_over_63_chars() {
let too_long = "a".repeat(64);
assert!(!is_valid_srv_label(&too_long));
let exactly_63 = "a".repeat(63);
assert!(is_valid_srv_label(&exactly_63));
}
#[test]
fn classify_ns_presence_absent_on_empty_ok() {
let r: Result<Vec<DnsRecord>> = Ok(vec![]);
assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
}
#[test]
fn classify_ns_presence_present_on_records() {
let rec = DnsRecord {
name: "example.test.".to_string(),
record_type: RecordType::NS,
ttl: 3600,
data: RecordData::NS {
nameserver: "ns1.example.net.".to_string(),
},
};
let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
}
#[test]
fn classify_ns_presence_unknown_on_error() {
let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
}
#[test]
fn reverse_dns_name_formats_ipv4_correctly() {
let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
}
#[test]
fn reverse_dns_name_formats_ipv6_correctly() {
let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
let name = reverse_dns_name(&ip);
assert!(
name.ends_with(".ip6.arpa"),
"must end with .ip6.arpa; got: {}",
name
);
assert!(
name.starts_with("1."),
"expected '1.' prefix, got: {}",
name
);
assert_eq!(name.len(), 72);
}
#[test]
fn resolver_new_has_default_timeout() {
let r = DnsResolver::new();
assert_eq!(r.timeout, DEFAULT_TIMEOUT);
}
#[test]
fn resolver_with_timeout_overrides_default() {
let custom = Duration::from_secs(42);
let r = DnsResolver::new().with_timeout(custom);
assert_eq!(r.timeout, custom);
}
#[test]
fn resolver_default_matches_new() {
let a = DnsResolver::default();
let b = DnsResolver::new();
assert_eq!(a.timeout, b.timeout);
}
#[tokio::test]
async fn custom_resolver_rejects_invalid_input() {
let r = DnsResolver::new();
let err = r.create_custom_resolver("..").await.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("dns resolution failed") || msg.contains("invalid"),
"expected resolution failure, got: {}",
msg
);
}
#[tokio::test]
async fn custom_resolver_rejects_private_ipv4() {
let r = DnsResolver::new();
for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
let err = r.create_custom_resolver(reserved).await.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("blocked") || msg.contains("reserved"),
"reserved IP {} must be rejected, got error: {}",
reserved,
msg
);
}
}
#[tokio::test]
async fn custom_resolver_rejects_loopback_ipv6() {
let r = DnsResolver::new();
let err = r.create_custom_resolver("::1").await.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("blocked") || msg.contains("reserved"),
"::1 must be rejected, got error: {}",
msg
);
}
#[tokio::test]
async fn custom_resolver_accepts_public_ipv4() {
let r = DnsResolver::new();
let result = r.create_custom_resolver("8.8.8.8").await;
assert!(
result.is_ok(),
"8.8.8.8 must be accepted as a public nameserver, got: {:?}",
result.err()
);
}
#[tokio::test]
async fn custom_resolver_rejects_private_ip_for_dot_and_doh() {
let r = DnsResolver::new();
for reserved in [
"tls://127.0.0.1",
"tls://192.168.1.1:853",
"tls://[::1]",
"https://10.0.0.1/dns-query",
"https://169.254.169.254",
"tls://[fd00::1]:853",
] {
let err = r.create_custom_resolver(reserved).await.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("blocked") || msg.contains("reserved"),
"reserved spec {} must be rejected, got error: {}",
reserved,
msg
);
}
let err = r
.create_custom_resolver("https://[fd00::1]:443/dns-query")
.await
.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
}
#[tokio::test]
async fn custom_resolver_accepts_public_dot_and_doh_literals() {
let r = DnsResolver::new();
for spec in ["tls://1.1.1.1", "https://8.8.8.8/dns-query"] {
let result = r.create_custom_resolver(spec).await;
assert!(
result.is_ok(),
"{} must be accepted, got: {:?}",
spec,
result.err()
);
}
}
#[tokio::test]
async fn custom_resolver_rejects_unknown_scheme() {
let r = DnsResolver::new();
let err = r.create_custom_resolver("ftp://8.8.8.8").await.unwrap_err();
assert!(
matches!(err, SeerError::InvalidInput(_)),
"unknown scheme must be an input error, got: {err:?}"
);
}
fn spec(s: &str) -> NameserverSpec {
NameserverSpec::parse(s).unwrap_or_else(|e| panic!("{s:?} must parse: {e}"))
}
#[test]
fn upstream_config_udp_defaults() {
use hickory_resolver::config::ProtocolConfig;
let ip: IpAddr = "8.8.8.8".parse().unwrap();
let config = build_upstream_config(&spec("8.8.8.8"), &[ip], None);
let servers = config.name_servers();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].ip, ip);
assert_eq!(servers[0].connections.len(), 1);
assert_eq!(servers[0].connections[0].port, 53);
assert!(matches!(
servers[0].connections[0].protocol,
ProtocolConfig::Udp
));
}
#[test]
fn upstream_config_udp_explicit_port() {
let ip: IpAddr = "9.9.9.9".parse().unwrap();
let config = build_upstream_config(&spec("9.9.9.9:5353"), &[ip], None);
assert_eq!(config.name_servers()[0].connections[0].port, 5353);
}
#[test]
fn upstream_config_tls_sets_protocol_port_and_server_name() {
use hickory_resolver::config::ProtocolConfig;
let ip: IpAddr = "9.9.9.9".parse().unwrap();
let config = build_upstream_config(&spec("tls://dns.quad9.net"), &[ip], None);
let ns = &config.name_servers()[0];
assert_eq!(ns.ip, ip);
assert_eq!(ns.connections.len(), 1);
assert_eq!(ns.connections[0].port, 853);
match &ns.connections[0].protocol {
ProtocolConfig::Tls { server_name } => {
assert_eq!(&**server_name, "dns.quad9.net");
}
other => panic!("expected Tls protocol, got {other:?}"),
}
}
#[test]
fn upstream_config_tls_ip_literal_uses_ip_as_server_name() {
use hickory_resolver::config::ProtocolConfig;
let ip: IpAddr = "1.1.1.1".parse().unwrap();
let config = build_upstream_config(&spec("tls://1.1.1.1"), &[ip], None);
match &config.name_servers()[0].connections[0].protocol {
ProtocolConfig::Tls { server_name } => assert_eq!(&**server_name, "1.1.1.1"),
other => panic!("expected Tls protocol, got {other:?}"),
}
}
#[test]
fn upstream_config_https_sets_protocol_port_path_and_server_name() {
use hickory_resolver::config::ProtocolConfig;
let ip: IpAddr = "104.16.248.249".parse().unwrap();
let config = build_upstream_config(&spec("https://cloudflare-dns.com"), &[ip], None);
let ns = &config.name_servers()[0];
assert_eq!(ns.connections[0].port, 443);
match &ns.connections[0].protocol {
ProtocolConfig::Https { server_name, path } => {
assert_eq!(&**server_name, "cloudflare-dns.com");
assert_eq!(&**path, "/dns-query");
}
other => panic!("expected Https protocol, got {other:?}"),
}
}
#[test]
fn upstream_config_https_custom_port_and_path() {
use hickory_resolver::config::ProtocolConfig;
let ip: IpAddr = "8.8.8.8".parse().unwrap();
let config = build_upstream_config(&spec("https://dns.google:8443/resolve"), &[ip], None);
let ns = &config.name_servers()[0];
assert_eq!(ns.connections[0].port, 8443);
match &ns.connections[0].protocol {
ProtocolConfig::Https { server_name, path } => {
assert_eq!(&**server_name, "dns.google");
assert_eq!(&**path, "/resolve");
}
other => panic!("expected Https protocol, got {other:?}"),
}
}
#[test]
fn upstream_config_multiple_ips_share_spec() {
use hickory_resolver::config::ProtocolConfig;
let ips: Vec<IpAddr> = vec![
"9.9.9.9".parse().unwrap(),
"149.112.112.112".parse().unwrap(),
];
let config = build_upstream_config(&spec("tls://dns.quad9.net"), &ips, None);
let servers = config.name_servers();
assert_eq!(servers.len(), 2);
for (ns, expected_ip) in servers.iter().zip(&ips) {
assert_eq!(&ns.ip, expected_ip);
assert_eq!(ns.connections[0].port, 853);
assert!(matches!(
&ns.connections[0].protocol,
ProtocolConfig::Tls { server_name } if &**server_name == "dns.quad9.net"
));
}
}
#[test]
fn upstream_config_orders_ipv4_before_ipv6() {
let ips: Vec<IpAddr> = [
"2606:4700::6810:f8f9",
"2606:4700::6810:f9f9",
"104.16.248.249",
"104.16.249.249",
]
.iter()
.map(|s| s.parse().unwrap())
.collect();
let config =
build_upstream_config(&spec("https://cloudflare-dns.com/dns-query"), &ips, None);
let order: Vec<IpAddr> = config.name_servers().iter().map(|ns| ns.ip).collect();
assert_eq!(
order,
[ips[2], ips[3], ips[0], ips[1]],
"IPv4 first, each family in resolved order, nothing dropped"
);
let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
assert!(
order.iter().take(concurrent).all(IpAddr::is_ipv4),
"the parallel window must be all IPv4: {order:?}"
);
}
#[test]
fn upstream_config_test_port_override_wins() {
let ip: IpAddr = "127.0.0.1".parse().unwrap();
let config = build_upstream_config(&spec("127.0.0.1"), &[ip], Some(9999));
assert_eq!(config.name_servers()[0].connections[0].port, 9999);
}
#[tokio::test]
async fn query_asks_dot_and_doh_servers_through_the_vetted_config() {
use hickory_resolver::config::ProtocolConfig;
let r = DnsResolver::new();
let dot = r.custom_upstream_config("tls://1.1.1.1").await.unwrap();
let [server] = dot.name_servers() else {
panic!("one server per address: {dot:?}");
};
assert_eq!(server.ip, IpAddr::from([1, 1, 1, 1]));
assert_eq!(server.connections.len(), 1);
assert_eq!(server.connections[0].port, 853);
assert!(matches!(
&server.connections[0].protocol,
ProtocolConfig::Tls { server_name } if &**server_name == "1.1.1.1"
));
let doh = r
.custom_upstream_config("https://8.8.8.8/dns-query")
.await
.unwrap();
let [server] = doh.name_servers() else {
panic!("one server per address: {doh:?}");
};
assert_eq!(server.connections[0].port, 443);
assert!(matches!(
&server.connections[0].protocol,
ProtocolConfig::Https { server_name, path }
if &**server_name == "8.8.8.8" && &**path == "/dns-query"
));
let udp = r.custom_upstream_config("9.9.9.9").await.unwrap();
assert_eq!(udp.name_servers()[0].connections[0].port, 53);
assert!(matches!(
udp.name_servers()[0].connections[0].protocol,
ProtocolConfig::Udp
));
let default = r.upstream_config(None).await.unwrap();
assert!(default.name_servers()[0].ip.is_ipv4());
}
#[tokio::test]
#[ignore = "live network — DoT query against Cloudflare"]
async fn live_query_over_dot() {
let result = DnsResolver::new()
.query("example.com", RecordType::A, Some("tls://1.1.1.1"))
.await
.expect("DoT query should get a response");
assert_eq!(result.status, DnsStatus::NoError);
assert!(result.flags.contains(&"qr".to_string()), "{result:?}");
assert!(result.records().count() > 0, "expected A records over DoT");
}
#[tokio::test]
#[ignore = "live network — DoH query against Cloudflare"]
async fn live_query_over_doh() {
let result = DnsResolver::new()
.query(
"example.com",
RecordType::A,
Some("https://cloudflare-dns.com/dns-query"),
)
.await
.expect("DoH query should get a response");
assert_eq!(result.status, DnsStatus::NoError);
assert!(result.records().count() > 0, "expected A records over DoH");
}
#[tokio::test]
#[ignore = "live network — NXDOMAIN keeps its header"]
async fn live_query_nxdomain_has_flags_and_the_soa() {
let result = DnsResolver::new()
.query("seer-does-not-exist.example", RecordType::A, None)
.await
.expect("a negative answer is a result");
assert_eq!(result.status, DnsStatus::NxDomain);
assert!(result.flags.contains(&"qr".to_string()), "{result:?}");
assert!(
result
.authority
.iter()
.any(|r| r.record_type == RecordType::SOA),
"{result:?}"
);
}
#[tokio::test]
#[ignore = "live network — DoT query against Cloudflare"]
async fn live_resolve_over_dot() {
let r = DnsResolver::new();
let records = r
.resolve("example.com", RecordType::A, Some("tls://1.1.1.1"))
.await
.expect("DoT lookup should succeed");
assert!(!records.is_empty(), "expected A records over DoT");
}
#[tokio::test]
#[ignore = "live network — DoH query against Cloudflare"]
async fn live_resolve_over_doh() {
let r = DnsResolver::new();
let records = r
.resolve(
"example.com",
RecordType::A,
Some("https://cloudflare-dns.com/dns-query"),
)
.await
.expect("DoH lookup should succeed");
assert!(!records.is_empty(), "expected A records over DoH");
}
#[tokio::test]
async fn resolve_srv_rejects_invalid_service_label() {
let r = DnsResolver::new();
let result = r
.resolve_srv_core(&r.default_resolver, "http.evil", "tcp", "example.com")
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string().to_lowercase();
assert!(
msg.contains("invalid srv service"),
"expected SRV service validation error, got: {}",
msg
);
}
#[tokio::test]
async fn resolve_srv_rejects_invalid_protocol_label() {
let r = DnsResolver::new();
let result = r
.resolve_srv_core(&r.default_resolver, "http", "tcp.evil", "example.com")
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string().to_lowercase();
assert!(
msg.contains("invalid srv protocol"),
"expected SRV protocol validation error, got: {}",
msg
);
}
#[tokio::test]
async fn resolve_srv_normalizes_and_validates_domain_input() {
let r = DnsResolver::new();
let result = r
.resolve("_http._tcp.not a valid domain", RecordType::SRV, None)
.await;
assert!(
matches!(result, Err(SeerError::InvalidDomain(_))),
"expected InvalidDomain from domain validation, got: {result:?}"
);
}
#[tokio::test]
async fn resolve_normalizes_uppercase_domain_input() {
let r = DnsResolver::new();
let result = r.resolve(".bad.example", RecordType::A, None).await;
assert!(result.is_err(), "leading-dot domain must be rejected");
}
#[test]
fn parse_srv_query_extracts_service_proto_and_name() {
assert_eq!(
parse_srv_query("_sip._tcp.example.com"),
Some((
"sip".to_string(),
"tcp".to_string(),
"example.com".to_string()
))
);
}
#[test]
fn parse_srv_query_keeps_multilabel_domain() {
assert_eq!(
parse_srv_query("_sip._tcp.sip.voice.google.com"),
Some((
"sip".to_string(),
"tcp".to_string(),
"sip.voice.google.com".to_string()
))
);
}
#[test]
fn fqdn_appends_root_dot_once() {
assert_eq!(fqdn("example.com"), "example.com.");
assert_eq!(fqdn("example.com."), "example.com.");
}
#[test]
fn parse_srv_query_rejects_bare_domain() {
assert_eq!(parse_srv_query("example.com"), None);
}
#[test]
fn parse_srv_query_rejects_missing_proto_label() {
assert_eq!(parse_srv_query("_sip.example.com"), None);
}
#[tokio::test]
async fn resolve_rejects_bare_domain_for_srv_as_input_error() {
let r = DnsResolver::new();
let err = r
.resolve("example.com", RecordType::SRV, None)
.await
.expect_err("bare-domain SRV must error");
assert!(
matches!(err, SeerError::InvalidInput(_)),
"bare-domain SRV should be an input error, got: {err:?}"
);
assert!(err.to_string().contains("_service._proto"));
}
#[tokio::test]
#[ignore = "live network"]
async fn resolve_srv_via_dig_style_name_returns_records() {
let r = DnsResolver::new();
let records = r
.resolve("_caldavs._tcp.google.com", RecordType::SRV, None)
.await
.expect("dig-style SRV lookup should succeed");
assert!(!records.is_empty(), "expected SRV records");
assert!(records.iter().all(|r| r.record_type == RecordType::SRV));
}
#[tokio::test]
#[ignore = "live network"]
async fn resolve_naptr_returns_records() {
let r = DnsResolver::new();
let records = r
.resolve("sip2sip.info", RecordType::NAPTR, None)
.await
.expect("NAPTR lookup should succeed");
assert!(!records.is_empty(), "expected NAPTR records");
assert!(records.iter().all(|r| r.record_type == RecordType::NAPTR));
}
#[test]
fn prepare_query_passes_ipv6_literal_through_for_ptr() {
let out = prepare_query("2606:4700:4700::1111", RecordType::PTR).unwrap();
assert_eq!(out, "2606:4700:4700::1111");
}
#[test]
fn prepare_query_passes_ipv6_loopback_through_for_ptr() {
let out = prepare_query("::1", RecordType::PTR).unwrap();
assert_eq!(out, "::1");
}
#[test]
fn prepare_query_passes_ipv4_literal_through_for_ptr() {
let out = prepare_query("8.8.8.8", RecordType::PTR).unwrap();
assert_eq!(out, "8.8.8.8");
}
#[test]
fn prepare_query_normalizes_non_ip_ptr_names() {
let out = prepare_query("1.1.1.1.in-addr.arpa", RecordType::PTR).unwrap();
assert_eq!(out, "1.1.1.1.in-addr.arpa");
}
#[test]
fn prepare_query_keeps_www_for_record_queries() {
let out = prepare_query("HTTPS://WWW.Example.com/path", RecordType::A).unwrap();
assert_eq!(out, "www.example.com");
let out = prepare_query("www.example.com", RecordType::CNAME).unwrap();
assert_eq!(out, "www.example.com");
}
#[test]
fn prepare_query_still_normalizes_scheme_port_path_and_case() {
let out = prepare_query("https://API.Example.COM:8443/v1?q=1#frag", RecordType::A).unwrap();
assert_eq!(out, "api.example.com");
let out = prepare_query("example.com.", RecordType::MX).unwrap();
assert_eq!(out, "example.com");
assert!(prepare_query("not a domain", RecordType::A).is_err());
}
#[test]
fn prepare_query_accepts_a_leading_wildcard_label() {
let out = prepare_query("*.Example.com.", RecordType::A).unwrap();
assert_eq!(out, "*.example.com");
let out = prepare_query("*.1.168.192.in-addr.arpa", RecordType::PTR).unwrap();
assert_eq!(out, "*.1.168.192.in-addr.arpa");
assert!(prepare_query("a.*.example.com", RecordType::A).is_err());
assert!(prepare_query("a*.example.com", RecordType::A).is_err());
}
#[test]
fn prepare_query_runs_ptr_ip_literal_through_the_normalizer() {
let deny_arpa = |name: &str| -> Result<String> {
if name.ends_with(".arpa") {
Err(SeerError::DomainNotAllowed {
domain: name.to_string(),
tld: "arpa".to_string(),
})
} else {
Ok(name.to_string())
}
};
for ip in ["192.0.2.1", "2606:4700:4700::1111"] {
let err = prepare_query_with(ip, RecordType::PTR, deny_arpa)
.expect_err("PTR for an IP literal must obey the normalizer");
assert!(matches!(err, SeerError::DomainNotAllowed { .. }), "{err:?}");
}
assert_eq!(
prepare_query_with("192.0.2.1", RecordType::PTR, normalize_query_name).unwrap(),
"192.0.2.1"
);
}
#[test]
fn from_wire_type_inverts_wire_type_for_every_single_type() {
for record_type in RecordType::ALL.iter().copied() {
match wire_type(record_type) {
Some(wire) => assert_eq!(from_wire_type(wire), Some(record_type)),
None => assert_eq!(record_type, RecordType::ANY),
}
}
assert_eq!(
from_wire_type(HickoryRecordType::SRV),
Some(RecordType::SRV)
);
assert_eq!(from_wire_type(HickoryRecordType::RRSIG), None);
}
#[test]
fn to_dns_record_keeps_the_real_owner_and_type() {
let cname = Record::from_rdata(
Name::from_ascii("www.seer.test.").unwrap(),
300,
HickoryRData::CNAME(hickory_resolver::proto::rr::rdata::CNAME(
Name::from_ascii("edge.cdn.test.").unwrap(),
)),
);
let converted = to_dns_record(&cname).expect("CNAME is modeled");
assert_eq!(converted.name, "www.seer.test");
assert_eq!(converted.record_type, RecordType::CNAME);
assert_eq!(converted.ttl, 300);
assert_eq!(converted.data.to_string(), "edge.cdn.test.");
let a = Record::from_rdata(
Name::from_ascii("edge.cdn.test.").unwrap(),
60,
HickoryRData::A(hickory_resolver::proto::rr::rdata::A(Ipv4Addr::new(
192, 0, 2, 7,
))),
);
let converted = to_dns_record(&a).expect("A is modeled");
assert_eq!(converted.name, "edge.cdn.test");
assert_eq!(converted.record_type, RecordType::A);
assert_eq!(owner_name(&Name::root()), ".");
assert_eq!(
owner_name(&Name::from_utf8("bücher.seer.test.").unwrap()),
"xn--bcher-kva.seer.test"
);
}
#[test]
fn to_dns_record_spells_rdata_names_as_a_labels_where_resolve_keeps_unicode() {
use hickory_resolver::proto::rr::rdata::{CNAME, MX, SOA, SRV};
let idn = |name: &str| Name::from_utf8(name).unwrap();
let data = |rdata: HickoryRData| {
let record = Record::from_rdata(idn("xn--bcher-kva.seer.test."), 300, rdata);
to_dns_record(&record).expect("modeled").data.to_string()
};
assert_eq!(
data(HickoryRData::CNAME(CNAME(idn("edge.café.test.")))),
"edge.xn--caf-dma.test."
);
assert_eq!(
data(HickoryRData::MX(MX::new(10, idn("mail.café.test.")))),
"10 mail.xn--caf-dma.test."
);
assert_eq!(
data(HickoryRData::SRV(SRV::new(
1,
5,
443,
idn("sip.café.test.")
))),
"1 5 443 sip.xn--caf-dma.test."
);
assert_eq!(
data(HickoryRData::SOA(SOA::new(
idn("ns1.café.test."),
idn("hostmaster.café.test."),
1,
7200,
3600,
1209600,
300,
))),
"ns1.xn--caf-dma.test. hostmaster.xn--caf-dma.test. 1 7200 3600 1209600 300"
);
let cname = HickoryRData::CNAME(CNAME(idn("edge.café.test.")));
assert_eq!(
convert_rdata(RecordType::CNAME, &cname, NameSpelling::Display)
.expect("modeled")
.to_string(),
"edge.café.test."
);
}
#[test]
fn parse_caa_keeps_reserved_flag_bits() {
let mut caa = CAA::new_issue(
true,
Some(Name::from_ascii("letsencrypt.org").unwrap()),
vec![],
);
caa.reserved_flags = 0x01;
let (flags, tag, value) = parse_caa(&caa);
assert_eq!(flags, 0x81);
assert_eq!(tag, "issue");
assert_eq!(value, "letsencrypt.org");
}
#[test]
fn alpn_ids_are_escaped_at_both_levels() {
use hickory_resolver::proto::rr::rdata::svcb::Alpn;
let alpn = |ids: &[&str]| {
svc_param_value(&SvcParamValue::Alpn(Alpn(
ids.iter().map(|id| id.to_string()).collect(),
)))
};
assert_eq!(alpn(&["h3", "h2"]), "h3,h2");
assert_eq!(alpn(&["a,b"]), r"a\\,b");
assert_eq!(alpn(&[r"x\y"]), r"x\\\\y");
assert_eq!(alpn(&[r"f\oo,bar", "h2"]), r"f\\\\oo\\,bar,h2");
}
use crate::dns::query::DnsStatus;
use crate::dns::test_support::{
a_rdata, cname_rdata, mock_dns_resolver, mock_dns_resolver_default, record, spawn_mock_dns,
spawn_mock_dns_fn, spawn_mock_dns_fn_with_tcp, MockMode, MockReply,
};
use hickory_resolver::proto::op::ResponseCode;
async fn mock_zone_lookup(record_type: RecordType, domain: &str) -> Vec<DnsRecord> {
let port = spawn_mock_dns(MockMode::Zone).await;
mock_dns_resolver(port)
.resolve(domain, record_type, Some("127.0.0.1"))
.await
.unwrap_or_else(|e| panic!("{record_type} lookup against mock must succeed: {e}"))
}
#[tokio::test]
async fn mock_resolve_a_returns_addresses() {
let records = mock_zone_lookup(RecordType::A, "seer.test").await;
assert_eq!(records.len(), 2);
assert!(records.iter().all(|r| r.record_type == RecordType::A));
assert_eq!(records[0].name, "seer.test");
assert_eq!(records[0].ttl, 300);
let addresses: Vec<String> = records
.iter()
.map(|r| match &r.data {
RecordData::A { address } => address.clone(),
other => panic!("expected A data, got {other:?}"),
})
.collect();
assert!(addresses.contains(&"192.0.2.1".to_string()));
assert!(addresses.contains(&"192.0.2.2".to_string()));
}
#[tokio::test]
async fn mock_resolve_mx_sorts_by_preference() {
let records = mock_zone_lookup(RecordType::MX, "seer.test").await;
let prefs: Vec<u16> = records
.iter()
.map(|r| match &r.data {
RecordData::MX { preference, .. } => *preference,
other => panic!("expected MX data, got {other:?}"),
})
.collect();
assert_eq!(prefs, vec![10, 20, 30]);
assert!(matches!(
&records[0].data,
RecordData::MX { exchange, .. } if exchange == "a.mail.seer.test."
));
}
#[tokio::test]
async fn mock_resolve_txt_joins_character_strings() {
let records = mock_zone_lookup(RecordType::TXT, "seer.test").await;
assert_eq!(records.len(), 1);
match &records[0].data {
RecordData::TXT { text } => assert_eq!(text, "v=spf1 -all"),
other => panic!("expected TXT data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_soa_maps_all_fields() {
let records = mock_zone_lookup(RecordType::SOA, "seer.test").await;
assert_eq!(records.len(), 1);
match &records[0].data {
RecordData::SOA {
mname,
rname,
serial,
refresh,
retry,
expire,
minimum,
} => {
assert_eq!(mname, "ns1.seer.test.");
assert_eq!(rname, "hostmaster.seer.test.");
assert_eq!(*serial, 2026070101);
assert_eq!(*refresh, 7200);
assert_eq!(*retry, 3600);
assert_eq!(*expire, 1209600);
assert_eq!(*minimum, 300);
}
other => panic!("expected SOA data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_caa_maps_flags_tag_and_value() {
let records = mock_zone_lookup(RecordType::CAA, "seer.test").await;
assert_eq!(records.len(), 2);
let by_tag = |wanted: &str| {
records
.iter()
.find_map(|r| match &r.data {
RecordData::CAA { flags, tag, value } if tag == wanted => {
Some((*flags, value.clone()))
}
_ => None,
})
.unwrap_or_else(|| panic!("expected a CAA record with tag {wanted}"))
};
assert_eq!(by_tag("issue"), (0, "letsencrypt.org".to_string()));
assert_eq!(
by_tag("iodef"),
(128, "mailto:security@seer.test".to_string())
);
}
#[tokio::test]
async fn mock_resolve_tlsa_hex_encodes_cert_data() {
let records = mock_zone_lookup(RecordType::TLSA, "_443._tcp.seer.test").await;
assert_eq!(records.len(), 1);
match &records[0].data {
RecordData::TLSA {
cert_usage,
selector,
matching,
cert_data,
} => {
assert_eq!((*cert_usage, *selector, *matching), (3, 1, 1));
assert_eq!(cert_data, "ABCD01");
}
other => panic!("expected TLSA data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_sshfp_hex_encodes_fingerprint() {
let records = mock_zone_lookup(RecordType::SSHFP, "seer.test").await;
assert_eq!(records.len(), 1);
match &records[0].data {
RecordData::SSHFP {
algorithm,
fingerprint_type,
fingerprint,
} => {
assert_eq!((*algorithm, *fingerprint_type), (4, 2));
assert_eq!(fingerprint, "DEADBEEF");
}
other => panic!("expected SSHFP data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_naptr_decodes_character_strings() {
let records = mock_zone_lookup(RecordType::NAPTR, "seer.test").await;
assert_eq!(records.len(), 1);
match &records[0].data {
RecordData::NAPTR {
order,
preference,
flags,
services,
regexp,
replacement,
} => {
assert_eq!((*order, *preference), (100, 50));
assert_eq!(flags, "U");
assert_eq!(services, "E2U+sip");
assert_eq!(regexp, "!^.*$!sip:info@seer.test!");
assert_eq!(replacement, ".");
}
other => panic!("expected NAPTR data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_srv_via_dig_style_name() {
let records = mock_zone_lookup(RecordType::SRV, "_sip._tcp.seer.test").await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "_sip._tcp.seer.test");
match &records[0].data {
RecordData::SRV {
priority,
weight,
port,
target,
} => {
assert_eq!((*priority, *weight, *port), (10, 5, 5060));
assert_eq!(target, "sipserver.seer.test.");
}
other => panic!("expected SRV data, got {other:?}"),
}
}
#[tokio::test]
async fn mock_resolve_ptr_transforms_ip_literal() {
let records = mock_zone_lookup(RecordType::PTR, "192.0.2.1").await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "1.2.0.192.in-addr.arpa");
assert!(matches!(
&records[0].data,
RecordData::PTR { target } if target == "ptr.seer.test."
));
}
#[tokio::test]
async fn mock_resolve_keeps_www_label() {
let records = mock_zone_lookup(RecordType::CNAME, "www.seer.test").await;
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(records[0].name, "www.seer.test");
assert_eq!(records[0].data.to_string(), "edge.cdn.test.");
}
#[tokio::test]
async fn mock_resolve_srv_keeps_www_label() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("_sip._tcp.www.seer.test", HickoryRecordType::SRV) => {
MockReply::Answer(vec![HickoryRData::SRV(
hickory_resolver::proto::rr::rdata::SRV::new(
10,
5,
5060,
Name::from_ascii("sip.seer.test.").unwrap(),
),
)])
}
_ => MockReply::NoData,
})
.await;
let records = mock_dns_resolver(port)
.resolve(
"_sip._tcp.www.seer.test",
RecordType::SRV,
Some("127.0.0.1"),
)
.await
.expect("SRV against mock");
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(records[0].name, "_sip._tcp.www.seer.test");
}
#[tokio::test]
async fn mock_resolve_queries_a_wildcard_owner_name() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("*.seer.test", HickoryRecordType::A) => MockReply::Answer(vec![HickoryRData::A(
hickory_resolver::proto::rr::rdata::A(Ipv4Addr::new(192, 0, 2, 42)),
)]),
_ => MockReply::NoData,
})
.await;
let records = mock_dns_resolver(port)
.resolve("*.seer.test", RecordType::A, Some("127.0.0.1"))
.await
.expect("wildcard A against mock");
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(records[0].name, "*.seer.test");
assert_eq!(records[0].data.to_string(), "192.0.2.42");
}
#[tokio::test]
async fn mock_presence_still_probes_the_apex_for_www_input() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("seer.test", HickoryRecordType::NS) => MockReply::Answer(vec![HickoryRData::NS(
hickory_resolver::proto::rr::rdata::NS(Name::from_ascii("ns1.seer.test.").unwrap()),
)]),
("www.seer.test", HickoryRecordType::NS) => MockReply::NoDataWithSoa("seer.test"),
_ => MockReply::NxDomain,
})
.await;
let resolver = mock_dns_resolver_default(port);
assert_eq!(
resolver.presence("www.seer.test").await,
DnsPresence::Present
);
}
#[tokio::test]
async fn mock_resolve_ptr_transforms_ipv6_literal() {
let records = mock_zone_lookup(RecordType::PTR, "2606:4700:4700::1111").await;
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(records[0].data.to_string(), "one.one.one.one.");
}
#[tokio::test]
async fn mock_resolve_any_aggregates_multiple_types() {
let records = mock_zone_lookup(RecordType::ANY, "seer.test").await;
for expected in [
RecordType::A,
RecordType::AAAA,
RecordType::MX,
RecordType::NS,
RecordType::TXT,
RecordType::SOA,
RecordType::CAA,
RecordType::HTTPS,
RecordType::DNSKEY,
] {
assert!(
records.iter().any(|r| r.record_type == expected),
"ANY must include {expected} records"
);
}
assert_eq!(records.len(), 13);
assert!(records.iter().all(|r| r.name == "seer.test"));
assert!(!records.iter().any(|r| r.record_type == RecordType::CDS));
}
#[tokio::test]
async fn mock_resolve_converts_service_bindings() {
let records = mock_zone_lookup(RecordType::HTTPS, "seer.test").await;
assert_eq!(records.len(), 1, "{records:?}");
let RecordData::HTTPS {
priority,
target,
params,
} = &records[0].data
else {
panic!("expected HTTPS data, got {:?}", records[0].data);
};
assert_eq!((*priority, target.as_str()), (1, "."));
let pairs: Vec<(&str, &str)> = params
.iter()
.map(|p| (p.key.as_str(), p.value.as_str()))
.collect();
assert_eq!(
pairs,
[
("alpn", "h3,h2"),
("port", "8443"),
("ipv4hint", "192.0.2.1,192.0.2.2"),
("ech", "AAH+"),
("ipv6hint", "2001:db8::1"),
("key65333", "ex\\0321"),
]
);
assert_eq!(
records[0].data.to_string(),
"1 . alpn=\"h3,h2\" port=8443 ipv4hint=192.0.2.1,192.0.2.2 ech=AAH+ \
ipv6hint=2001:db8::1 key65333=ex\\0321"
);
let alias = mock_zone_lookup(RecordType::HTTPS, "alias.seer.test").await;
assert_eq!(alias.len(), 1, "{alias:?}");
assert_eq!(alias[0].data.to_string(), "0 pool.seer.test.");
let svcb = mock_zone_lookup(RecordType::SVCB, "_8443._foo.seer.test").await;
assert_eq!(svcb.len(), 1, "{svcb:?}");
assert_eq!(svcb[0].record_type, RecordType::SVCB);
assert_eq!(
svcb[0].data.to_string(),
"2 svc.seer.test. mandatory=alpn,port alpn=\"foo\" no-default-alpn port=8443"
);
}
#[tokio::test]
async fn mock_resolve_converts_child_dnssec_records() {
let cds = mock_zone_lookup(RecordType::CDS, "seer.test").await;
let shown: Vec<String> = cds.iter().map(|r| r.data.to_string()).collect();
assert_eq!(shown, ["2371 15 2 ABCDEF", "0 0 0 00"]);
assert!(cds.iter().all(|r| r.record_type == RecordType::CDS));
let cdnskey = mock_zone_lookup(RecordType::CDNSKEY, "seer.test").await;
let shown: Vec<String> = cdnskey.iter().map(|r| r.data.to_string()).collect();
assert_eq!(
shown,
[
"257 3 15 BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
"0 3 0 AA==",
]
);
}
#[tokio::test]
async fn mock_resolve_keeps_only_the_requested_type_behind_a_cname() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
record("www.seer.test.", 300, cname_rdata("edge.cdn.test.")),
record("edge.cdn.test.", 60, a_rdata([192, 0, 2, 7])),
]),
_ => MockReply::NxDomain,
})
.await;
let records = mock_dns_resolver(port)
.resolve("www.seer.test", RecordType::A, Some("127.0.0.1"))
.await
.expect("A behind a CNAME");
assert_eq!(
records,
[DnsRecord {
name: "www.seer.test".to_string(),
record_type: RecordType::A,
ttl: 60,
data: RecordData::A {
address: "192.0.2.7".to_string(),
},
}]
);
}
fn is_probe(qname: &str) -> bool {
qname.starts_with("seer-probe-")
}
type Asked = Arc<std::sync::Mutex<Vec<(String, HickoryRecordType)>>>;
async fn spawn_recording<F>(mut handler: F) -> (u16, Asked)
where
F: FnMut(&str, HickoryRecordType) -> MockReply + Send + 'static,
{
let asked: Asked = Arc::default();
let log = Arc::clone(&asked);
let port = spawn_mock_dns_fn(move |qname, qtype| {
log.lock().unwrap().push((qname.to_string(), qtype));
handler(qname, qtype)
})
.await;
(port, asked)
}
fn probes_sent(asked: &Asked) -> usize {
asked
.lock()
.unwrap()
.iter()
.filter(|(name, _)| is_probe(name))
.count()
}
async fn mock_query(port: u16, name: &str, record_type: RecordType) -> DnsQueryResult {
mock_dns_resolver(port)
.query(name, record_type, Some("127.0.0.1"))
.await
.unwrap_or_else(|e| panic!("{record_type} query against mock must succeed: {e}"))
}
fn shown(records: &[&DnsRecord]) -> Vec<(String, RecordType, String)> {
records
.iter()
.map(|r| (r.name.clone(), r.record_type, r.data.to_string()))
.collect()
}
fn row(name: &str, record_type: RecordType, data: &str) -> (String, RecordType, String) {
(name.to_string(), record_type, data.to_string())
}
#[tokio::test]
async fn mock_query_reports_the_cname_chain_under_real_owners() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
record("www.seer.test.", 300, cname_rdata("edge.cdn.test.")),
record("edge.cdn.test.", 120, cname_rdata("origin.cdn.test.")),
record("origin.cdn.test.", 60, a_rdata([192, 0, 2, 7])),
record("origin.cdn.test.", 60, a_rdata([192, 0, 2, 8])),
]),
_ => MockReply::NxDomain,
})
.await;
let result = mock_query(port, "WWW.Seer.test", RecordType::A).await;
assert_eq!(result.name, "www.seer.test");
assert_eq!(result.record_type, RecordType::A);
assert_eq!(result.server.as_deref(), Some("127.0.0.1"));
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(
shown(&result.cname_chain().collect::<Vec<_>>()),
[
row("www.seer.test", RecordType::CNAME, "edge.cdn.test."),
row("edge.cdn.test", RecordType::CNAME, "origin.cdn.test."),
]
);
assert_eq!(
shown(&result.records().collect::<Vec<_>>()),
[
row("origin.cdn.test", RecordType::A, "192.0.2.7"),
row("origin.cdn.test", RecordType::A, "192.0.2.8"),
]
);
assert_eq!(result.answers[2].ttl, 60);
assert!(result.authority.is_empty());
assert!(!result.is_nodata());
let wildcard = result
.wildcard
.expect("probe ran for a name below seer.test");
assert!(is_probe(&wildcard.probe_name), "{wildcard:?}");
assert!(wildcard.probe_name.ends_with(".seer.test"), "{wildcard:?}");
assert!(!wildcard.present && !wildcard.matches_answer);
}
#[tokio::test]
async fn mock_query_reports_idn_owners_as_a_labels() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("xn--bcher-kva.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
record(
"xn--bcher-kva.seer.test.",
300,
cname_rdata("edge.xn--caf-dma.test."),
),
record("edge.xn--caf-dma.test.", 60, a_rdata([192, 0, 2, 7])),
]),
_ => MockReply::NxDomain,
})
.await;
let result = mock_query(port, "Bücher.seer.test", RecordType::A).await;
assert_eq!(result.name, "xn--bcher-kva.seer.test");
assert_eq!(
shown(&result.answers.iter().collect::<Vec<_>>()),
[
row(
"xn--bcher-kva.seer.test",
RecordType::CNAME,
"edge.xn--caf-dma.test."
),
row("edge.xn--caf-dma.test", RecordType::A, "192.0.2.7"),
]
);
assert_eq!(result.cname_chain().count(), 1);
}
#[tokio::test]
async fn query_refuses_a_reserved_nameserver() {
let r = DnsResolver::new();
for reserved in ["127.0.0.1", "10.0.0.1", "::1", "tls://192.168.1.1"] {
let err = r
.query("www.example.com", RecordType::A, Some(reserved))
.await
.expect_err("a reserved nameserver must be refused");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("blocked") || msg.contains("reserved"),
"{reserved}: {msg}"
);
}
}
#[tokio::test]
async fn mock_query_reports_a_cname_the_server_did_not_follow_as_sent() {
let (port, asked) = spawn_recording(|qname, qtype| match (qname, qtype) {
("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![record(
"www.seer.test.",
300,
cname_rdata("edge.cdn.test."),
)]),
("edge.cdn.test", HickoryRecordType::A) => {
MockReply::Answer(vec![a_rdata([192, 0, 2, 7])])
}
_ => MockReply::NxDomain,
})
.await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(
shown(&result.answers.iter().collect::<Vec<_>>()),
[row("www.seer.test", RecordType::CNAME, "edge.cdn.test.")]
);
assert_eq!(result.records().count(), 0);
assert!(
!asked
.lock()
.unwrap()
.iter()
.any(|(n, _)| n == "edge.cdn.test"),
"the target is not chased: {asked:?}"
);
}
#[tokio::test]
async fn mock_query_reports_a_dangling_cname_with_its_chain() {
let port = spawn_mock_dns_fn(|qname, _| match qname {
"www.seer.test" => MockReply::Negative {
code: ResponseCode::NXDomain,
chain: vec![
record("www.seer.test.", 300, cname_rdata("shop.seer.test.")),
record("shop.seer.test.", 300, cname_rdata("gone.cdn.test.")),
],
soa: "cdn.test",
},
_ => MockReply::NxDomainWithSoa("seer.test"),
})
.await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NxDomain);
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(
shown(&result.cname_chain().collect::<Vec<_>>()),
[
row("www.seer.test", RecordType::CNAME, "shop.seer.test."),
row("shop.seer.test", RecordType::CNAME, "gone.cdn.test."),
]
);
assert_eq!(result.records().count(), 0);
assert_eq!(
shown(&result.authority.iter().collect::<Vec<_>>()),
[row(
"cdn.test",
RecordType::SOA,
"ns1.cdn.test. hostmaster.cdn.test. 2026070101 7200 3600 1209600 300"
)]
);
assert!(!result.is_nodata());
assert_eq!(result.referral_zone(), None);
assert_eq!(
result.wildcard, None,
"a negative answer has nothing to compare"
);
}
#[tokio::test]
async fn mock_query_reports_nodata_behind_a_cname() {
let port = spawn_mock_dns_fn(|qname, _| match qname {
"www.seer.test" => MockReply::Negative {
code: ResponseCode::NoError,
chain: vec![record("www.seer.test.", 300, cname_rdata("edge.cdn.test."))],
soa: "cdn.test",
},
_ => MockReply::NxDomain,
})
.await;
let result = mock_query(port, "www.seer.test", RecordType::AAAA).await;
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(result.cname_chain().count(), 1);
assert_eq!(result.records().count(), 0);
assert!(result.is_nodata());
assert_eq!(result.referral_zone(), None);
assert_eq!(result.authority.len(), 1);
assert_eq!(result.authority[0].name, "cdn.test");
}
#[tokio::test]
async fn mock_query_reports_nxdomain_with_the_zone_soa() {
let (port, asked) = spawn_recording(|_, _| MockReply::NxDomainWithSoa("seer.test")).await;
let result = mock_query(port, "nx.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NxDomain);
assert!(result.answers.is_empty());
assert!(!result.is_nodata(), "NXDOMAIN is not NODATA");
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(
shown(&result.authority.iter().collect::<Vec<_>>()),
[row(
"seer.test",
RecordType::SOA,
"ns1.seer.test. hostmaster.seer.test. 2026070101 7200 3600 1209600 300"
)]
);
assert_eq!(result.wildcard, None);
assert!(asked
.lock()
.unwrap()
.iter()
.any(|(n, _)| n == "nx.seer.test"));
}
#[tokio::test]
async fn mock_query_reports_nodata_with_the_zone_soa() {
let port = spawn_mock_dns_fn(|_, _| MockReply::NoDataWithSoa("seer.test")).await;
let result = mock_query(port, "www.seer.test", RecordType::MX).await;
assert_eq!(result.status, DnsStatus::NoError);
assert!(result.is_nodata());
assert!(result.answers.is_empty());
assert_eq!(result.authority.len(), 1);
assert_eq!(result.authority[0].record_type, RecordType::SOA);
assert_eq!(result.authority[0].name, "seer.test");
assert_eq!(result.wildcard, None);
}
#[tokio::test]
async fn mock_query_reports_error_codes_as_results() {
let port = spawn_mock_dns_fn(|qname, _| match qname {
"servfail.seer.test" => MockReply::ServFail,
"refused.seer.test" => MockReply::Refused,
"notimp.seer.test" => MockReply::Rcode(ResponseCode::NotImp),
_ => MockReply::NxDomain,
})
.await;
for (name, status, shown) in [
("servfail.seer.test", DnsStatus::ServFail, "SERVFAIL"),
("refused.seer.test", DnsStatus::Refused, "REFUSED"),
("notimp.seer.test", DnsStatus::Other(4), "NOTIMP"),
] {
let result = mock_query(port, name, RecordType::A).await;
assert_eq!(result.status, status, "{name}");
assert_eq!(result.status.to_string(), shown);
assert!(result.answers.is_empty() && result.authority.is_empty());
assert_eq!(result.flags, ["qr", "rd", "ra"], "{name}");
assert!(!result.is_nodata());
assert_eq!(result.wildcard, None);
}
}
#[tokio::test]
async fn mock_query_reads_an_extended_rcode_from_edns() {
let port = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::BADVERS)).await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::Other(16));
assert_eq!(result.status.to_string(), "BADVERS");
}
#[tokio::test]
async fn mock_query_repeats_a_truncated_reply_over_tcp() {
let transports = Arc::new(std::sync::Mutex::new(0));
let seen = Arc::clone(&transports);
let port = spawn_mock_dns_fn_with_tcp(move |qname, _| {
if qname != "big.seer.test" {
return MockReply::NxDomain;
}
let mut n = seen.lock().unwrap();
*n += 1;
if *n == 1 {
MockReply::Truncated
} else {
MockReply::Answer((1..=3).map(|i| a_rdata([192, 0, 2, i])).collect())
}
})
.await;
let result = mock_query(port, "big.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.records().count(), 3);
assert!(
!result.flags.contains(&"tc".to_string()),
"{:?}",
result.flags
);
assert_eq!(*transports.lock().unwrap(), 2, "UDP, then TCP");
}
#[tokio::test]
async fn mock_query_reports_a_referral_as_neither_nodata_nor_an_answer() {
let port = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
zone: "child.seer.test".to_string(),
servers: vec![
(
"ns1.child.seer.test".to_string(),
vec![IpAddr::from([192, 0, 2, 1])],
),
("ns2.child.seer.test".to_string(), vec![]),
],
})
.await;
let result = mock_query(port, "www.child.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.flags, ["qr", "rd"]);
assert!(result.answers.is_empty());
assert_eq!(result.referral_zone(), Some("child.seer.test"));
assert!(!result.is_nodata());
assert_eq!(
shown(&result.authority.iter().collect::<Vec<_>>()),
[
row("child.seer.test", RecordType::NS, "ns1.child.seer.test."),
row("child.seer.test", RecordType::NS, "ns2.child.seer.test."),
]
);
assert_eq!(result.wildcard, None);
}
#[tokio::test]
async fn mock_query_reports_special_use_names_as_answered_locally() {
let (port, asked) = spawn_recording(|_, _| MockReply::NxDomain).await;
let ptr = mock_query(port, "127.0.0.1", RecordType::PTR).await;
assert_eq!(ptr.name, "1.0.0.127.in-addr.arpa");
assert!(ptr.answered_locally);
assert_eq!(ptr.server, None, "no server was asked");
assert!(ptr.flags.is_empty(), "the header is hickory's own");
assert_eq!(ptr.status, DnsStatus::NoError);
assert_eq!(
shown(&ptr.answers.iter().collect::<Vec<_>>()),
[row("1.0.0.127.in-addr.arpa", RecordType::PTR, "localhost.")]
);
assert_eq!(ptr.wildcard, None, "no wildcard probe for a local name");
let mx = mock_query(port, "foo.localhost", RecordType::MX).await;
assert!(mx.answered_locally && mx.is_nodata());
let onion = mock_query(port, "x.onion", RecordType::A).await;
assert!(onion.answered_locally);
assert_eq!(onion.status, DnsStatus::NxDomain);
assert!(asked.lock().unwrap().is_empty(), "{asked:?}");
}
#[tokio::test]
async fn mock_query_times_out_as_an_error() {
let port = spawn_mock_dns(MockMode::Ignore).await;
let resolver = DnsResolver::new()
.with_timeout(Duration::from_millis(200))
.allowing_private_hosts()
.with_port(port);
let result = resolver
.query("www.seer.test", RecordType::A, Some("127.0.0.1"))
.await;
assert!(
matches!(&result, Err(SeerError::DnsError(m)) if m.contains("A lookup failed")),
"an unanswered query is a transport error, got: {result:?}"
);
}
#[tokio::test]
async fn mock_query_reads_flags_from_the_response_header() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("seer.test", HickoryRecordType::A) => {
MockReply::AuthoritativeAnswer(vec![a_rdata([192, 0, 2, 1])])
}
_ => MockReply::NxDomain,
})
.await;
let result = mock_query(port, "seer.test", RecordType::A).await;
assert_eq!(result.flags, ["qr", "aa", "rd", "ra"]);
assert_eq!(result.cname_chain().count(), 0);
assert_eq!(
result.records().next().map(|r| r.name.as_str()),
Some("seer.test")
);
}
#[tokio::test]
async fn mock_query_converts_the_new_record_types_under_their_owner() {
let port = spawn_mock_dns(MockMode::Zone).await;
let https = mock_query(port, "seer.test", RecordType::HTTPS).await;
assert_eq!(https.answers.len(), 1, "{https:?}");
assert_eq!(https.answers[0].name, "seer.test");
assert!(https.answers[0]
.data
.to_string()
.starts_with("1 . alpn=\"h3,h2\" port=8443"));
let alias = mock_query(port, "alias.seer.test", RecordType::HTTPS).await;
assert_eq!(alias.answers[0].data.to_string(), "0 pool.seer.test.");
assert_eq!(alias.answers[0].name, "alias.seer.test");
let cds = mock_query(port, "seer.test", RecordType::CDS).await;
assert_eq!(cds.answers.len(), 2);
let cdnskey = mock_query(port, "seer.test", RecordType::CDNSKEY).await;
assert_eq!(cdnskey.answers[1].data.to_string(), "0 3 0 AA==");
}
#[tokio::test]
async fn mock_query_sorts_mx_like_resolve() {
let port = spawn_mock_dns(MockMode::Zone).await;
let result = mock_query(port, "seer.test", RecordType::MX).await;
let prefs: Vec<String> = result.answers.iter().map(|r| r.data.to_string()).collect();
assert_eq!(
prefs,
[
"10 a.mail.seer.test.",
"20 b.mail.seer.test.",
"30 c.mail.seer.test."
]
);
}
#[tokio::test]
async fn mock_query_resolves_srv_and_ptr_names_like_resolve() {
let (port, asked) = spawn_recording(|qname, qtype| match (qname, qtype) {
("_sip._tcp.seer.test", HickoryRecordType::SRV) => {
MockReply::Answer(vec![HickoryRData::SRV(
hickory_resolver::proto::rr::rdata::SRV::new(
10,
5,
5060,
Name::from_ascii("sip.seer.test.").unwrap(),
),
)])
}
("1.2.0.192.in-addr.arpa", HickoryRecordType::PTR) => {
MockReply::Answer(vec![HickoryRData::PTR(
hickory_resolver::proto::rr::rdata::PTR(
Name::from_ascii("ptr.seer.test.").unwrap(),
),
)])
}
_ => MockReply::NxDomain,
})
.await;
let srv = mock_query(port, "_sip._tcp.seer.test", RecordType::SRV).await;
assert_eq!(
shown(&srv.answers.iter().collect::<Vec<_>>()),
[row(
"_sip._tcp.seer.test",
RecordType::SRV,
"10 5 5060 sip.seer.test."
)]
);
let probe = srv.wildcard.expect("SRV name is below seer.test");
assert!(probe.probe_name.ends_with("._tcp.seer.test"), "{probe:?}");
assert!(asked.lock().unwrap().iter().any(|(n, t)| is_probe(n)
&& n.ends_with("._tcp.seer.test")
&& *t == HickoryRecordType::SRV));
let ptr = mock_query(port, "192.0.2.1", RecordType::PTR).await;
assert_eq!(ptr.name, "1.2.0.192.in-addr.arpa");
assert_eq!(ptr.answers[0].name, "1.2.0.192.in-addr.arpa");
let err = mock_dns_resolver(port)
.query("seer.test", RecordType::SRV, Some("127.0.0.1"))
.await
.expect_err("a bare domain is not an SRV query");
assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
let err = mock_dns_resolver(port)
.query(".bad.example", RecordType::A, Some("127.0.0.1"))
.await;
assert!(err.is_err(), "invalid names are rejected before any I/O");
}
#[tokio::test]
async fn mock_query_any_fans_out_to_the_new_types_and_dedupes() {
let (port, asked) = spawn_recording(|qname, qtype| {
let chain = || record("www.seer.test.", 300, cname_rdata("edge.seer.test."));
match (qname, qtype) {
("www.seer.test", HickoryRecordType::CNAME) => MockReply::Records(vec![chain()]),
("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
chain(),
record("edge.seer.test.", 60, a_rdata([192, 0, 2, 7])),
]),
("www.seer.test", HickoryRecordType::HTTPS) => {
MockReply::Records(vec![
chain(),
record(
"edge.seer.test.",
60,
HickoryRData::HTTPS(hickory_resolver::proto::rr::rdata::HTTPS(
SVCB::new(1, Name::root(), vec![]),
)),
),
])
}
("www.seer.test", _) => MockReply::Records(vec![chain()]),
_ => MockReply::NoDataWithSoa("seer.test"),
}
})
.await;
let result = mock_query(port, "www.seer.test", RecordType::ANY).await;
assert_eq!(result.record_type, RecordType::ANY);
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.flags, ["qr", "rd", "ra"]);
assert_eq!(
shown(&result.answers.iter().collect::<Vec<_>>()),
[
row("www.seer.test", RecordType::CNAME, "edge.seer.test."),
row("edge.seer.test", RecordType::A, "192.0.2.7"),
row("edge.seer.test", RecordType::HTTPS, "1 ."),
]
);
assert_eq!(result.cname_chain().count(), 1);
assert_eq!(result.records().count(), 2);
assert!(!result.is_nodata());
assert_eq!(result.wildcard, None, "ANY is never probed");
let asked: Vec<HickoryRecordType> = asked
.lock()
.unwrap()
.iter()
.filter(|(n, _)| n == "www.seer.test")
.map(|(_, t)| *t)
.collect();
for wire in ANY_TYPES.iter().filter_map(|t| wire_type(*t)) {
assert!(asked.contains(&wire), "ANY must query {wire}: {asked:?}");
}
assert!(
!asked.contains(&HickoryRecordType::ANY),
"ANY is never sent"
);
}
#[tokio::test]
async fn mock_query_any_errors_only_when_every_sub_query_fails() {
let port = spawn_mock_dns(MockMode::Ignore).await;
let resolver = DnsResolver::new()
.with_timeout(Duration::from_millis(200))
.allowing_private_hosts()
.with_port(port);
let result = resolver
.query("www.seer.test", RecordType::ANY, Some("127.0.0.1"))
.await;
assert!(matches!(result, Err(SeerError::DnsError(_))), "{result:?}");
}
async fn wildcard_zone(probe_reply: fn() -> MockReply) -> (u16, Asked) {
spawn_recording(move |qname, qtype| match (qname, qtype) {
("www.seer.test", HickoryRecordType::A) => {
MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])
}
(name, HickoryRecordType::A) if is_probe(name) => probe_reply(),
_ => MockReply::NxDomain,
})
.await
}
#[tokio::test]
async fn mock_wildcard_probe_matching_the_answer() {
let (port, asked) =
wildcard_zone(|| MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])).await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
let probe = result.wildcard.expect("probe attached");
assert!(probe.present && probe.matches_answer, "{probe:?}");
assert_eq!(probes_sent(&asked), 1);
}
#[tokio::test]
async fn mock_wildcard_probe_with_different_data() {
let (port, _) = wildcard_zone(|| MockReply::Answer(vec![a_rdata([192, 0, 2, 99])])).await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
let probe = result.wildcard.expect("probe attached");
assert!(probe.present && !probe.matches_answer, "{probe:?}");
}
#[tokio::test]
async fn mock_wildcard_probe_absent() {
let (port, _) = wildcard_zone(|| MockReply::NxDomainWithSoa("seer.test")).await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
let probe = result.wildcard.expect("probe attached");
assert!(!probe.present && !probe.matches_answer, "{probe:?}");
}
#[tokio::test]
async fn mock_wildcard_probe_that_fails_is_dropped_not_fatal() {
let (port, _) = wildcard_zone(|| MockReply::ServFail).await;
let result = mock_query(port, "www.seer.test", RecordType::A).await;
assert_eq!(result.status, DnsStatus::NoError);
assert_eq!(result.wildcard, None);
let (port, _) = wildcard_zone(|| MockReply::NoReply).await;
let resolver = DnsResolver::new()
.with_timeout(Duration::from_millis(200))
.allowing_private_hosts()
.with_port(port);
let result = resolver
.query("www.seer.test", RecordType::A, Some("127.0.0.1"))
.await
.expect("the probe never fails the query");
assert_eq!(result.records().count(), 1);
assert_eq!(result.wildcard, None);
}
#[tokio::test]
async fn mock_wildcard_probe_is_not_sent_for_ineligible_names() {
let (port, asked) =
spawn_recording(|_, _| MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])).await;
assert_eq!(
mock_query(port, "seer.test", RecordType::A).await.wildcard,
None
);
assert_eq!(
mock_query(port, "*.seer.test", RecordType::A)
.await
.wildcard,
None
);
assert_eq!(
mock_query(port, "www.seer.test", RecordType::ANY)
.await
.wildcard,
None
);
assert_eq!(probes_sent(&asked), 0, "{:?}", asked.lock().unwrap());
}
#[tokio::test]
async fn mock_nodata_folds_to_empty_and_classifies_absent() {
let port = spawn_mock_dns(MockMode::NoData).await;
let result = mock_dns_resolver(port)
.resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
.await;
assert!(
matches!(&result, Ok(records) if records.is_empty()),
"NODATA must fold to Ok(vec![]), got: {result:?}"
);
assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
}
#[tokio::test]
async fn mock_nxdomain_folds_to_empty_and_classifies_absent() {
let port = spawn_mock_dns(MockMode::Nxdomain).await;
let result = mock_dns_resolver(port)
.resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
.await;
assert!(
matches!(&result, Ok(records) if records.is_empty()),
"NXDOMAIN must fold to Ok(vec![]), got: {result:?}"
);
assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
}
#[tokio::test]
async fn mock_timeout_errors_and_classifies_unknown() {
let port = spawn_mock_dns(MockMode::Ignore).await;
let resolver = DnsResolver::new()
.with_timeout(Duration::from_millis(200))
.allowing_private_hosts()
.with_port(port);
let result = resolver
.resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
.await;
match &result {
Err(SeerError::DnsError(_)) => {}
other => panic!("unanswered query must surface a DnsError, got: {other:?}"),
}
assert_eq!(classify_ns_presence(&result), DnsPresence::Unknown);
}
#[tokio::test]
async fn resolve_type_rejects_composite_types_consistently() {
let r = DnsResolver::new();
for composite in [RecordType::SRV, RecordType::ANY] {
let err = r
.resolve_type(&r.default_resolver, "seer.test", composite)
.await
.expect_err("composite types must be rejected by resolve_type");
assert_eq!(
err.to_string(),
unsupported_record_type(composite).to_string()
);
}
}
}