1use std::collections::HashSet;
37use std::fmt;
38use std::str::FromStr;
39use std::time::Duration;
40
41use hickory_resolver::lookup::Lookup;
42use hickory_resolver::net::{DnsError, NetError};
43use hickory_resolver::proto::op::{Message, MessageType, Metadata, ResponseCode};
44use hickory_resolver::proto::rr::domain::usage::{
45 ResolverUsage, INVALID, IN_ADDR_ARPA_127, IP6_ARPA_1, LOCAL, LOCALHOST, ONION,
46};
47use hickory_resolver::proto::rr::Name;
48use serde::{Deserialize, Deserializer, Serialize, Serializer};
49
50use super::records::{DnsRecord, RecordData, RecordType};
51use super::resolver::to_dns_record;
52use crate::error::{Result, SeerError};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum DnsStatus {
62 NoError,
67 NxDomain,
70 ServFail,
73 Refused,
75 Other(u16),
77}
78
79impl DnsStatus {
80 pub fn is_success(self) -> bool {
82 self == DnsStatus::NoError
83 }
84
85 pub fn code(self) -> u16 {
87 match self {
88 DnsStatus::NoError => 0,
89 DnsStatus::ServFail => 2,
90 DnsStatus::NxDomain => 3,
91 DnsStatus::Refused => 5,
92 DnsStatus::Other(code) => code,
93 }
94 }
95
96 pub fn from_code(code: u16) -> Self {
98 match code {
99 0 => DnsStatus::NoError,
100 2 => DnsStatus::ServFail,
101 3 => DnsStatus::NxDomain,
102 5 => DnsStatus::Refused,
103 other => DnsStatus::Other(other),
104 }
105 }
106}
107
108const OTHER_MNEMONICS: &[(u16, &str)] = &[
111 (1, "FORMERR"),
112 (4, "NOTIMP"),
113 (6, "YXDOMAIN"),
114 (7, "YXRRSET"),
115 (8, "NXRRSET"),
116 (9, "NOTAUTH"),
117 (10, "NOTZONE"),
118 (16, "BADVERS"),
119 (17, "BADKEY"),
120 (18, "BADTIME"),
121 (19, "BADMODE"),
122 (20, "BADNAME"),
123 (21, "BADALG"),
124 (22, "BADTRUNC"),
125 (23, "BADCOOKIE"),
126];
127
128impl fmt::Display for DnsStatus {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 match self {
131 DnsStatus::NoError => f.write_str("NOERROR"),
132 DnsStatus::NxDomain => f.write_str("NXDOMAIN"),
133 DnsStatus::ServFail => f.write_str("SERVFAIL"),
134 DnsStatus::Refused => f.write_str("REFUSED"),
135 DnsStatus::Other(code) => match OTHER_MNEMONICS.iter().find(|(c, _)| c == code) {
136 Some((_, name)) => f.write_str(name),
137 None => write!(f, "RCODE{code}"),
138 },
139 }
140 }
141}
142
143impl FromStr for DnsStatus {
144 type Err = SeerError;
145
146 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
148 let upper = s.trim().to_ascii_uppercase();
149 let status = match upper.as_str() {
150 "NOERROR" => DnsStatus::NoError,
151 "NXDOMAIN" => DnsStatus::NxDomain,
152 "SERVFAIL" => DnsStatus::ServFail,
153 "REFUSED" => DnsStatus::Refused,
154 other => {
155 if let Some((code, _)) = OTHER_MNEMONICS.iter().find(|(_, name)| *name == other) {
156 DnsStatus::Other(*code)
157 } else if let Some(code) = other
158 .strip_prefix("RCODE")
159 .and_then(|n| n.parse::<u16>().ok())
160 {
161 DnsStatus::from_code(code)
162 } else {
163 return Err(SeerError::InvalidInput(format!("unknown DNS status: {s}")));
164 }
165 }
166 };
167 Ok(status)
168 }
169}
170
171impl From<ResponseCode> for DnsStatus {
172 fn from(code: ResponseCode) -> Self {
173 DnsStatus::from_code(u16::from(code))
174 }
175}
176
177impl Serialize for DnsStatus {
178 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
179 serializer.collect_str(self)
180 }
181}
182
183impl<'de> Deserialize<'de> for DnsStatus {
184 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
185 let s = String::deserialize(deserializer)?;
186 s.parse().map_err(serde::de::Error::custom)
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct DnsQueryResult {
202 pub name: String,
205 pub record_type: RecordType,
206 pub server: Option<String>,
210 pub answered_locally: bool,
216 pub status: DnsStatus,
217 pub flags: Vec<String>,
224 pub answers: Vec<DnsRecord>,
236 pub authority: Vec<DnsRecord>,
244 pub wildcard: Option<WildcardProbe>,
247 pub query_time_ms: u64,
249}
250
251impl DnsQueryResult {
252 pub fn is_nodata(&self) -> bool {
256 self.status == DnsStatus::NoError && self.lacks_answer() && self.referral_zone().is_none()
257 }
258
259 pub fn referral_zone(&self) -> Option<&str> {
271 if self.status != DnsStatus::NoError
272 || !self.lacks_answer()
273 || self.flags.iter().any(|flag| flag == "aa")
274 || self
275 .authority
276 .iter()
277 .any(|r| r.record_type == RecordType::SOA)
278 {
279 return None;
280 }
281 self.authority
282 .iter()
283 .find(|r| r.record_type == RecordType::NS)
284 .map(|r| r.name.as_str())
285 }
286
287 fn lacks_answer(&self) -> bool {
289 match self.record_type {
290 RecordType::ANY => self.answers.is_empty(),
291 wanted => !self.records().any(|r| r.record_type == wanted),
292 }
293 }
294
295 pub fn cname_chain(&self) -> impl Iterator<Item = &DnsRecord> {
297 self.answers[..chain_len(self.record_type, &self.answers)].iter()
298 }
299
300 pub fn records(&self) -> impl Iterator<Item = &DnsRecord> {
303 self.answers[chain_len(self.record_type, &self.answers)..].iter()
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct WildcardProbe {
316 pub probe_name: String,
319 pub present: bool,
322 pub matches_answer: bool,
327}
328
329fn chain_len(record_type: RecordType, answers: &[DnsRecord]) -> usize {
332 if record_type == RecordType::CNAME {
333 return 0;
334 }
335 answers
336 .iter()
337 .take_while(|r| r.record_type == RecordType::CNAME)
338 .count()
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
346pub(crate) struct Exchange {
347 pub(crate) status: DnsStatus,
348 pub(crate) flags: Vec<String>,
349 pub(crate) answers: Vec<DnsRecord>,
350 pub(crate) authority: Vec<DnsRecord>,
351}
352
353impl Exchange {
354 pub(crate) fn from_message(message: &Message) -> Self {
361 Self {
362 status: message.metadata.response_code.into(),
363 flags: header_flags(&message.metadata),
364 answers: message.answers.iter().filter_map(to_dns_record).collect(),
365 authority: message
366 .authorities
367 .iter()
368 .filter_map(to_dns_record)
369 .collect(),
370 }
371 }
372
373 pub(crate) fn from_local(
379 result: std::result::Result<Lookup, NetError>,
380 record_type: RecordType,
381 ) -> Result<Self> {
382 match result {
383 Ok(lookup) => Ok(Self::from_message(lookup.message())),
384 Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => Ok(Self {
385 status: no_records.response_code.into(),
386 flags: Vec::new(),
387 answers: Vec::new(),
388 authority: Vec::new(),
389 }),
390 Err(e) => Err(SeerError::DnsError(format!(
391 "{record_type} lookup failed: {e}"
392 ))),
393 }
394 }
395
396 pub(crate) fn ordered(mut self, query_name: &str, record_type: RecordType) -> Self {
399 self.answers = order_answers(query_name, record_type, self.answers);
400 self
401 }
402
403 pub(crate) fn records(&self, record_type: RecordType) -> &[DnsRecord] {
405 &self.answers[chain_len(record_type, &self.answers)..]
406 }
407
408 pub(crate) fn has_records(&self, record_type: RecordType) -> bool {
411 self.status == DnsStatus::NoError && !self.records(record_type).is_empty()
412 }
413
414 pub(crate) fn into_result(
418 self,
419 name: String,
420 record_type: RecordType,
421 server: Option<&str>,
422 wildcard: Option<WildcardProbe>,
423 query_time: Duration,
424 ) -> DnsQueryResult {
425 let local = answered_locally(&name);
426 DnsQueryResult {
427 name,
428 record_type,
429 server: server.filter(|_| !local).map(str::to_string),
430 answered_locally: local,
431 status: self.status,
432 flags: if local { Vec::new() } else { self.flags },
433 answers: self.answers,
434 authority: self.authority,
435 wildcard,
436 query_time_ms: duration_ms(query_time),
437 }
438 }
439}
440
441pub(crate) fn duration_ms(elapsed: Duration) -> u64 {
444 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
445}
446
447pub(crate) fn header_flags(metadata: &Metadata) -> Vec<String> {
450 [
451 (metadata.message_type == MessageType::Response, "qr"),
452 (metadata.authoritative, "aa"),
453 (metadata.truncation, "tc"),
454 (metadata.recursion_desired, "rd"),
455 (metadata.recursion_available, "ra"),
456 (metadata.authentic_data, "ad"),
457 (metadata.checking_disabled, "cd"),
458 ]
459 .into_iter()
460 .filter(|(set, _)| *set)
461 .map(|(_, flag)| flag.to_string())
462 .collect()
463}
464
465fn name_key(name: &str) -> String {
470 name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
471}
472
473pub(crate) fn order_answers(
478 query_name: &str,
479 record_type: RecordType,
480 answers: Vec<DnsRecord>,
481) -> Vec<DnsRecord> {
482 if record_type == RecordType::CNAME {
483 return answers;
484 }
485 let mut rest = answers;
486 let mut ordered = Vec::with_capacity(rest.len());
487 let mut current = name_key(query_name);
488 while let Some(pos) = rest
490 .iter()
491 .position(|r| r.record_type == RecordType::CNAME && name_key(&r.name) == current)
492 {
493 let hop = rest.remove(pos);
494 if let RecordData::CNAME { target } = &hop.data {
495 current = name_key(target);
496 }
497 ordered.push(hop);
498 }
499 if record_type == RecordType::MX {
500 rest.sort_by_key(|r| match &r.data {
501 RecordData::MX { preference, .. } => *preference,
502 _ => 0,
503 });
504 }
505 ordered.extend(rest);
506 ordered
507}
508
509pub(crate) fn dedupe_records(records: impl IntoIterator<Item = DnsRecord>) -> Vec<DnsRecord> {
514 let mut seen = HashSet::new();
515 records
516 .into_iter()
517 .filter(|r| {
518 seen.insert((
519 r.name.to_ascii_lowercase(),
520 r.record_type,
521 r.data.comparison_key(),
522 ))
523 })
524 .collect()
525}
526
527pub(crate) fn merge_any(results: Vec<Result<Exchange>>) -> Result<Exchange> {
545 let mut exchanges = Vec::with_capacity(results.len());
546 let mut last_err = None;
547 for result in results {
548 match result {
549 Ok(exchange) => exchanges.push(exchange),
550 Err(e) => last_err = Some(e),
551 }
552 }
553 let Some(first) = exchanges.first() else {
554 return Err(last_err
555 .unwrap_or_else(|| SeerError::DnsError("ANY lookup ran no queries".to_string())));
556 };
557 let status = if exchanges.iter().any(|e| e.status == DnsStatus::NoError) {
558 DnsStatus::NoError
559 } else if exchanges.iter().all(|e| e.status == DnsStatus::NxDomain) {
560 DnsStatus::NxDomain
561 } else {
562 first.status
563 };
564 let flags = first.flags.clone();
565 let answers = dedupe_records(exchanges.iter().flat_map(|e| e.answers.iter().cloned()));
566 let authority = if answers.is_empty() {
567 dedupe_records(
568 exchanges
569 .iter()
570 .filter(|e| e.status == status)
571 .flat_map(|e| e.authority.iter().cloned()),
572 )
573 } else {
574 Vec::new()
575 };
576 Ok(Exchange {
577 status,
578 flags,
579 answers,
580 authority,
581 })
582}
583
584pub(crate) fn answered_locally(name: &str) -> bool {
592 let Ok(name) = Name::from_ascii(name) else {
593 return false;
594 };
595 [
596 &LOCALHOST,
597 &IN_ADDR_ARPA_127,
598 &IP6_ARPA_1,
599 &INVALID,
600 &LOCAL,
601 &ONION,
602 ]
603 .into_iter()
604 .find(|zone| zone.name().zone_of(&name))
605 .is_some_and(|zone| {
606 matches!(
607 zone.resolver(),
608 ResolverUsage::Loopback | ResolverUsage::NxDomain
609 )
610 })
611}
612
613const PROBE_LABEL_PREFIX: &str = "seer-probe-";
615
616pub(crate) fn random_probe_label() -> String {
619 use rand::RngExt;
620 let bits = rand::rng().random::<u64>() & 0xFF_FFFF_FFFF;
621 format!("{PROBE_LABEL_PREFIX}{bits:010x}")
622}
623
624pub(crate) fn wildcard_probe_name(
635 name: &str,
636 record_type: RecordType,
637 label: &str,
638) -> Option<String> {
639 if record_type == RecordType::ANY
640 || name == "*"
641 || name.starts_with("*.")
642 || answered_locally(name)
643 {
644 return None;
645 }
646 crate::psl::registrable_parent(name)?;
647 let (_, parent) = name.split_once('.')?;
648 Some(format!("{label}.{parent}"))
649}
650
651pub(crate) fn same_record_set(a: &[DnsRecord], b: &[DnsRecord]) -> bool {
654 let key_set = |records: &[DnsRecord]| {
655 records
656 .iter()
657 .map(|r| (r.record_type, r.data.comparison_key()))
658 .collect::<HashSet<_>>()
659 };
660 key_set(a) == key_set(b)
661}
662
663pub(crate) fn wildcard_outcome(
671 probe_name: &str,
672 record_type: RecordType,
673 answer: &Exchange,
674 probe: Result<Exchange>,
675) -> Option<WildcardProbe> {
676 if !answer.has_records(record_type) {
677 return None;
678 }
679 let answered = answer.records(record_type);
680 let probe = probe.ok()?;
681 let probed = probe.records(record_type);
682 let present = match probe.status {
683 DnsStatus::NoError => probed.iter().any(|r| r.record_type == record_type),
684 DnsStatus::NxDomain => false,
685 _ => return None,
686 };
687 Some(WildcardProbe {
688 probe_name: probe_name.to_string(),
689 present,
690 matches_answer: present && same_record_set(answered, probed),
691 })
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn status_names_match_dig() {
700 assert_eq!(DnsStatus::NoError.to_string(), "NOERROR");
701 assert_eq!(DnsStatus::NxDomain.to_string(), "NXDOMAIN");
702 assert_eq!(DnsStatus::ServFail.to_string(), "SERVFAIL");
703 assert_eq!(DnsStatus::Refused.to_string(), "REFUSED");
704 assert_eq!(DnsStatus::Other(1).to_string(), "FORMERR");
705 assert_eq!(DnsStatus::Other(4).to_string(), "NOTIMP");
706 assert_eq!(DnsStatus::Other(16).to_string(), "BADVERS");
707 assert_eq!(DnsStatus::Other(3000).to_string(), "RCODE3000");
708 }
709
710 #[test]
711 fn status_maps_every_hickory_code_by_value() {
712 assert_eq!(DnsStatus::from(ResponseCode::NoError), DnsStatus::NoError);
713 assert_eq!(DnsStatus::from(ResponseCode::NXDomain), DnsStatus::NxDomain);
714 assert_eq!(DnsStatus::from(ResponseCode::ServFail), DnsStatus::ServFail);
715 assert_eq!(DnsStatus::from(ResponseCode::Refused), DnsStatus::Refused);
716 assert_eq!(DnsStatus::from(ResponseCode::NotImp), DnsStatus::Other(4));
717 assert_eq!(
718 DnsStatus::from(ResponseCode::Unknown(3000)),
719 DnsStatus::Other(3000)
720 );
721 }
722
723 #[test]
724 fn status_round_trips_through_its_name() {
725 for code in [0u16, 1, 2, 3, 4, 5, 6, 9, 16, 23, 3000] {
726 let status = DnsStatus::from_code(code);
727 assert_eq!(status.to_string().parse::<DnsStatus>().unwrap(), status);
728 assert_eq!(status.code(), code);
729 }
730 assert_eq!(
731 "nxdomain".parse::<DnsStatus>().unwrap(),
732 DnsStatus::NxDomain
733 );
734 assert!("NOPE".parse::<DnsStatus>().is_err());
735 }
736
737 #[test]
738 fn status_serializes_as_its_name() {
739 assert_eq!(
740 serde_json::to_string(&DnsStatus::NxDomain).unwrap(),
741 "\"NXDOMAIN\""
742 );
743 assert_eq!(
744 serde_json::from_str::<DnsStatus>("\"SERVFAIL\"").unwrap(),
745 DnsStatus::ServFail
746 );
747 }
748
749 use hickory_resolver::net::NoRecords;
752 use hickory_resolver::proto::op::{OpCode, Query};
753 use hickory_resolver::proto::rr::rdata::SOA;
754 use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as WireType};
755
756 fn rec(name: &str, record_type: RecordType, ttl: u32, data: RecordData) -> DnsRecord {
757 DnsRecord {
758 name: name.to_string(),
759 record_type,
760 ttl,
761 data,
762 }
763 }
764
765 fn a(name: &str, address: &str) -> DnsRecord {
766 rec(
767 name,
768 RecordType::A,
769 300,
770 RecordData::A {
771 address: address.to_string(),
772 },
773 )
774 }
775
776 fn cname(name: &str, target: &str) -> DnsRecord {
777 rec(
778 name,
779 RecordType::CNAME,
780 300,
781 RecordData::CNAME {
782 target: target.to_string(),
783 },
784 )
785 }
786
787 fn mx(name: &str, preference: u16) -> DnsRecord {
788 rec(
789 name,
790 RecordType::MX,
791 300,
792 RecordData::MX {
793 preference,
794 exchange: format!("mx{preference}.seer.test."),
795 },
796 )
797 }
798
799 fn exchange(status: DnsStatus, answers: Vec<DnsRecord>) -> Exchange {
800 Exchange {
801 status,
802 flags: vec!["qr".to_string(), "rd".to_string(), "ra".to_string()],
803 answers,
804 authority: vec![],
805 }
806 }
807
808 fn negative(status: DnsStatus, authority: Vec<DnsRecord>) -> Exchange {
810 Exchange {
811 authority,
812 ..exchange(status, vec![])
813 }
814 }
815
816 fn soa_record(zone: &str) -> Record<SOA> {
817 Record::from_rdata(
818 Name::from_ascii(format!("{zone}.")).unwrap(),
819 900,
820 SOA::new(
821 Name::from_ascii(format!("ns1.{zone}.")).unwrap(),
822 Name::from_ascii(format!("hostmaster.{zone}.")).unwrap(),
823 7,
824 7200,
825 3600,
826 1209600,
827 300,
828 ),
829 )
830 }
831
832 fn query_for(name: &str) -> Query {
833 Query::query(Name::from_ascii(name).unwrap(), WireType::A)
834 }
835
836 #[test]
837 fn header_flags_follow_digs_order() {
838 let mut metadata = Metadata::new(7, MessageType::Response, OpCode::Query);
839 metadata.recursion_desired = true;
840 metadata.recursion_available = true;
841 assert_eq!(header_flags(&metadata), ["qr", "rd", "ra"]);
842
843 metadata.authoritative = true;
844 metadata.truncation = true;
845 metadata.authentic_data = true;
846 metadata.checking_disabled = true;
847 assert_eq!(
848 header_flags(&metadata),
849 ["qr", "aa", "tc", "rd", "ra", "ad", "cd"]
850 );
851
852 let query = Metadata::new(7, MessageType::Query, OpCode::Query);
854 assert!(header_flags(&query).is_empty());
855 }
856
857 fn response(name: &str, code: ResponseCode) -> Message {
860 let mut message = Message::response(7, OpCode::Query);
861 message.metadata.response_code = code;
862 message.metadata.recursion_desired = true;
863 message.metadata.recursion_available = true;
864 message.add_query(query_for(name));
865 message
866 }
867
868 fn wire_record(owner: &str, data: RData) -> Record {
869 Record::from_rdata(Name::from_ascii(owner).unwrap(), 300, data)
870 }
871
872 fn wire_cname(owner: &str, target: &str) -> Record {
873 wire_record(
874 owner,
875 RData::CNAME(hickory_resolver::proto::rr::rdata::CNAME(
876 Name::from_ascii(target).unwrap(),
877 )),
878 )
879 }
880
881 #[test]
882 fn from_message_reads_status_flags_and_sections_as_sent() {
883 let mut message = response("www.seer.test.", ResponseCode::NoError);
884 message.add_answer(wire_record(
885 "www.seer.test.",
886 RData::A(hickory_resolver::proto::rr::rdata::A::new(192, 0, 2, 1)),
887 ));
888 let exchange = Exchange::from_message(&message);
889 assert_eq!(exchange.status, DnsStatus::NoError);
890 assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
891 assert_eq!(exchange.answers, [a("www.seer.test", "192.0.2.1")]);
892 assert!(exchange.authority.is_empty());
893 }
894
895 #[test]
896 fn from_message_keeps_the_chain_and_header_of_a_negative_answer() {
897 let mut dangling = response("www.seer.test.", ResponseCode::NXDomain);
902 dangling.add_answer(wire_cname("www.seer.test.", "gone.cdn.test."));
903 dangling.add_authority(soa_record("cdn.test").into_record_of_rdata());
904 let exchange = Exchange::from_message(&dangling);
905 assert_eq!(exchange.status, DnsStatus::NxDomain);
906 assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
907 assert_eq!(exchange.answers, [cname("www.seer.test", "gone.cdn.test.")]);
908 assert_eq!(exchange.authority.len(), 1);
909 assert_eq!(exchange.authority[0].name, "cdn.test");
910 assert_eq!(exchange.authority[0].record_type, RecordType::SOA);
911
912 for (code, status) in [
914 (ResponseCode::ServFail, DnsStatus::ServFail),
915 (ResponseCode::Refused, DnsStatus::Refused),
916 (ResponseCode::NotImp, DnsStatus::Other(4)),
917 (ResponseCode::BADVERS, DnsStatus::Other(16)),
918 ] {
919 let exchange = Exchange::from_message(&response("www.seer.test.", code));
920 assert_eq!(exchange, negative(status, vec![]), "{code}");
921 }
922 }
923
924 #[test]
925 fn from_local_maps_the_resolvers_own_answers() {
926 let answer = wire_record(
928 "localhost.",
929 RData::A(hickory_resolver::proto::rr::rdata::A::new(127, 0, 0, 1)),
930 );
931 let lookup = Lookup::new_with_max_ttl(query_for("localhost."), [answer]);
932 let exchange = Exchange::from_local(Ok(lookup), RecordType::A).unwrap();
933 assert_eq!(exchange.status, DnsStatus::NoError);
934 assert_eq!(exchange.answers, [a("localhost", "127.0.0.1")]);
935
936 for (code, status) in [
939 (ResponseCode::NoError, DnsStatus::NoError),
940 (ResponseCode::NXDomain, DnsStatus::NxDomain),
941 ] {
942 let no_records = NoRecords::new(query_for("x.onion."), code);
943 let exchange =
944 Exchange::from_local(Err(NetError::from(no_records)), RecordType::MX).unwrap();
945 assert_eq!(exchange.status, status);
946 assert!(exchange.flags.is_empty() && exchange.answers.is_empty());
947 assert!(exchange.authority.is_empty());
948 }
949
950 for transport in [NetError::Timeout, NetError::NoConnections] {
951 let err = Exchange::from_local(Err(transport), RecordType::MX).unwrap_err();
952 assert!(matches!(&err, SeerError::DnsError(m) if m.starts_with("MX lookup failed")));
953 }
954 }
955
956 #[test]
957 fn order_answers_puts_the_chain_first_hop_by_hop() {
958 let answers = vec![
960 a("edge.origin.test", "192.0.2.7"),
961 cname("edge.cdn.test", "edge.origin.test."),
962 cname("WWW.Seer.Test", "edge.cdn.test."),
963 ];
964 let ordered = order_answers("www.seer.test", RecordType::A, answers);
965 assert_eq!(
966 ordered,
967 vec![
968 cname("WWW.Seer.Test", "edge.cdn.test."),
969 cname("edge.cdn.test", "edge.origin.test."),
970 a("edge.origin.test", "192.0.2.7"),
971 ]
972 );
973 }
974
975 #[test]
976 fn order_answers_leaves_a_cname_answer_and_sorts_mx_after_the_chain() {
977 let answers = vec![cname("www.seer.test", "edge.cdn.test.")];
978 assert_eq!(
979 order_answers("www.seer.test", RecordType::CNAME, answers.clone()),
980 answers
981 );
982
983 let answers = vec![
984 mx("mail.cdn.test", 30),
985 cname("mail.seer.test", "mail.cdn.test."),
986 mx("mail.cdn.test", 10),
987 ];
988 let ordered = order_answers("mail.seer.test", RecordType::MX, answers);
989 assert_eq!(
990 ordered,
991 vec![
992 cname("mail.seer.test", "mail.cdn.test."),
993 mx("mail.cdn.test", 10),
994 mx("mail.cdn.test", 30),
995 ]
996 );
997 }
998
999 #[test]
1000 fn order_answers_walks_a_chain_through_idn_names() {
1001 let answers = vec![
1004 a("edge.xn--caf-dma.test", "192.0.2.7"),
1005 cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
1006 ];
1007 let ordered = order_answers("xn--bcher-kva.seer.test", RecordType::A, answers);
1008 assert_eq!(
1009 ordered,
1010 vec![
1011 cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
1012 a("edge.xn--caf-dma.test", "192.0.2.7"),
1013 ]
1014 );
1015 assert_eq!(name_key("Edge.XN--CAF-DMA.test."), "edge.xn--caf-dma.test");
1016 assert_eq!(name_key("WWW.seer.test"), "www.seer.test");
1017 }
1018
1019 #[test]
1020 fn order_answers_terminates_on_a_cname_loop() {
1021 let answers = vec![
1022 cname("a.seer.test", "b.seer.test."),
1023 cname("b.seer.test", "a.seer.test."),
1024 ];
1025 let ordered = order_answers("a.seer.test", RecordType::A, answers);
1026 assert_eq!(ordered.len(), 2);
1027 assert_eq!(ordered[0].name, "a.seer.test");
1028 }
1029
1030 #[test]
1031 fn dedupe_keeps_the_first_copy_of_each_rr() {
1032 let mut later = cname("WWW.seer.test", "Edge.CDN.test.");
1033 later.ttl = 299;
1034 let records = vec![
1035 cname("www.seer.test", "edge.cdn.test."),
1036 a("edge.cdn.test", "192.0.2.7"),
1037 later,
1038 a("edge.cdn.test", "192.0.2.8"),
1039 ];
1040 assert_eq!(
1041 dedupe_records(records),
1042 vec![
1043 cname("www.seer.test", "edge.cdn.test."),
1044 a("edge.cdn.test", "192.0.2.7"),
1045 a("edge.cdn.test", "192.0.2.8"),
1046 ]
1047 );
1048 }
1049
1050 fn soa(zone: &str) -> DnsRecord {
1051 to_dns_record(&soa_record(zone).into_record_of_rdata()).unwrap()
1052 }
1053
1054 #[test]
1055 fn merge_any_status_flags_and_answers() {
1056 let chain = cname("www.seer.test", "edge.cdn.test.");
1057 let nodata = negative(DnsStatus::NoError, vec![soa("seer.test")]);
1058 let mut authoritative = nodata.clone();
1059 authoritative.flags = vec!["qr".to_string(), "aa".to_string()];
1060 let merged = merge_any(vec![
1061 Err(SeerError::DnsError("A lookup failed: timeout".to_string())),
1062 Ok(authoritative),
1063 Ok(exchange(
1064 DnsStatus::NoError,
1065 vec![chain.clone(), a("edge.cdn.test", "192.0.2.7")],
1066 )),
1067 Err(SeerError::DnsError("MX lookup failed: timeout".to_string())),
1068 Ok(exchange(DnsStatus::NoError, vec![chain.clone()])),
1069 Ok(negative(DnsStatus::NoError, vec![soa("test")])),
1071 ])
1072 .unwrap();
1073 assert_eq!(merged.status, DnsStatus::NoError);
1074 assert_eq!(merged.flags, ["qr", "aa"]);
1076 assert_eq!(merged.answers, vec![chain, a("edge.cdn.test", "192.0.2.7")]);
1077 assert!(merged.authority.is_empty(), "{:?}", merged.authority);
1080
1081 let all_nodata = merge_any(vec![
1084 Ok(nodata.clone()),
1085 Ok(negative(DnsStatus::ServFail, vec![soa("other")])),
1086 Ok(nodata),
1087 ])
1088 .unwrap();
1089 assert_eq!(all_nodata.status, DnsStatus::NoError);
1090 assert_eq!(all_nodata.authority, [soa("seer.test")]);
1091
1092 let all_nx = merge_any(vec![
1093 Ok(negative(DnsStatus::NxDomain, vec![soa("seer.test")])),
1094 Ok(negative(DnsStatus::NxDomain, vec![soa("seer.test")])),
1095 ])
1096 .unwrap();
1097 assert_eq!(all_nx.status, DnsStatus::NxDomain);
1098 assert_eq!(
1099 all_nx.flags,
1100 ["qr", "rd", "ra"],
1101 "a negative answer has a header"
1102 );
1103 assert_eq!(all_nx.authority, [soa("seer.test")]);
1104
1105 let mixed = merge_any(vec![
1107 Ok(negative(DnsStatus::ServFail, vec![])),
1108 Ok(negative(DnsStatus::NxDomain, vec![])),
1109 ])
1110 .unwrap();
1111 assert_eq!(mixed.status, DnsStatus::ServFail);
1112 }
1113
1114 #[test]
1115 fn merge_any_errors_only_when_every_sub_query_failed() {
1116 let err = merge_any(vec![
1117 Err(SeerError::DnsError("A lookup failed: timeout".to_string())),
1118 Err(SeerError::DnsError(
1119 "AAAA lookup failed: timeout".to_string(),
1120 )),
1121 ])
1122 .unwrap_err();
1123 assert!(err.to_string().contains("AAAA lookup failed"), "{err}");
1124 assert!(merge_any(vec![]).is_err());
1125 }
1126
1127 #[test]
1128 fn probe_label_is_ten_random_lowercase_hex_digits() {
1129 let label = random_probe_label();
1130 let hex = label.strip_prefix(PROBE_LABEL_PREFIX).expect("prefixed");
1131 assert_eq!(hex.len(), 10, "{label}");
1132 assert!(hex
1133 .chars()
1134 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
1135 assert_ne!(random_probe_label(), label, "40 random bits per label");
1136 }
1137
1138 #[test]
1139 fn probe_name_is_a_sibling_below_the_registrable_domain() {
1140 let probe =
1141 |name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
1142 assert_eq!(
1143 probe("www.example.com", RecordType::A).as_deref(),
1144 Some("seer-probe-x.example.com")
1145 );
1146 assert_eq!(
1147 probe("a.b.example.co.uk", RecordType::TXT).as_deref(),
1148 Some("seer-probe-x.b.example.co.uk")
1149 );
1150 assert_eq!(
1152 probe("_sip._tcp.example.com", RecordType::SRV).as_deref(),
1153 Some("seer-probe-x._tcp.example.com")
1154 );
1155 assert_eq!(
1156 probe("_443._tcp.www.example.com", RecordType::TLSA).as_deref(),
1157 Some("seer-probe-x._tcp.www.example.com")
1158 );
1159 }
1160
1161 #[test]
1162 fn probe_is_never_run_at_tld_level_for_wildcards_or_for_any() {
1163 let probe =
1164 |name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
1165 assert_eq!(probe("example.com", RecordType::A), None);
1167 assert_eq!(probe("example.co.uk", RecordType::A), None);
1168 assert_eq!(probe("com", RecordType::A), None);
1169 assert_eq!(probe("github.io", RecordType::A), None);
1171 assert_eq!(probe("*.example.com", RecordType::A), None);
1172 assert_eq!(probe("*", RecordType::A), None);
1173 assert_eq!(probe("www.example.com", RecordType::ANY), None);
1174 assert_eq!(probe("1.0.0.127.in-addr.arpa", RecordType::PTR), None);
1177 assert_eq!(probe("a.b.localhost", RecordType::A), None);
1178 }
1179
1180 #[test]
1181 fn record_sets_compare_by_type_and_data_only() {
1182 let ours = vec![
1183 a("www.seer.test", "192.0.2.1"),
1184 a("www.seer.test", "192.0.2.2"),
1185 ];
1186 let mut theirs = vec![
1187 a("seer-probe-1.seer.test", "192.0.2.2"),
1188 a("seer-probe-1.seer.test", "192.0.2.1"),
1189 ];
1190 theirs[0].ttl = 5;
1191 assert!(same_record_set(&ours, &theirs));
1192 assert!(!same_record_set(&ours, &theirs[..1]));
1193 assert!(!same_record_set(
1194 &[cname("x.seer.test", "a.test.")],
1195 &[a("x.seer.test", "a.test.")]
1196 ));
1197 }
1198
1199 #[test]
1200 fn wildcard_outcome_needs_a_positive_answer_and_a_completed_probe() {
1201 let answer = exchange(DnsStatus::NoError, vec![a("www.seer.test", "192.0.2.1")]);
1202 let probe_answer = |address: &str| {
1203 Ok(exchange(
1204 DnsStatus::NoError,
1205 vec![a("seer-probe-1.seer.test", address)],
1206 ))
1207 };
1208 let judge = |answer: &Exchange, probe| {
1209 wildcard_outcome("seer-probe-1.seer.test", RecordType::A, answer, probe)
1210 };
1211
1212 let matched = judge(&answer, probe_answer("192.0.2.1")).unwrap();
1213 assert_eq!(
1214 matched,
1215 WildcardProbe {
1216 probe_name: "seer-probe-1.seer.test".to_string(),
1217 present: true,
1218 matches_answer: true,
1219 }
1220 );
1221 let different = judge(&answer, probe_answer("192.0.2.99")).unwrap();
1222 assert!(different.present && !different.matches_answer);
1223
1224 let absent = judge(&answer, Ok(negative(DnsStatus::NxDomain, vec![]))).unwrap();
1225 assert!(!absent.present && !absent.matches_answer);
1226 let nodata = judge(&answer, Ok(negative(DnsStatus::NoError, vec![]))).unwrap();
1227 assert!(!nodata.present);
1228
1229 assert_eq!(
1231 judge(&answer, Ok(negative(DnsStatus::ServFail, vec![]))),
1232 None
1233 );
1234 assert_eq!(
1235 judge(&answer, Err(SeerError::DnsError("timeout".to_string()))),
1236 None
1237 );
1238
1239 let nx = negative(DnsStatus::NxDomain, vec![]);
1241 assert_eq!(judge(&nx, probe_answer("192.0.2.1")), None);
1242 let chain_only = exchange(
1243 DnsStatus::NoError,
1244 vec![cname("www.seer.test", "edge.cdn.test.")],
1245 );
1246 assert_eq!(judge(&chain_only, probe_answer("192.0.2.1")), None);
1247 }
1248
1249 fn result(
1250 record_type: RecordType,
1251 status: DnsStatus,
1252 answers: Vec<DnsRecord>,
1253 ) -> DnsQueryResult {
1254 exchange(status, answers).into_result(
1255 "www.seer.test".to_string(),
1256 record_type,
1257 None,
1258 None,
1259 Duration::from_millis(12),
1260 )
1261 }
1262
1263 #[test]
1264 fn result_splits_the_chain_from_the_records() {
1265 let chained = result(
1266 RecordType::A,
1267 DnsStatus::NoError,
1268 vec![
1269 cname("www.seer.test", "edge.cdn.test."),
1270 a("edge.cdn.test", "192.0.2.7"),
1271 ],
1272 );
1273 assert_eq!(chained.cname_chain().count(), 1);
1274 assert_eq!(
1275 chained
1276 .records()
1277 .map(|r| r.name.as_str())
1278 .collect::<Vec<_>>(),
1279 ["edge.cdn.test"]
1280 );
1281 assert!(!chained.is_nodata());
1282 assert_eq!(chained.query_time_ms, 12);
1283
1284 let cname_query = result(
1286 RecordType::CNAME,
1287 DnsStatus::NoError,
1288 vec![cname("www.seer.test", "edge.cdn.test.")],
1289 );
1290 assert_eq!(cname_query.cname_chain().count(), 0);
1291 assert_eq!(cname_query.records().count(), 1);
1292 assert!(!cname_query.is_nodata());
1293 }
1294
1295 #[test]
1296 fn nodata_means_noerror_without_records_of_the_type() {
1297 assert!(result(RecordType::A, DnsStatus::NoError, vec![]).is_nodata());
1298 assert!(result(
1300 RecordType::A,
1301 DnsStatus::NoError,
1302 vec![cname("www.seer.test", "edge.cdn.test.")]
1303 )
1304 .is_nodata());
1305 assert!(!result(RecordType::A, DnsStatus::NxDomain, vec![]).is_nodata());
1306 assert!(!result(RecordType::A, DnsStatus::ServFail, vec![]).is_nodata());
1307 assert!(result(RecordType::ANY, DnsStatus::NoError, vec![]).is_nodata());
1309 assert!(!result(
1310 RecordType::ANY,
1311 DnsStatus::NoError,
1312 vec![mx("www.seer.test", 10)]
1313 )
1314 .is_nodata());
1315 }
1316
1317 fn ns(zone: &str, host: &str) -> DnsRecord {
1318 rec(
1319 zone,
1320 RecordType::NS,
1321 300,
1322 RecordData::NS {
1323 nameserver: host.to_string(),
1324 },
1325 )
1326 }
1327
1328 #[test]
1329 fn a_referral_is_not_nodata() {
1330 let mut referral = result(RecordType::A, DnsStatus::NoError, vec![]);
1334 referral.authority = vec![
1335 ns("child.seer.test", "ns1.child.seer.test."),
1336 ns("child.seer.test", "ns2.child.seer.test."),
1337 ];
1338 assert_eq!(referral.referral_zone(), Some("child.seer.test"));
1339 assert!(!referral.is_nodata());
1340
1341 let mut nodata = referral.clone();
1344 nodata.authority.insert(0, soa("seer.test"));
1345 assert_eq!(nodata.referral_zone(), None);
1346 assert!(nodata.is_nodata());
1347
1348 let mut answered = result(
1351 RecordType::A,
1352 DnsStatus::NoError,
1353 vec![a("www.seer.test", "192.0.2.1")],
1354 );
1355 answered.authority = referral.authority.clone();
1356 assert_eq!(answered.referral_zone(), None);
1357 let mut nx = result(RecordType::A, DnsStatus::NxDomain, vec![]);
1358 nx.authority = referral.authority.clone();
1359 assert_eq!(nx.referral_zone(), None);
1360 let mut any = referral.clone();
1362 any.record_type = RecordType::ANY;
1363 assert_eq!(any.referral_zone(), Some("child.seer.test"));
1364 assert!(!any.is_nodata());
1365
1366 let mut unfollowed = result(
1370 RecordType::A,
1371 DnsStatus::NoError,
1372 vec![cname("www.seer.test", "edge.cdn.test.")],
1373 );
1374 unfollowed.flags = vec!["qr".to_string(), "aa".to_string(), "rd".to_string()];
1375 unfollowed.authority = vec![ns("seer.test", "ns1.seer.test.")];
1376 assert_eq!(unfollowed.referral_zone(), None);
1377 unfollowed.flags.retain(|flag| flag != "aa");
1378 assert_eq!(unfollowed.referral_zone(), Some("seer.test"));
1379 }
1380
1381 #[test]
1382 fn special_use_names_are_answered_locally() {
1383 for name in [
1384 "localhost",
1385 "foo.localhost",
1386 "1.0.0.127.in-addr.arpa",
1387 "seer-probe-0123456789.0.0.127.in-addr.arpa",
1388 "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",
1389 "x.invalid",
1390 "facebookcorewwwi.onion",
1391 "Foo.LOCALHOST",
1392 ] {
1393 assert!(answered_locally(name), "{name}");
1394 }
1395 for name in [
1396 "www.seer.test",
1397 "printer.local",
1399 "1.0.0.10.in-addr.arpa",
1400 "localhost.example.com",
1401 "onion.seer.test",
1402 ] {
1403 assert!(!answered_locally(name), "{name}");
1404 }
1405 }
1406
1407 #[test]
1408 fn a_local_answer_names_no_server_and_no_invented_header() {
1409 let local = exchange(
1413 DnsStatus::NoError,
1414 vec![rec(
1415 "1.0.0.127.in-addr.arpa",
1416 RecordType::PTR,
1417 86400,
1418 RecordData::PTR {
1419 target: "localhost.".to_string(),
1420 },
1421 )],
1422 )
1423 .into_result(
1424 "1.0.0.127.in-addr.arpa".to_string(),
1425 RecordType::PTR,
1426 Some("1.1.1.1"),
1427 None,
1428 Duration::ZERO,
1429 );
1430 assert!(local.answered_locally);
1431 assert_eq!(local.server, None);
1432 assert!(local.flags.is_empty());
1433 assert_eq!(local.records().count(), 1);
1434
1435 let sent = exchange(DnsStatus::NoError, vec![]).into_result(
1436 "www.seer.test".to_string(),
1437 RecordType::A,
1438 Some("1.1.1.1"),
1439 None,
1440 Duration::ZERO,
1441 );
1442 assert_eq!(sent.server.as_deref(), Some("1.1.1.1"));
1443 assert!(!sent.answered_locally);
1444 assert_eq!(sent.flags, ["qr", "rd", "ra"]);
1445 }
1446
1447 #[test]
1448 fn result_serializes_to_the_documented_shape() {
1449 let mut query = result(
1450 RecordType::A,
1451 DnsStatus::NoError,
1452 vec![
1453 cname("www.seer.test", "edge.cdn.test."),
1454 a("edge.cdn.test", "192.0.2.7"),
1455 ],
1456 );
1457 query.server = Some("1.1.1.1".to_string());
1458 query.wildcard = Some(WildcardProbe {
1459 probe_name: "seer-probe-3f9a1c2e7b.seer.test".to_string(),
1460 present: false,
1461 matches_answer: false,
1462 });
1463 let json = serde_json::to_value(&query).unwrap();
1464 assert_eq!(
1465 json,
1466 serde_json::json!({
1467 "name": "www.seer.test",
1468 "record_type": "A",
1469 "server": "1.1.1.1",
1470 "answered_locally": false,
1471 "status": "NOERROR",
1472 "flags": ["qr", "rd", "ra"],
1473 "answers": [
1474 {
1475 "name": "www.seer.test",
1476 "record_type": "CNAME",
1477 "ttl": 300,
1478 "data": {"record_type": "CNAME", "value": {"target": "edge.cdn.test."}}
1479 },
1480 {
1481 "name": "edge.cdn.test",
1482 "record_type": "A",
1483 "ttl": 300,
1484 "data": {"record_type": "A", "value": {"address": "192.0.2.7"}}
1485 }
1486 ],
1487 "authority": [],
1488 "wildcard": {
1489 "probe_name": "seer-probe-3f9a1c2e7b.seer.test",
1490 "present": false,
1491 "matches_answer": false
1492 },
1493 "query_time_ms": 12
1494 })
1495 );
1496 let back: DnsQueryResult = serde_json::from_value(json).unwrap();
1497 assert_eq!(back, query);
1498
1499 let default = result(RecordType::A, DnsStatus::NxDomain, vec![]);
1501 let json = serde_json::to_value(&default).unwrap();
1502 assert_eq!(json["server"], serde_json::Value::Null);
1503 assert_eq!(json["wildcard"], serde_json::Value::Null);
1504 assert_eq!(json["status"], "NXDOMAIN");
1505 }
1506}