use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use hickory_resolver::lookup::Lookup;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::op::{Message, MessageType, Metadata, ResponseCode};
use hickory_resolver::proto::rr::domain::usage::{
ResolverUsage, INVALID, IN_ADDR_ARPA_127, IP6_ARPA_1, LOCAL, LOCALHOST, ONION,
};
use hickory_resolver::proto::rr::Name;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::records::{DnsRecord, RecordData, RecordType};
use super::resolver::to_dns_record;
use super::transport::NoResponse;
use crate::error::{Result, SeerError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DnsStatus {
NoError,
NxDomain,
ServFail,
Refused,
Other(u16),
}
impl DnsStatus {
pub fn is_success(self) -> bool {
self == DnsStatus::NoError
}
pub fn code(self) -> u16 {
match self {
DnsStatus::NoError => 0,
DnsStatus::ServFail => 2,
DnsStatus::NxDomain => 3,
DnsStatus::Refused => 5,
DnsStatus::Other(code) => code,
}
}
pub fn from_code(code: u16) -> Self {
match code {
0 => DnsStatus::NoError,
2 => DnsStatus::ServFail,
3 => DnsStatus::NxDomain,
5 => DnsStatus::Refused,
other => DnsStatus::Other(other),
}
}
}
const OTHER_MNEMONICS: &[(u16, &str)] = &[
(1, "FORMERR"),
(4, "NOTIMP"),
(6, "YXDOMAIN"),
(7, "YXRRSET"),
(8, "NXRRSET"),
(9, "NOTAUTH"),
(10, "NOTZONE"),
(16, "BADVERS"),
(17, "BADKEY"),
(18, "BADTIME"),
(19, "BADMODE"),
(20, "BADNAME"),
(21, "BADALG"),
(22, "BADTRUNC"),
(23, "BADCOOKIE"),
];
impl fmt::Display for DnsStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DnsStatus::NoError => f.write_str("NOERROR"),
DnsStatus::NxDomain => f.write_str("NXDOMAIN"),
DnsStatus::ServFail => f.write_str("SERVFAIL"),
DnsStatus::Refused => f.write_str("REFUSED"),
DnsStatus::Other(code) => match OTHER_MNEMONICS.iter().find(|(c, _)| c == code) {
Some((_, name)) => f.write_str(name),
None => write!(f, "RCODE{code}"),
},
}
}
}
impl FromStr for DnsStatus {
type Err = SeerError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let upper = s.trim().to_ascii_uppercase();
let status = match upper.as_str() {
"NOERROR" => DnsStatus::NoError,
"NXDOMAIN" => DnsStatus::NxDomain,
"SERVFAIL" => DnsStatus::ServFail,
"REFUSED" => DnsStatus::Refused,
other => {
if let Some((code, _)) = OTHER_MNEMONICS.iter().find(|(_, name)| *name == other) {
DnsStatus::Other(*code)
} else if let Some(code) = other
.strip_prefix("RCODE")
.and_then(|n| n.parse::<u16>().ok())
{
DnsStatus::from_code(code)
} else {
return Err(SeerError::InvalidInput(format!("unknown DNS status: {s}")));
}
}
};
Ok(status)
}
}
impl From<ResponseCode> for DnsStatus {
fn from(code: ResponseCode) -> Self {
DnsStatus::from_code(u16::from(code))
}
}
impl Serialize for DnsStatus {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for DnsStatus {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsQueryResult {
pub name: String,
pub record_type: RecordType,
pub server: Option<String>,
pub answered_locally: bool,
pub status: DnsStatus,
pub flags: Vec<String>,
pub answers: Vec<DnsRecord>,
pub authority: Vec<DnsRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed_types: Vec<FailedType>,
pub wildcard: Option<WildcardProbe>,
pub query_time_ms: u64,
}
impl DnsQueryResult {
pub fn is_nodata(&self) -> bool {
self.status == DnsStatus::NoError && self.lacks_answer() && self.referral_zone().is_none()
}
pub fn referral_zone(&self) -> Option<&str> {
if self.status != DnsStatus::NoError
|| !self.lacks_answer()
|| self.flags.iter().any(|flag| flag == "aa")
|| self
.authority
.iter()
.any(|r| r.record_type == RecordType::SOA)
{
return None;
}
self.authority
.iter()
.find(|r| r.record_type == RecordType::NS)
.map(|r| r.name.as_str())
}
fn lacks_answer(&self) -> bool {
match self.record_type {
RecordType::ANY => self.answers.is_empty(),
wanted => !self.records().any(|r| r.record_type == wanted),
}
}
pub fn cname_chain(&self) -> impl Iterator<Item = &DnsRecord> {
self.answers[..chain_len(self.record_type, &self.answers)].iter()
}
pub fn records(&self) -> impl Iterator<Item = &DnsRecord> {
self.answers[chain_len(self.record_type, &self.answers)..].iter()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailedType {
pub record_type: RecordType,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WildcardProbe {
pub probe_name: String,
pub present: bool,
pub matches_answer: bool,
}
fn chain_len(record_type: RecordType, answers: &[DnsRecord]) -> usize {
if record_type == RecordType::CNAME {
return 0;
}
answers
.iter()
.take_while(|r| r.record_type == RecordType::CNAME)
.count()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Exchange {
pub(crate) status: DnsStatus,
pub(crate) flags: Vec<String>,
pub(crate) answers: Vec<DnsRecord>,
pub(crate) authority: Vec<DnsRecord>,
pub(crate) failed_types: Vec<FailedType>,
}
impl Exchange {
pub(crate) fn from_message(message: &Message) -> Self {
Self {
status: message.metadata.response_code.into(),
flags: header_flags(&message.metadata),
answers: message.answers.iter().filter_map(to_dns_record).collect(),
authority: message
.authorities
.iter()
.filter_map(to_dns_record)
.collect(),
failed_types: Vec::new(),
}
}
pub(crate) fn from_local(
result: std::result::Result<Lookup, NetError>,
record_type: RecordType,
) -> Result<Self> {
match result {
Ok(lookup) => Ok(Self::from_message(lookup.message())),
Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => Ok(Self {
status: no_records.response_code.into(),
flags: Vec::new(),
answers: Vec::new(),
authority: Vec::new(),
failed_types: Vec::new(),
}),
Err(e) => Err(SeerError::DnsError(format!(
"{record_type} lookup failed: {e}"
))),
}
}
pub(crate) fn ordered(mut self, query_name: &str, record_type: RecordType) -> Self {
self.answers = order_answers(query_name, record_type, self.answers);
self
}
pub(crate) fn records(&self, record_type: RecordType) -> &[DnsRecord] {
&self.answers[chain_len(record_type, &self.answers)..]
}
pub(crate) fn has_records(&self, record_type: RecordType) -> bool {
self.status == DnsStatus::NoError && !self.records(record_type).is_empty()
}
pub(crate) fn into_result(
self,
name: String,
record_type: RecordType,
server: Option<&str>,
wildcard: Option<WildcardProbe>,
query_time: Duration,
) -> DnsQueryResult {
let local = answered_locally(&name);
DnsQueryResult {
name,
record_type,
server: server.filter(|_| !local).map(str::to_string),
answered_locally: local,
status: self.status,
flags: if local { Vec::new() } else { self.flags },
answers: self.answers,
authority: self.authority,
failed_types: self.failed_types,
wildcard,
query_time_ms: duration_ms(query_time),
}
}
}
pub(crate) fn duration_ms(elapsed: Duration) -> u64 {
u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
}
pub(crate) fn header_flags(metadata: &Metadata) -> Vec<String> {
[
(metadata.message_type == MessageType::Response, "qr"),
(metadata.authoritative, "aa"),
(metadata.truncation, "tc"),
(metadata.recursion_desired, "rd"),
(metadata.recursion_available, "ra"),
(metadata.authentic_data, "ad"),
(metadata.checking_disabled, "cd"),
]
.into_iter()
.filter(|(set, _)| *set)
.map(|(_, flag)| flag.to_string())
.collect()
}
fn name_key(name: &str) -> String {
name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
}
pub(crate) fn order_answers(
query_name: &str,
record_type: RecordType,
answers: Vec<DnsRecord>,
) -> Vec<DnsRecord> {
if record_type == RecordType::CNAME {
return answers;
}
let mut rest = answers;
let mut ordered = Vec::with_capacity(rest.len());
let mut current = name_key(query_name);
while let Some(pos) = rest
.iter()
.position(|r| r.record_type == RecordType::CNAME && name_key(&r.name) == current)
{
let hop = rest.remove(pos);
if let RecordData::CNAME { target } = &hop.data {
current = name_key(target);
}
ordered.push(hop);
}
if record_type == RecordType::MX {
rest.sort_by_key(|r| match &r.data {
RecordData::MX { preference, .. } => *preference,
_ => 0,
});
}
ordered.extend(rest);
ordered
}
pub(crate) fn dedupe_records(records: impl IntoIterator<Item = DnsRecord>) -> Vec<DnsRecord> {
let mut seen = HashSet::new();
records
.into_iter()
.filter(|r| {
seen.insert((
r.name.to_ascii_lowercase(),
r.record_type,
r.data.comparison_key(),
))
})
.collect()
}
pub(crate) fn merge_any(results: Vec<(RecordType, SubQuery)>) -> Result<Exchange> {
let (exchanges, failures) = fold_any(results.into_iter().map(|(record_type, result)| {
let result = match result {
Ok(Ok(exchange)) => Ok(exchange),
Ok(Err(why)) => Err((
why.to_string(),
SeerError::DnsError(format!("{record_type} lookup failed: {why}")),
)),
Err(e) => Err((e.sanitized_message(), e)),
};
(record_type, result)
}))
.map_err(|(_, e)| e)?;
let failed_types = failures
.into_iter()
.map(|(record_type, (error, _))| FailedType { record_type, error })
.collect();
let Some(first) = exchanges.first() else {
return Err(SeerError::DnsError("ANY lookup ran no queries".to_string()));
};
let status = if exchanges.iter().any(|e| e.status == DnsStatus::NoError) {
DnsStatus::NoError
} else if exchanges.iter().all(|e| e.status == DnsStatus::NxDomain) {
DnsStatus::NxDomain
} else {
first.status
};
let flags = first.flags.clone();
let answers = dedupe_records(exchanges.iter().flat_map(|e| e.answers.iter().cloned()));
let authority = if answers.is_empty() {
dedupe_records(
exchanges
.iter()
.filter(|e| e.status == status)
.flat_map(|e| e.authority.iter().cloned()),
)
} else {
Vec::new()
};
Ok(Exchange {
status,
flags,
answers,
authority,
failed_types,
})
}
pub(crate) type SubQuery = Result<std::result::Result<Exchange, NoResponse>>;
pub(crate) type Folded<T, E> = (Vec<T>, Vec<(RecordType, E)>);
pub(crate) fn fold_any<T, E>(
results: impl IntoIterator<Item = (RecordType, std::result::Result<T, E>)>,
) -> std::result::Result<Folded<T, E>, E> {
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for (record_type, result) in results {
match result {
Ok(value) => succeeded.push(value),
Err(e) => failed.push((record_type, e)),
}
}
if succeeded.is_empty() {
if let Some((_, e)) = failed.pop() {
return Err(e);
}
}
Ok((succeeded, failed))
}
pub(crate) fn answered_locally(name: &str) -> bool {
let Ok(name) = Name::from_ascii(name) else {
return false;
};
[
&LOCALHOST,
&IN_ADDR_ARPA_127,
&IP6_ARPA_1,
&INVALID,
&LOCAL,
&ONION,
]
.into_iter()
.find(|zone| zone.name().zone_of(&name))
.is_some_and(|zone| {
matches!(
zone.resolver(),
ResolverUsage::Loopback | ResolverUsage::NxDomain
)
})
}
const PROBE_LABEL_PREFIX: &str = "seer-probe-";
pub(crate) fn random_probe_label() -> String {
use rand::RngExt;
let bits = rand::rng().random::<u64>() & 0xFF_FFFF_FFFF;
format!("{PROBE_LABEL_PREFIX}{bits:010x}")
}
pub(crate) fn wildcard_probe_name(
name: &str,
record_type: RecordType,
label: &str,
) -> Option<String> {
if record_type == RecordType::ANY
|| name == "*"
|| name.starts_with("*.")
|| answered_locally(name)
{
return None;
}
crate::psl::registrable_parent(name)?;
let (_, parent) = name.split_once('.')?;
Some(format!("{label}.{parent}"))
}
pub(crate) fn same_record_set(a: &[DnsRecord], b: &[DnsRecord]) -> bool {
let key_set = |records: &[DnsRecord]| {
records
.iter()
.map(|r| (r.record_type, r.data.comparison_key()))
.collect::<HashSet<_>>()
};
key_set(a) == key_set(b)
}
pub(crate) fn wildcard_outcome(
probe_name: &str,
record_type: RecordType,
answer: &Exchange,
probe: Result<Exchange>,
) -> Option<WildcardProbe> {
if !answer.has_records(record_type) {
return None;
}
let answered = answer.records(record_type);
let probe = probe.ok()?;
let probed = probe.records(record_type);
let present = match probe.status {
DnsStatus::NoError => probed.iter().any(|r| r.record_type == record_type),
DnsStatus::NxDomain => false,
_ => return None,
};
Some(WildcardProbe {
probe_name: probe_name.to_string(),
present,
matches_answer: present && same_record_set(answered, probed),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_names_match_dig() {
assert_eq!(DnsStatus::NoError.to_string(), "NOERROR");
assert_eq!(DnsStatus::NxDomain.to_string(), "NXDOMAIN");
assert_eq!(DnsStatus::ServFail.to_string(), "SERVFAIL");
assert_eq!(DnsStatus::Refused.to_string(), "REFUSED");
assert_eq!(DnsStatus::Other(1).to_string(), "FORMERR");
assert_eq!(DnsStatus::Other(4).to_string(), "NOTIMP");
assert_eq!(DnsStatus::Other(16).to_string(), "BADVERS");
assert_eq!(DnsStatus::Other(3000).to_string(), "RCODE3000");
}
#[test]
fn status_maps_every_hickory_code_by_value() {
assert_eq!(DnsStatus::from(ResponseCode::NoError), DnsStatus::NoError);
assert_eq!(DnsStatus::from(ResponseCode::NXDomain), DnsStatus::NxDomain);
assert_eq!(DnsStatus::from(ResponseCode::ServFail), DnsStatus::ServFail);
assert_eq!(DnsStatus::from(ResponseCode::Refused), DnsStatus::Refused);
assert_eq!(DnsStatus::from(ResponseCode::NotImp), DnsStatus::Other(4));
assert_eq!(
DnsStatus::from(ResponseCode::Unknown(3000)),
DnsStatus::Other(3000)
);
}
#[test]
fn status_round_trips_through_its_name() {
for code in [0u16, 1, 2, 3, 4, 5, 6, 9, 16, 23, 3000] {
let status = DnsStatus::from_code(code);
assert_eq!(status.to_string().parse::<DnsStatus>().unwrap(), status);
assert_eq!(status.code(), code);
}
assert_eq!(
"nxdomain".parse::<DnsStatus>().unwrap(),
DnsStatus::NxDomain
);
assert!("NOPE".parse::<DnsStatus>().is_err());
}
#[test]
fn status_serializes_as_its_name() {
assert_eq!(
serde_json::to_string(&DnsStatus::NxDomain).unwrap(),
"\"NXDOMAIN\""
);
assert_eq!(
serde_json::from_str::<DnsStatus>("\"SERVFAIL\"").unwrap(),
DnsStatus::ServFail
);
}
use hickory_resolver::net::NoRecords;
use hickory_resolver::proto::op::{OpCode, Query};
use hickory_resolver::proto::rr::rdata::SOA;
use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as WireType};
fn rec(name: &str, record_type: RecordType, ttl: u32, data: RecordData) -> DnsRecord {
DnsRecord {
name: name.to_string(),
record_type,
ttl,
data,
}
}
fn a(name: &str, address: &str) -> DnsRecord {
rec(
name,
RecordType::A,
300,
RecordData::A {
address: address.to_string(),
},
)
}
fn cname(name: &str, target: &str) -> DnsRecord {
rec(
name,
RecordType::CNAME,
300,
RecordData::CNAME {
target: target.to_string(),
},
)
}
fn mx(name: &str, preference: u16) -> DnsRecord {
rec(
name,
RecordType::MX,
300,
RecordData::MX {
preference,
exchange: format!("mx{preference}.seer.test."),
},
)
}
fn exchange(status: DnsStatus, answers: Vec<DnsRecord>) -> Exchange {
Exchange {
status,
flags: vec!["qr".to_string(), "rd".to_string(), "ra".to_string()],
answers,
authority: vec![],
failed_types: vec![],
}
}
fn negative(status: DnsStatus, authority: Vec<DnsRecord>) -> Exchange {
Exchange {
authority,
..exchange(status, vec![])
}
}
fn soa_record(zone: &str) -> Record<SOA> {
Record::from_rdata(
Name::from_ascii(format!("{zone}.")).unwrap(),
900,
SOA::new(
Name::from_ascii(format!("ns1.{zone}.")).unwrap(),
Name::from_ascii(format!("hostmaster.{zone}.")).unwrap(),
7,
7200,
3600,
1209600,
300,
),
)
}
fn query_for(name: &str) -> Query {
Query::query(Name::from_ascii(name).unwrap(), WireType::A)
}
#[test]
fn header_flags_follow_digs_order() {
let mut metadata = Metadata::new(7, MessageType::Response, OpCode::Query);
metadata.recursion_desired = true;
metadata.recursion_available = true;
assert_eq!(header_flags(&metadata), ["qr", "rd", "ra"]);
metadata.authoritative = true;
metadata.truncation = true;
metadata.authentic_data = true;
metadata.checking_disabled = true;
assert_eq!(
header_flags(&metadata),
["qr", "aa", "tc", "rd", "ra", "ad", "cd"]
);
let query = Metadata::new(7, MessageType::Query, OpCode::Query);
assert!(header_flags(&query).is_empty());
}
fn response(name: &str, code: ResponseCode) -> Message {
let mut message = Message::response(7, OpCode::Query);
message.metadata.response_code = code;
message.metadata.recursion_desired = true;
message.metadata.recursion_available = true;
message.add_query(query_for(name));
message
}
fn wire_record(owner: &str, data: RData) -> Record {
Record::from_rdata(Name::from_ascii(owner).unwrap(), 300, data)
}
fn wire_cname(owner: &str, target: &str) -> Record {
wire_record(
owner,
RData::CNAME(hickory_resolver::proto::rr::rdata::CNAME(
Name::from_ascii(target).unwrap(),
)),
)
}
#[test]
fn from_message_reads_status_flags_and_sections_as_sent() {
let mut message = response("www.seer.test.", ResponseCode::NoError);
message.add_answer(wire_record(
"www.seer.test.",
RData::A(hickory_resolver::proto::rr::rdata::A::new(192, 0, 2, 1)),
));
let exchange = Exchange::from_message(&message);
assert_eq!(exchange.status, DnsStatus::NoError);
assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
assert_eq!(exchange.answers, [a("www.seer.test", "192.0.2.1")]);
assert!(exchange.authority.is_empty());
}
#[test]
fn from_message_keeps_the_chain_and_header_of_a_negative_answer() {
let mut dangling = response("www.seer.test.", ResponseCode::NXDomain);
dangling.add_answer(wire_cname("www.seer.test.", "gone.cdn.test."));
dangling.add_authority(soa_record("cdn.test").into_record_of_rdata());
let exchange = Exchange::from_message(&dangling);
assert_eq!(exchange.status, DnsStatus::NxDomain);
assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
assert_eq!(exchange.answers, [cname("www.seer.test", "gone.cdn.test.")]);
assert_eq!(exchange.authority.len(), 1);
assert_eq!(exchange.authority[0].name, "cdn.test");
assert_eq!(exchange.authority[0].record_type, RecordType::SOA);
for (code, status) in [
(ResponseCode::ServFail, DnsStatus::ServFail),
(ResponseCode::Refused, DnsStatus::Refused),
(ResponseCode::NotImp, DnsStatus::Other(4)),
(ResponseCode::BADVERS, DnsStatus::Other(16)),
] {
let exchange = Exchange::from_message(&response("www.seer.test.", code));
assert_eq!(exchange, negative(status, vec![]), "{code}");
}
}
#[test]
fn from_local_maps_the_resolvers_own_answers() {
let answer = wire_record(
"localhost.",
RData::A(hickory_resolver::proto::rr::rdata::A::new(127, 0, 0, 1)),
);
let lookup = Lookup::new_with_max_ttl(query_for("localhost."), [answer]);
let exchange = Exchange::from_local(Ok(lookup), RecordType::A).unwrap();
assert_eq!(exchange.status, DnsStatus::NoError);
assert_eq!(exchange.answers, [a("localhost", "127.0.0.1")]);
for (code, status) in [
(ResponseCode::NoError, DnsStatus::NoError),
(ResponseCode::NXDomain, DnsStatus::NxDomain),
] {
let no_records = NoRecords::new(query_for("x.onion."), code);
let exchange =
Exchange::from_local(Err(NetError::from(no_records)), RecordType::MX).unwrap();
assert_eq!(exchange.status, status);
assert!(exchange.flags.is_empty() && exchange.answers.is_empty());
assert!(exchange.authority.is_empty());
}
for transport in [NetError::Timeout, NetError::NoConnections] {
let err = Exchange::from_local(Err(transport), RecordType::MX).unwrap_err();
assert!(matches!(&err, SeerError::DnsError(m) if m.starts_with("MX lookup failed")));
}
}
#[test]
fn order_answers_puts_the_chain_first_hop_by_hop() {
let answers = vec![
a("edge.origin.test", "192.0.2.7"),
cname("edge.cdn.test", "edge.origin.test."),
cname("WWW.Seer.Test", "edge.cdn.test."),
];
let ordered = order_answers("www.seer.test", RecordType::A, answers);
assert_eq!(
ordered,
vec![
cname("WWW.Seer.Test", "edge.cdn.test."),
cname("edge.cdn.test", "edge.origin.test."),
a("edge.origin.test", "192.0.2.7"),
]
);
}
#[test]
fn order_answers_leaves_a_cname_answer_and_sorts_mx_after_the_chain() {
let answers = vec![cname("www.seer.test", "edge.cdn.test.")];
assert_eq!(
order_answers("www.seer.test", RecordType::CNAME, answers.clone()),
answers
);
let answers = vec![
mx("mail.cdn.test", 30),
cname("mail.seer.test", "mail.cdn.test."),
mx("mail.cdn.test", 10),
];
let ordered = order_answers("mail.seer.test", RecordType::MX, answers);
assert_eq!(
ordered,
vec![
cname("mail.seer.test", "mail.cdn.test."),
mx("mail.cdn.test", 10),
mx("mail.cdn.test", 30),
]
);
}
#[test]
fn order_answers_walks_a_chain_through_idn_names() {
let answers = vec![
a("edge.xn--caf-dma.test", "192.0.2.7"),
cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
];
let ordered = order_answers("xn--bcher-kva.seer.test", RecordType::A, answers);
assert_eq!(
ordered,
vec![
cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
a("edge.xn--caf-dma.test", "192.0.2.7"),
]
);
assert_eq!(name_key("Edge.XN--CAF-DMA.test."), "edge.xn--caf-dma.test");
assert_eq!(name_key("WWW.seer.test"), "www.seer.test");
}
#[test]
fn order_answers_terminates_on_a_cname_loop() {
let answers = vec![
cname("a.seer.test", "b.seer.test."),
cname("b.seer.test", "a.seer.test."),
];
let ordered = order_answers("a.seer.test", RecordType::A, answers);
assert_eq!(ordered.len(), 2);
assert_eq!(ordered[0].name, "a.seer.test");
}
#[test]
fn dedupe_keeps_the_first_copy_of_each_rr() {
let mut later = cname("WWW.seer.test", "Edge.CDN.test.");
later.ttl = 299;
let records = vec![
cname("www.seer.test", "edge.cdn.test."),
a("edge.cdn.test", "192.0.2.7"),
later,
a("edge.cdn.test", "192.0.2.8"),
];
assert_eq!(
dedupe_records(records),
vec![
cname("www.seer.test", "edge.cdn.test."),
a("edge.cdn.test", "192.0.2.7"),
a("edge.cdn.test", "192.0.2.8"),
]
);
}
fn soa(zone: &str) -> DnsRecord {
to_dns_record(&soa_record(zone).into_record_of_rdata()).unwrap()
}
fn answered(record_type: RecordType, exchange: Exchange) -> (RecordType, SubQuery) {
(record_type, Ok(Ok(exchange)))
}
fn silent(record_type: RecordType) -> (RecordType, SubQuery) {
(
record_type,
Ok(Err(NoResponse::timed_out("192.0.2.53".parse().unwrap()))),
)
}
#[test]
fn merge_any_status_flags_and_answers() {
let chain = cname("www.seer.test", "edge.cdn.test.");
let nodata = negative(DnsStatus::NoError, vec![soa("seer.test")]);
let mut authoritative = nodata.clone();
authoritative.flags = vec!["qr".to_string(), "aa".to_string()];
let merged = merge_any(vec![
silent(RecordType::A),
answered(RecordType::AAAA, authoritative),
answered(
RecordType::CNAME,
exchange(
DnsStatus::NoError,
vec![chain.clone(), a("edge.cdn.test", "192.0.2.7")],
),
),
silent(RecordType::MX),
answered(
RecordType::NS,
exchange(DnsStatus::NoError, vec![chain.clone()]),
),
answered(
RecordType::DS,
negative(DnsStatus::NoError, vec![soa("test")]),
),
])
.unwrap();
assert_eq!(merged.status, DnsStatus::NoError);
assert_eq!(merged.flags, ["qr", "aa"]);
assert_eq!(merged.answers, vec![chain, a("edge.cdn.test", "192.0.2.7")]);
assert!(merged.authority.is_empty(), "{:?}", merged.authority);
let all_nodata = merge_any(vec![
answered(RecordType::A, nodata.clone()),
answered(
RecordType::AAAA,
negative(DnsStatus::ServFail, vec![soa("other")]),
),
answered(RecordType::MX, nodata),
])
.unwrap();
assert_eq!(all_nodata.status, DnsStatus::NoError);
assert_eq!(all_nodata.authority, [soa("seer.test")]);
assert!(all_nodata.failed_types.is_empty());
let all_nx = merge_any(vec![
answered(
RecordType::A,
negative(DnsStatus::NxDomain, vec![soa("seer.test")]),
),
answered(
RecordType::MX,
negative(DnsStatus::NxDomain, vec![soa("seer.test")]),
),
])
.unwrap();
assert_eq!(all_nx.status, DnsStatus::NxDomain);
assert_eq!(
all_nx.flags,
["qr", "rd", "ra"],
"a negative answer has a header"
);
assert_eq!(all_nx.authority, [soa("seer.test")]);
let mixed = merge_any(vec![
answered(RecordType::A, negative(DnsStatus::ServFail, vec![])),
answered(RecordType::MX, negative(DnsStatus::NxDomain, vec![])),
])
.unwrap();
assert_eq!(mixed.status, DnsStatus::ServFail);
}
#[test]
fn merge_any_reports_the_types_whose_sub_query_failed() {
let merged = merge_any(vec![
answered(
RecordType::A,
exchange(DnsStatus::NoError, vec![a("seer.test", "192.0.2.1")]),
),
silent(RecordType::TXT),
(
RecordType::DNSKEY,
Err(SeerError::DnsError("internal detail".to_string())),
),
])
.unwrap();
assert_eq!(merged.answers, [a("seer.test", "192.0.2.1")]);
assert_eq!(
merged.failed_types,
[
FailedType {
record_type: RecordType::TXT,
error: "192.0.2.53: timed out".to_string(),
},
FailedType {
record_type: RecordType::DNSKEY,
error: "DNS resolution failed".to_string(),
},
]
);
let result = merged.into_result(
"seer.test".to_string(),
RecordType::ANY,
None,
None,
Duration::ZERO,
);
assert_eq!(result.failed_types.len(), 2);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["failed_types"][0]["record_type"], "TXT");
}
#[test]
fn merge_any_errors_only_when_every_sub_query_failed() {
let err = merge_any(vec![silent(RecordType::A), silent(RecordType::AAAA)]).unwrap_err();
assert!(err.to_string().contains("AAAA lookup failed"), "{err}");
assert!(err.to_string().contains("192.0.2.53: timed out"), "{err}");
assert!(merge_any(vec![]).is_err());
}
#[test]
fn fold_any_keeps_successes_and_failures_apart() {
let (ok, failed) = fold_any([
(RecordType::A, Ok(1)),
(RecordType::MX, Err("mx")),
(RecordType::TXT, Ok(3)),
])
.unwrap();
assert_eq!(ok, [1, 3]);
assert_eq!(failed, [(RecordType::MX, "mx")]);
let all_failed: std::result::Result<(Vec<u8>, _), _> =
fold_any([(RecordType::A, Err("a")), (RecordType::MX, Err("mx"))]);
assert_eq!(all_failed.unwrap_err(), "mx");
let none: std::result::Result<Folded<u8, &str>, &str> = fold_any([]);
assert_eq!(none.unwrap(), (vec![], vec![]));
}
#[test]
fn probe_label_is_ten_random_lowercase_hex_digits() {
let label = random_probe_label();
let hex = label.strip_prefix(PROBE_LABEL_PREFIX).expect("prefixed");
assert_eq!(hex.len(), 10, "{label}");
assert!(hex
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
assert_ne!(random_probe_label(), label, "40 random bits per label");
}
#[test]
fn probe_name_is_a_sibling_below_the_registrable_domain() {
let probe =
|name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
assert_eq!(
probe("www.example.com", RecordType::A).as_deref(),
Some("seer-probe-x.example.com")
);
assert_eq!(
probe("a.b.example.co.uk", RecordType::TXT).as_deref(),
Some("seer-probe-x.b.example.co.uk")
);
assert_eq!(
probe("_sip._tcp.example.com", RecordType::SRV).as_deref(),
Some("seer-probe-x._tcp.example.com")
);
assert_eq!(
probe("_443._tcp.www.example.com", RecordType::TLSA).as_deref(),
Some("seer-probe-x._tcp.www.example.com")
);
}
#[test]
fn probe_is_never_run_at_tld_level_for_wildcards_or_for_any() {
let probe =
|name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
assert_eq!(probe("example.com", RecordType::A), None);
assert_eq!(probe("example.co.uk", RecordType::A), None);
assert_eq!(probe("com", RecordType::A), None);
assert_eq!(probe("github.io", RecordType::A), None);
assert_eq!(probe("*.example.com", RecordType::A), None);
assert_eq!(probe("*", RecordType::A), None);
assert_eq!(probe("www.example.com", RecordType::ANY), None);
assert_eq!(probe("1.0.0.127.in-addr.arpa", RecordType::PTR), None);
assert_eq!(probe("a.b.localhost", RecordType::A), None);
}
#[test]
fn record_sets_compare_by_type_and_data_only() {
let ours = vec![
a("www.seer.test", "192.0.2.1"),
a("www.seer.test", "192.0.2.2"),
];
let mut theirs = vec![
a("seer-probe-1.seer.test", "192.0.2.2"),
a("seer-probe-1.seer.test", "192.0.2.1"),
];
theirs[0].ttl = 5;
assert!(same_record_set(&ours, &theirs));
assert!(!same_record_set(&ours, &theirs[..1]));
assert!(!same_record_set(
&[cname("x.seer.test", "a.test.")],
&[a("x.seer.test", "a.test.")]
));
}
#[test]
fn wildcard_outcome_needs_a_positive_answer_and_a_completed_probe() {
let answer = exchange(DnsStatus::NoError, vec![a("www.seer.test", "192.0.2.1")]);
let probe_answer = |address: &str| {
Ok(exchange(
DnsStatus::NoError,
vec![a("seer-probe-1.seer.test", address)],
))
};
let judge = |answer: &Exchange, probe| {
wildcard_outcome("seer-probe-1.seer.test", RecordType::A, answer, probe)
};
let matched = judge(&answer, probe_answer("192.0.2.1")).unwrap();
assert_eq!(
matched,
WildcardProbe {
probe_name: "seer-probe-1.seer.test".to_string(),
present: true,
matches_answer: true,
}
);
let different = judge(&answer, probe_answer("192.0.2.99")).unwrap();
assert!(different.present && !different.matches_answer);
let absent = judge(&answer, Ok(negative(DnsStatus::NxDomain, vec![]))).unwrap();
assert!(!absent.present && !absent.matches_answer);
let nodata = judge(&answer, Ok(negative(DnsStatus::NoError, vec![]))).unwrap();
assert!(!nodata.present);
assert_eq!(
judge(&answer, Ok(negative(DnsStatus::ServFail, vec![]))),
None
);
assert_eq!(
judge(&answer, Err(SeerError::DnsError("timeout".to_string()))),
None
);
let nx = negative(DnsStatus::NxDomain, vec![]);
assert_eq!(judge(&nx, probe_answer("192.0.2.1")), None);
let chain_only = exchange(
DnsStatus::NoError,
vec![cname("www.seer.test", "edge.cdn.test.")],
);
assert_eq!(judge(&chain_only, probe_answer("192.0.2.1")), None);
}
fn result(
record_type: RecordType,
status: DnsStatus,
answers: Vec<DnsRecord>,
) -> DnsQueryResult {
exchange(status, answers).into_result(
"www.seer.test".to_string(),
record_type,
None,
None,
Duration::from_millis(12),
)
}
#[test]
fn result_splits_the_chain_from_the_records() {
let chained = result(
RecordType::A,
DnsStatus::NoError,
vec![
cname("www.seer.test", "edge.cdn.test."),
a("edge.cdn.test", "192.0.2.7"),
],
);
assert_eq!(chained.cname_chain().count(), 1);
assert_eq!(
chained
.records()
.map(|r| r.name.as_str())
.collect::<Vec<_>>(),
["edge.cdn.test"]
);
assert!(!chained.is_nodata());
assert_eq!(chained.query_time_ms, 12);
let cname_query = result(
RecordType::CNAME,
DnsStatus::NoError,
vec![cname("www.seer.test", "edge.cdn.test.")],
);
assert_eq!(cname_query.cname_chain().count(), 0);
assert_eq!(cname_query.records().count(), 1);
assert!(!cname_query.is_nodata());
}
#[test]
fn nodata_means_noerror_without_records_of_the_type() {
assert!(result(RecordType::A, DnsStatus::NoError, vec![]).is_nodata());
assert!(result(
RecordType::A,
DnsStatus::NoError,
vec![cname("www.seer.test", "edge.cdn.test.")]
)
.is_nodata());
assert!(!result(RecordType::A, DnsStatus::NxDomain, vec![]).is_nodata());
assert!(!result(RecordType::A, DnsStatus::ServFail, vec![]).is_nodata());
assert!(result(RecordType::ANY, DnsStatus::NoError, vec![]).is_nodata());
assert!(!result(
RecordType::ANY,
DnsStatus::NoError,
vec![mx("www.seer.test", 10)]
)
.is_nodata());
}
fn ns(zone: &str, host: &str) -> DnsRecord {
rec(
zone,
RecordType::NS,
300,
RecordData::NS {
nameserver: host.to_string(),
},
)
}
#[test]
fn a_referral_is_not_nodata() {
let mut referral = result(RecordType::A, DnsStatus::NoError, vec![]);
referral.authority = vec![
ns("child.seer.test", "ns1.child.seer.test."),
ns("child.seer.test", "ns2.child.seer.test."),
];
assert_eq!(referral.referral_zone(), Some("child.seer.test"));
assert!(!referral.is_nodata());
let mut nodata = referral.clone();
nodata.authority.insert(0, soa("seer.test"));
assert_eq!(nodata.referral_zone(), None);
assert!(nodata.is_nodata());
let mut answered = result(
RecordType::A,
DnsStatus::NoError,
vec![a("www.seer.test", "192.0.2.1")],
);
answered.authority = referral.authority.clone();
assert_eq!(answered.referral_zone(), None);
let mut nx = result(RecordType::A, DnsStatus::NxDomain, vec![]);
nx.authority = referral.authority.clone();
assert_eq!(nx.referral_zone(), None);
let mut any = referral.clone();
any.record_type = RecordType::ANY;
assert_eq!(any.referral_zone(), Some("child.seer.test"));
assert!(!any.is_nodata());
let mut unfollowed = result(
RecordType::A,
DnsStatus::NoError,
vec![cname("www.seer.test", "edge.cdn.test.")],
);
unfollowed.flags = vec!["qr".to_string(), "aa".to_string(), "rd".to_string()];
unfollowed.authority = vec![ns("seer.test", "ns1.seer.test.")];
assert_eq!(unfollowed.referral_zone(), None);
unfollowed.flags.retain(|flag| flag != "aa");
assert_eq!(unfollowed.referral_zone(), Some("seer.test"));
}
#[test]
fn special_use_names_are_answered_locally() {
for name in [
"localhost",
"foo.localhost",
"1.0.0.127.in-addr.arpa",
"seer-probe-0123456789.0.0.127.in-addr.arpa",
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa",
"x.invalid",
"facebookcorewwwi.onion",
"Foo.LOCALHOST",
] {
assert!(answered_locally(name), "{name}");
}
for name in [
"www.seer.test",
"printer.local",
"1.0.0.10.in-addr.arpa",
"localhost.example.com",
"onion.seer.test",
] {
assert!(!answered_locally(name), "{name}");
}
}
#[test]
fn a_local_answer_names_no_server_and_no_invented_header() {
let local = exchange(
DnsStatus::NoError,
vec![rec(
"1.0.0.127.in-addr.arpa",
RecordType::PTR,
86400,
RecordData::PTR {
target: "localhost.".to_string(),
},
)],
)
.into_result(
"1.0.0.127.in-addr.arpa".to_string(),
RecordType::PTR,
Some("1.1.1.1"),
None,
Duration::ZERO,
);
assert!(local.answered_locally);
assert_eq!(local.server, None);
assert!(local.flags.is_empty());
assert_eq!(local.records().count(), 1);
let sent = exchange(DnsStatus::NoError, vec![]).into_result(
"www.seer.test".to_string(),
RecordType::A,
Some("1.1.1.1"),
None,
Duration::ZERO,
);
assert_eq!(sent.server.as_deref(), Some("1.1.1.1"));
assert!(!sent.answered_locally);
assert_eq!(sent.flags, ["qr", "rd", "ra"]);
}
#[test]
fn result_serializes_to_the_documented_shape() {
let mut query = result(
RecordType::A,
DnsStatus::NoError,
vec![
cname("www.seer.test", "edge.cdn.test."),
a("edge.cdn.test", "192.0.2.7"),
],
);
query.server = Some("1.1.1.1".to_string());
query.wildcard = Some(WildcardProbe {
probe_name: "seer-probe-3f9a1c2e7b.seer.test".to_string(),
present: false,
matches_answer: false,
});
let json = serde_json::to_value(&query).unwrap();
assert_eq!(
json,
serde_json::json!({
"name": "www.seer.test",
"record_type": "A",
"server": "1.1.1.1",
"answered_locally": false,
"status": "NOERROR",
"flags": ["qr", "rd", "ra"],
"answers": [
{
"name": "www.seer.test",
"record_type": "CNAME",
"ttl": 300,
"data": {"record_type": "CNAME", "value": {"target": "edge.cdn.test."}}
},
{
"name": "edge.cdn.test",
"record_type": "A",
"ttl": 300,
"data": {"record_type": "A", "value": {"address": "192.0.2.7"}}
}
],
"authority": [],
"wildcard": {
"probe_name": "seer-probe-3f9a1c2e7b.seer.test",
"present": false,
"matches_answer": false
},
"query_time_ms": 12
})
);
let back: DnsQueryResult = serde_json::from_value(json).unwrap();
assert_eq!(back, query);
let default = result(RecordType::A, DnsStatus::NxDomain, vec![]);
let json = serde_json::to_value(&default).unwrap();
assert_eq!(json["server"], serde_json::Value::Null);
assert_eq!(json["wildcard"], serde_json::Value::Null);
assert_eq!(json["status"], "NXDOMAIN");
}
}