dns_resolver/
lib.rs

1use anyhow::Context;
2use arc_swap::ArcSwap;
3use hickory_resolver::proto::op::ResponseCode;
4pub use hickory_resolver::proto::rr::rdata::tlsa::{CertUsage, Matching, Selector, TLSA};
5pub use hickory_resolver::proto::rr::Name;
6use hickory_resolver::proto::rr::{RData, RecordType};
7use hickory_resolver::proto::ProtoError;
8use kumo_address::host::HostAddress;
9use kumo_address::host_or_socket::HostOrSocketAddress;
10use kumo_address::resolvable::ResolvableSocketAddr;
11use kumo_log_types::ResolvedAddress;
12use kumo_prometheus::declare_metric;
13use lruttl::declare_cache;
14use rand::prelude::SliceRandom;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::net::{IpAddr, Ipv6Addr, SocketAddr};
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::{Arc, LazyLock};
20use std::time::{Duration, Instant};
21use tokio::sync::Semaphore;
22use tokio::time::timeout;
23
24mod resolver;
25#[cfg(feature = "unbound")]
26pub use resolver::UnboundResolver;
27pub use resolver::{
28    ptr_host, reverse_ip, AggregateResolver, Answer, DnsError, HickoryResolver, IpDisplay,
29    Resolver, TestResolver,
30};
31
32// An `ArcSwap` can only hold `Sized` types, so we cannot stuff a `dyn Resolver` directly into it.
33// Instead, the documentation recommends adding a level of indirection, so we wrap the `Resolver`
34// trait object in a `Box`. In the context of DNS requests, the additional pointer chasing should
35// not be a significant performance concern.
36static RESOLVER: LazyLock<ArcSwap<Box<dyn Resolver>>> =
37    LazyLock::new(|| ArcSwap::from_pointee(Box::new(default_resolver())));
38
39declare_cache! {
40/// Caches domain name to computed set of MailExchanger records
41static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, String>>::new("dns_resolver_mx", 64 * 1024);
42}
43declare_cache! {
44/// Caches domain name to ipv4 records and their DNSSEC secure status
45static IPV4_CACHE: LruCacheWithTtl<Name, Arc<IpAddresses>>::new("dns_resolver_ipv4", 1024);
46}
47declare_cache! {
48/// Caches domain name to ipv6 records and their DNSSEC secure status
49static IPV6_CACHE: LruCacheWithTtl<Name, Arc<IpAddresses>>::new("dns_resolver_ipv6", 1024);
50}
51declare_cache! {
52/// Caches domain name to the combined set of ipv4 and ipv6 records and their
53/// DNSSEC secure status
54static IP_CACHE: LruCacheWithTtl<(Name, IpLookupStrategy), Arc<IpAddresses>>::new("dns_resolver_ip", 1024);
55}
56
57/// Maximum number of concurrent mx resolves permitted
58static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
59static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
60    LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
61
62/// 5 seconds in ms
63static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
64
65/// 5 minutes in ms
66static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
67
68declare_metric! {
69/// number of `MailExchanger::resolve` calls currently in progress.
70static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
71}
72
73declare_metric! {
74/// Total number of successful `MailExchanger::resolve` calls
75static MX_SUCCESS: IntCounter(
76        "dns_mx_resolve_status_ok");
77}
78
79declare_metric! {
80/// Total number of failed `MailExchanger::resolve` calls.
81///
82/// Spikes may indicate an issue with your DNS configuration
83/// or infrastructure, or may simply indicate that the traffic
84/// is destined for bogus addresses.
85static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
86}
87
88declare_metric! {
89/// Total number of MailExchanger::resolve calls satisfied by level 1 cache.
90///
91/// Redundant with the newer [lruttl_hit_count{cache_name="dns_resolver_mx"}](lruttl_hit_count.md)
92/// metric.
93static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
94}
95
96declare_metric! {
97/// Total number of MailExchanger::resolve calls that resulted in an MX DNS request to the next level of cache
98///
99/// Redundant with the newer [lruttl_miss_count{cache_name="dns_resolver_mx"}](lruttl_miss_count.md)
100/// metric.
101static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
102}
103
104fn default_resolver() -> impl Resolver {
105    #[cfg(feature = "default-unbound")]
106    return UnboundResolver::new().unwrap();
107    #[cfg(not(feature = "default-unbound"))]
108    return HickoryResolver::new().expect("Parsing /etc/resolv.conf failed");
109}
110
111pub fn set_mx_concurrency_limit(n: usize) {
112    MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
113}
114
115pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
116    let ms = duration
117        .as_millis()
118        .try_into()
119        .context("set_mx_timeout: duration is too large")?;
120    MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
121    Ok(())
122}
123
124pub fn get_mx_timeout() -> Duration {
125    Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
126}
127
128pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
129    let ms = duration
130        .as_millis()
131        .try_into()
132        .context("set_mx_negative_cache_ttl: duration is too large")?;
133    MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
134    Ok(())
135}
136
137pub fn get_mx_negative_ttl() -> Duration {
138    Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
139}
140
141#[derive(Clone, Debug, Serialize)]
142pub struct MailExchanger {
143    pub domain_name: String,
144    pub hosts: Vec<String>,
145    pub site_name: String,
146    pub by_pref: BTreeMap<u16, Vec<String>>,
147    pub is_domain_literal: bool,
148    /// DNSSEC verified
149    pub is_secure: bool,
150    pub is_mx: bool,
151    #[serde(skip)]
152    expires: Option<Instant>,
153}
154
155pub fn fully_qualify(domain_name: &str) -> Result<Name, ProtoError> {
156    let mut name = Name::from_str_relaxed(domain_name)?.to_lowercase();
157
158    // Treat it as fully qualified
159    name.set_fqdn(true);
160
161    Ok(name)
162}
163
164pub fn reconfigure_resolver(resolver: impl Resolver) {
165    RESOLVER.store(Arc::new(Box::new(resolver)));
166}
167
168pub fn get_resolver() -> Arc<Box<dyn Resolver>> {
169    RESOLVER.load_full()
170}
171
172/// The outcome of attempting to resolve DANE TLSA records for an MX host,
173/// per <https://datatracker.ietf.org/doc/html/rfc7672>.
174#[derive(Debug, Clone, PartialEq)]
175pub enum DaneStatus {
176    /// The host is not DANE-eligible: either the MX host's address (A/AAAA)
177    /// RRset was not securely (DNSSEC) resolved, or there was a secure proof
178    /// that no TLSA records exist (NODATA/NXDOMAIN). Delivery should continue
179    /// using the normal (opportunistic / configured) TLS policy.
180    NotApplicable,
181    /// Secure, usable DANE-TA(2)/DANE-EE(3) TLSA records were found. The
182    /// connection must use TLS and authenticate the peer against these records.
183    Records(Vec<TLSA>),
184    /// Secure TLSA records were published, but none are usable for DANE SMTP
185    /// (e.g. only PKIX-TA(0)/PKIX-EE(1), private/unassigned usages, or
186    /// unsupported selector/matching types). Per RFC 7672 section 4.1 the
187    /// client must still require STARTTLS but cannot authenticate the peer.
188    Unusable,
189    /// The secure status of the TLSA records (or of the MX host's address
190    /// records) could not be determined, e.g. due to SERVFAIL, a timeout, or a
191    /// DNSSEC validation failure (bogus). To preserve downgrade resistance the
192    /// delivery must be deferred rather than continuing without authentication.
193    TempFail(String),
194}
195
196/// Decides whether a single TLSA record is usable for DANE SMTP.
197///
198/// RFC 7672 section 3.1.3 restricts SMTP DANE to the DANE-TA(2) and DANE-EE(3)
199/// certificate usages; PKIX-TA(0), PKIX-EE(1), and private/unassigned usages
200/// MUST be treated as unusable. We also require a selector and matching type
201/// that we (and OpenSSL) understand, and a digest of the correct length.
202fn tlsa_is_usable(tlsa: &TLSA) -> bool {
203    match tlsa.cert_usage {
204        CertUsage::DaneTa | CertUsage::DaneEe => {}
205        _ => return false,
206    }
207    match tlsa.selector {
208        Selector::Full | Selector::Spki => {}
209        _ => return false,
210    }
211    let expected_len = match tlsa.matching {
212        Matching::Raw => None,
213        Matching::Sha256 => Some(32),
214        Matching::Sha512 => Some(64),
215        _ => return false,
216    };
217    match expected_len {
218        Some(len) if tlsa.cert_data.len() != len => false,
219        _ => true,
220    }
221}
222
223/// Resolves DANE TLSA records for the given MX host and port, applying the
224/// RFC 7672 rules required for downgrade-resistant DANE SMTP.
225///
226/// `mx_host` must be the MX hostname (RFC 7672 section 3.2.2: the MX hostname is
227/// the only reference identifier), not the envelope/routing domain.
228///
229/// The caller is responsible for only invoking this when the chain to the MX
230/// host was securely (DNSSEC) resolved: both the MX RRset (see
231/// [`MailExchanger::is_secure`]) and the MX host's address records (see
232/// [`ResolvedAddress::is_secure`](kumo_log_types::ResolvedAddress)) must be
233/// secure. RFC 7672 section 2.2 requires the address records to be secure
234/// before it is safe to rely on TLSA records; we satisfy that by gating on the
235/// secure status of the very address we are about to connect to.
236pub async fn resolve_dane(mx_host: &str, port: u16) -> anyhow::Result<DaneStatus> {
237    let name = fully_qualify(&format!("_{port}._tcp.{mx_host}"))?;
238    let answer = RESOLVER.load().resolve(name, RecordType::TLSA).await;
239    Ok(classify_tlsa_answer(mx_host, port, answer))
240}
241
242/// Maps the result of the TLSA lookup onto a [`DaneStatus`], applying the
243/// RFC 7672 rules. Split out from [`resolve_dane`] so that it can be unit
244/// tested without a live (DNSSEC validating) resolver.
245fn classify_tlsa_answer(mx_host: &str, port: u16, answer: Result<Answer, DnsError>) -> DaneStatus {
246    let answer = match answer {
247        Ok(answer) => answer,
248        Err(err) => {
249            // No answer at all (a timeout or other communication/resource
250            // failure); the TLSA status is unknown, so we must not downgrade.
251            // Failure RCODEs such as SERVFAIL are handled in the match below.
252            return DaneStatus::TempFail(format!("TLSA lookup for {mx_host}:{port} failed: {err}"));
253        }
254    };
255    tracing::debug!("resolve_dane {mx_host}:{port} TLSA answer is: {answer:?}");
256
257    if answer.bogus {
258        // Bogus records are either tampered with, or due to misconfiguration
259        // of the local resolver.
260        return DaneStatus::TempFail(format!(
261            "TLSA records for {mx_host}:{port} are bogus: {}",
262            answer
263                .why_bogus
264                .as_deref()
265                .unwrap_or("DNSSEC validation failed")
266        ));
267    }
268
269    match answer.response_code {
270        ResponseCode::NoError => {}
271        // A secure (or insecure) denial of existence means there are no TLSA
272        // records; the host is simply not a DANE host.
273        ResponseCode::NXDomain => return DaneStatus::NotApplicable,
274        // SERVFAIL, REFUSED, NOTIMP, etc. leave the status unknown.
275        rcode => {
276            return DaneStatus::TempFail(format!(
277                "TLSA lookup for {mx_host}:{port} returned {rcode}"
278            ));
279        }
280    }
281
282    // We can only trust TLSA records that were DNSSEC validated.
283    if !answer.secure {
284        return DaneStatus::NotApplicable;
285    }
286
287    let mut published = vec![];
288    for r in &answer.records {
289        if let RData::TLSA(tlsa) = r {
290            published.push(tlsa.clone());
291        }
292    }
293
294    if published.is_empty() {
295        // Secure proof that no TLSA records exist (NODATA).
296        return DaneStatus::NotApplicable;
297    }
298
299    let mut usable: Vec<TLSA> = published.into_iter().filter(tlsa_is_usable).collect();
300
301    if usable.is_empty() {
302        // TLSA records exist but none are usable for DANE SMTP.
303        return DaneStatus::Unusable;
304    }
305
306    // DNS results are unordered; sort for stable behavior and tests. The TLSA
307    // type is an upstream type and does not implement Ord, so sort on its
308    // component fields directly.
309    usable.sort_by_key(|a| {
310        (
311            u8::from(a.cert_usage),
312            u8::from(a.selector),
313            u8::from(a.matching),
314            a.cert_data.clone(),
315        )
316    });
317
318    tracing::info!("resolve_dane {mx_host}:{port} usable TLSA records: {usable:?}");
319
320    DaneStatus::Records(usable)
321}
322
323/// The outcome of an explicit `CNAME` lookup used to decide DANE eligibility
324/// for an MX host whose address chain was not fully DNSSEC-secure.
325///
326/// RFC 7672 section 2.2.2 treats an MX host that is a securely published CNAME
327/// alias as DANE-eligible at the original (unexpanded) name, even when the
328/// alias target lands in an insecure (unsigned) zone: it is the securely
329/// published TLSA RRset, not the address records, that authenticates the peer.
330#[derive(Debug, PartialEq, Eq)]
331pub enum SecureCnameStatus {
332    /// The name is a CNAME alias whose alias record was DNSSEC validated, so the
333    /// host remains DANE-eligible at its original name.
334    SecureAlias,
335    /// The name is not a securely published CNAME alias (it is not an alias at
336    /// all, or the alias was not DNSSEC validated); it is not the secure-CNAME
337    /// case and DANE must not be engaged off the back of it.
338    NotSecureAlias,
339    /// The secure status of the alias could not be determined (SERVFAIL,
340    /// timeout, or a DNSSEC validation failure). To preserve downgrade
341    /// resistance the caller must defer rather than continue.
342    TempFail(String),
343}
344
345/// Performs an explicit `CNAME` lookup for `mx_host` to determine whether it is
346/// a securely published alias.
347///
348/// This is a narrow fallback in the DANE path: when the MX host's address
349/// (A/AAAA) records did not resolve securely we may still be looking at a secure
350/// CNAME whose target merely lives in an unsigned zone. Querying the `CNAME`
351/// type explicitly isolates the alias's own DNSSEC status (a CNAME-type query is
352/// answered by the alias RRset and is not chased into the insecure target), and
353/// works uniformly across resolver backends because it relies only on the
354/// per-answer secure bit rather than per-record validation proofs.
355pub async fn resolve_secure_cname(mx_host: &str) -> anyhow::Result<SecureCnameStatus> {
356    let name = fully_qualify(mx_host)?;
357    let answer = RESOLVER.load().resolve(name, RecordType::CNAME).await;
358    Ok(classify_cname_answer(mx_host, answer))
359}
360
361/// Maps the result of the explicit `CNAME` lookup onto a [`SecureCnameStatus`].
362/// Split out from [`resolve_secure_cname`] so it can be unit tested without a
363/// live (DNSSEC validating) resolver.
364fn classify_cname_answer(mx_host: &str, answer: Result<Answer, DnsError>) -> SecureCnameStatus {
365    let answer = match answer {
366        Ok(answer) => answer,
367        Err(err) => {
368            return SecureCnameStatus::TempFail(format!(
369                "CNAME lookup for {mx_host} failed: {err}"
370            ));
371        }
372    };
373
374    if answer.bogus {
375        return SecureCnameStatus::TempFail(format!(
376            "CNAME records for {mx_host} are bogus: {}",
377            answer
378                .why_bogus
379                .as_deref()
380                .unwrap_or("DNSSEC validation failed")
381        ));
382    }
383
384    match answer.response_code {
385        ResponseCode::NoError => {}
386        // A denial of existence means there is no CNAME (and possibly no such
387        // name); either way it is not a secure alias.
388        ResponseCode::NXDomain => return SecureCnameStatus::NotSecureAlias,
389        rcode => {
390            return SecureCnameStatus::TempFail(format!(
391                "CNAME lookup for {mx_host} returned {rcode}"
392            ));
393        }
394    }
395
396    // We can only rely on a CNAME that was DNSSEC validated.
397    if !answer.secure {
398        return SecureCnameStatus::NotSecureAlias;
399    }
400
401    // A secure NODATA answer (no CNAME record) means the host is not an alias.
402    if answer.records.iter().any(|r| matches!(r, RData::CNAME(_))) {
403        SecureCnameStatus::SecureAlias
404    } else {
405        SecureCnameStatus::NotSecureAlias
406    }
407}
408
409/// If the provided parameter ends with `:PORT` and `PORT` is a valid u16,
410/// then crack apart and return the LABEL and PORT number portions.
411/// Otherwise, returns None
412pub fn has_colon_port(a: &str) -> Option<(&str, u16)> {
413    let (label, maybe_port) = a.rsplit_once(':')?;
414
415    // v6 addresses can look like `::1` and confuse us. Try not
416    // to be confused here
417    if label.contains(':') {
418        return None;
419    }
420
421    let port = maybe_port.parse::<u16>().ok()?;
422    Some((label, port))
423}
424
425/// Helper to reason about a domain name string.
426/// It can either be name that needs to be resolved, or some kind
427/// of IP literal.
428/// We also allow for an optional port number to be present in
429/// the domain name string.
430pub enum DomainClassification {
431    /// A DNS Name pending resolution, plus an optional port number
432    Domain(Name, Option<u16>),
433    /// A literal IP address (no port), or socket address (with port)
434    Literal(HostOrSocketAddress),
435}
436
437impl DomainClassification {
438    pub fn classify(domain_name: &str) -> anyhow::Result<Self> {
439        let (domain_name, mut opt_port) = match has_colon_port(domain_name) {
440            Some((domain_name, port)) => (domain_name, Some(port)),
441            None => (domain_name, None),
442        };
443
444        if domain_name.starts_with('[') {
445            if !domain_name.ends_with(']') {
446                anyhow::bail!(
447                    "domain_name `{domain_name}` is a malformed literal \
448                     domain with no trailing `]`"
449                );
450            }
451
452            let lowered = domain_name.to_ascii_lowercase();
453            let literal = &lowered[1..lowered.len() - 1];
454
455            let literal = match has_colon_port(literal) {
456                Some((_, _)) if opt_port.is_some() => {
457                    anyhow::bail!("invalid address: `{domain_name}` specifies a port both inside and outside a literal address enclosed in square brackets");
458                }
459                Some((literal, port)) => {
460                    opt_port.replace(port);
461                    literal
462                }
463                None => literal,
464            };
465
466            if let Some(v6_literal) = literal.strip_prefix("ipv6:") {
467                match v6_literal.parse::<Ipv6Addr>() {
468                    Ok(addr) => {
469                        let mut host_addr: HostOrSocketAddress = addr.into();
470                        if let Some(port) = opt_port {
471                            host_addr.set_port(port);
472                        }
473                        return Ok(Self::Literal(host_addr));
474                    }
475                    Err(err) => {
476                        anyhow::bail!("invalid ipv6 address: `{v6_literal}`: {err:#}");
477                    }
478                }
479            }
480
481            // Try to interpret the literal as either an IPv4 or IPv6 address.
482            // Note that RFC5321 doesn't actually permit using an untagged
483            // IPv6 address, so this is non-conforming behavior.
484            match literal.parse::<IpAddr>() {
485                Ok(ip_addr) => {
486                    let mut host_addr: HostOrSocketAddress = ip_addr.into();
487                    if let Some(port) = opt_port {
488                        host_addr.set_port(port);
489                    }
490                    return Ok(Self::Literal(host_addr));
491                }
492                Err(err) => {
493                    anyhow::bail!("invalid address: `{literal}`: {err:#}");
494                }
495            }
496        }
497
498        let name_fq = fully_qualify(domain_name)?;
499        Ok(Self::Domain(name_fq, opt_port))
500    }
501
502    pub fn has_port(&self) -> bool {
503        match self {
504            Self::Domain(_, Some(_)) => true,
505            Self::Domain(_, None) => false,
506            Self::Literal(addr) => addr.port().is_some(),
507        }
508    }
509}
510
511pub async fn resolve_a_or_aaaa(
512    domain_name: &str,
513    resolver: Option<&dyn Resolver>,
514    strategy: IpLookupStrategy,
515) -> anyhow::Result<Vec<ResolvedAddress>> {
516    if domain_name.starts_with('[') {
517        // It's a literal address, no DNS lookup necessary
518
519        if !domain_name.ends_with(']') {
520            anyhow::bail!(
521                "domain_name `{domain_name}` is a malformed literal \
522                     domain with no trailing `]`"
523            );
524        }
525
526        let lowered = domain_name.to_ascii_lowercase();
527        let literal = &lowered[1..lowered.len() - 1];
528
529        if let Some(v6_literal) = literal.strip_prefix("ipv6:") {
530            match v6_literal.parse::<Ipv6Addr>() {
531                Ok(addr) => {
532                    return Ok(vec![ResolvedAddress {
533                        name: domain_name.to_string(),
534                        addr: std::net::IpAddr::V6(addr).into(),
535                        is_secure: false,
536                    }]);
537                }
538                Err(err) => {
539                    anyhow::bail!("invalid ipv6 address: `{v6_literal}`: {err:#}");
540                }
541            }
542        }
543
544        // Try to interpret the literal as either an IPv4 or IPv6 address.
545        // Note that RFC5321 doesn't actually permit using an untagged
546        // IPv6 address, so this is non-conforming behavior.
547        match literal.parse::<HostAddress>() {
548            Ok(addr) => {
549                return Ok(vec![ResolvedAddress {
550                    name: domain_name.to_string(),
551                    addr: addr.into(),
552                    is_secure: false,
553                }]);
554            }
555            Err(err) => {
556                anyhow::bail!("invalid address: `{literal}`: {err:#}");
557            }
558        }
559    } else {
560        // Maybe its a unix domain socket path
561        if let Ok(addr) = domain_name.parse::<HostAddress>() {
562            return Ok(vec![ResolvedAddress {
563                name: domain_name.to_string(),
564                addr: addr.into(),
565                is_secure: false,
566            }]);
567        }
568    }
569
570    match ip_lookup(domain_name, resolver, strategy).await {
571        Ok((result, _expires)) => {
572            let addrs = result
573                .addrs
574                .iter()
575                .map(|&addr| ResolvedAddress {
576                    name: domain_name.to_string(),
577                    addr: addr.into(),
578                    is_secure: result.secure,
579                })
580                .collect();
581            Ok(addrs)
582        }
583        Err(err) => anyhow::bail!("{err:#}"),
584    }
585}
586
587/// Resolve a [`ResolvableSocketAddr`] to a list of concrete [`ResolvedAddress`]es.
588///
589/// For `UnixDomain`, `V4` and `V6` arms, no DNS lookup is performed; a single
590/// `ResolvedAddress` is returned whose `addr` carries the supplied port (for
591/// the IP arms). For the `Hostname` arm, an A/AAAA lookup is performed via
592/// [`ip_lookup`] and each returned IP is paired with the supplied port.
593pub async fn resolve_socket_addr(
594    addr: &ResolvableSocketAddr,
595    resolver: Option<&dyn Resolver>,
596    strategy: IpLookupStrategy,
597) -> anyhow::Result<Vec<ResolvedAddress>> {
598    match addr {
599        ResolvableSocketAddr::UnixDomain(unix) => {
600            let name = match unix.as_pathname() {
601                Some(path) => path.display().to_string(),
602                None => "<unbound unix domain>".to_string(),
603            };
604            Ok(vec![ResolvedAddress {
605                name,
606                addr: HostOrSocketAddress::UnixDomain(unix.clone()),
607                is_secure: false,
608            }])
609        }
610        ResolvableSocketAddr::V4(sa) => Ok(vec![ResolvedAddress {
611            name: sa.to_string(),
612            addr: HostOrSocketAddress::V4Socket(sa.clone()),
613            is_secure: false,
614        }]),
615        ResolvableSocketAddr::V6(sa) => Ok(vec![ResolvedAddress {
616            name: sa.to_string(),
617            addr: HostOrSocketAddress::V6Socket(sa.clone()),
618            is_secure: false,
619        }]),
620        ResolvableSocketAddr::Hostname { host, port } => {
621            match ip_lookup(host, resolver, strategy).await {
622                Ok((result, _expires)) => Ok(result
623                    .addrs
624                    .iter()
625                    .map(|&ip| {
626                        let sa = SocketAddr::new(ip, *port);
627                        ResolvedAddress {
628                            name: host.clone(),
629                            addr: sa.into(),
630                            is_secure: result.secure,
631                        }
632                    })
633                    .collect()),
634                Err(err) => anyhow::bail!("{err:#}"),
635            }
636        }
637    }
638}
639
640impl MailExchanger {
641    pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
642        MX_IN_PROGRESS.inc();
643        let result = Self::resolve_impl(domain_name).await;
644        MX_IN_PROGRESS.dec();
645        if result.is_ok() {
646            MX_SUCCESS.inc();
647        } else {
648            MX_FAIL.inc();
649        }
650        result
651    }
652
653    async fn resolve_impl(domain_name: &str) -> anyhow::Result<Arc<Self>> {
654        let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
655            DomainClassification::Literal(addr) => {
656                let mut by_pref = BTreeMap::new();
657                by_pref.insert(1, vec![addr.to_string()]);
658                return Ok(Arc::new(Self {
659                    domain_name: domain_name.to_string(),
660                    hosts: vec![addr.to_string()],
661                    site_name: addr.to_string(),
662                    by_pref,
663                    is_domain_literal: true,
664                    is_secure: false,
665                    is_mx: false,
666                    expires: None,
667                }));
668            }
669            DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
670        };
671
672        let lookup_result = MX_CACHE
673            .get_or_try_insert(
674                &(name_fq.clone(), opt_port),
675                |mx_result| {
676                    if let Ok(mx) = mx_result {
677                        if let Some(exp) = mx.expires {
678                            return exp
679                                .checked_duration_since(std::time::Instant::now())
680                                .unwrap_or_else(|| Duration::from_secs(10));
681                        }
682                    }
683                    get_mx_negative_ttl()
684                },
685                async {
686                    MX_QUERIES.inc();
687                    let start = Instant::now();
688                    let (mut by_pref, expires) = match lookup_mx_record(&name_fq).await {
689                        Ok((by_pref, expires)) => (by_pref, expires),
690                        Err(err) => {
691                            let error = format!(
692                                "MX lookup for {domain_name} failed after {elapsed:?}: {err:#}",
693                                elapsed = start.elapsed()
694                            );
695                            return Ok::<Result<Arc<MailExchanger>, String>, anyhow::Error>(Err(
696                                error,
697                            ));
698                        }
699                    };
700
701                    let mut hosts = vec![];
702                    for pref in &mut by_pref {
703                        for host in &mut pref.hosts {
704                            if let Some(port) = opt_port {
705                                *host = format!("{host}:{port}");
706                            };
707                            hosts.push(host.to_string());
708                        }
709                    }
710
711                    let is_secure = by_pref.iter().all(|p| p.is_secure);
712                    let is_mx = by_pref.iter().all(|p| p.is_mx);
713
714                    let by_pref = by_pref
715                        .into_iter()
716                        .map(|pref| (pref.pref, pref.hosts))
717                        .collect();
718
719                    let site_name = factor_names(&hosts);
720                    let mx = Self {
721                        hosts,
722                        domain_name: name_fq.to_ascii(),
723                        site_name,
724                        by_pref,
725                        is_domain_literal: false,
726                        is_secure,
727                        is_mx,
728                        expires: Some(expires),
729                    };
730
731                    Ok(Ok(Arc::new(mx)))
732                },
733            )
734            .await
735            .map_err(|err| anyhow::anyhow!("{err}"))?;
736
737        if !lookup_result.is_fresh {
738            MX_CACHED.inc();
739        }
740
741        lookup_result.item.map_err(|err| anyhow::anyhow!("{err}"))
742    }
743
744    pub fn has_expired(&self) -> bool {
745        match self.expires {
746            Some(deadline) => deadline <= Instant::now(),
747            None => false,
748        }
749    }
750
751    /// Returns the list of resolve MX hosts in *reverse* preference
752    /// order; the first one to try is the last element.
753    /// smtp_dispatcher.rs relies on this ordering, as it will pop
754    /// off candidates until it has exhausted its connection plan.
755    pub async fn resolve_addresses(&self, strategy: IpLookupStrategy) -> ResolvedMxAddresses {
756        let mut result = vec![];
757
758        for hosts in self.by_pref.values().rev() {
759            let mut by_pref = vec![];
760
761            for mx_host in hosts {
762                // '.' is a null mx; skip trying to resolve it
763                if mx_host == "." {
764                    return ResolvedMxAddresses::NullMx;
765                }
766
767                // Handle the literal address case
768                let (mx_host, opt_port) = match has_colon_port(mx_host) {
769                    Some((domain_name, port)) => (domain_name, Some(port)),
770                    None => (mx_host.as_str(), None),
771                };
772                if let Ok(addr) = mx_host.parse::<IpAddr>() {
773                    let mut addr: HostOrSocketAddress = addr.into();
774                    if let Some(port) = opt_port {
775                        addr.set_port(port);
776                    }
777                    by_pref.push(ResolvedAddress {
778                        name: mx_host.to_string(),
779                        addr: addr.into(),
780                        is_secure: false,
781                    });
782                    continue;
783                }
784
785                match ip_lookup(mx_host, None, strategy).await {
786                    Err(err) => {
787                        tracing::error!("failed to resolve {mx_host}: {err:#}");
788                        continue;
789                    }
790                    Ok((result, _expires)) => {
791                        for addr in result.addrs.iter() {
792                            let mut addr: HostOrSocketAddress = (*addr).into();
793                            if let Some(port) = opt_port {
794                                addr.set_port(port);
795                            }
796                            by_pref.push(ResolvedAddress {
797                                name: mx_host.to_string(),
798                                addr,
799                                is_secure: result.secure,
800                            });
801                        }
802                    }
803                }
804            }
805
806            // Randomize the list of addresses within this preference
807            // level. This probablistically "load balances" outgoing
808            // traffic across MX hosts with equal preference value.
809            let mut rng = rand::thread_rng();
810            by_pref.shuffle(&mut rng);
811            result.append(&mut by_pref);
812        }
813        ResolvedMxAddresses::Addresses(result)
814    }
815}
816
817#[derive(Debug, Clone, Serialize)]
818pub enum ResolvedMxAddresses {
819    NullMx,
820    /// The list of addresses to which to connect, expressed
821    /// in LIFO order
822    Addresses(Vec<ResolvedAddress>),
823}
824
825struct ByPreference {
826    hosts: Vec<String>,
827    pref: u16,
828    is_secure: bool,
829    is_mx: bool,
830}
831
832async fn lookup_mx_record(domain_name: &Name) -> anyhow::Result<(Vec<ByPreference>, Instant)> {
833    let mx_lookup = timeout(get_mx_timeout(), async {
834        let _permit = MX_CONCURRENCY_SEMA.acquire().await;
835        RESOLVER
836            .load()
837            .resolve(domain_name.clone(), RecordType::MX)
838            .await
839    })
840    .await??;
841    let mx_records = mx_lookup.records;
842
843    if mx_records.is_empty() {
844        if mx_lookup.nxdomain {
845            anyhow::bail!("NXDOMAIN");
846        }
847
848        // No MX records: the domain's own A/AAAA records act as the implicit
849        // MX. This implicit MX is secure exactly when the MX NODATA response
850        // was securely (DNSSEC) resolved, which is common for signed domains
851        // that publish no MX (e.g. many `.br` domains).
852        return Ok((
853            vec![ByPreference {
854                hosts: vec![domain_name.to_lowercase().to_ascii()],
855                pref: 1,
856                is_secure: mx_lookup.secure,
857                is_mx: false,
858            }],
859            mx_lookup.expires,
860        ));
861    }
862
863    let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
864
865    for mx_record in mx_records {
866        if let RData::MX(mx) = mx_record {
867            let pref = mx.preference;
868            let host = mx.exchange.to_lowercase().to_string();
869
870            if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
871                record.hosts.push(host);
872            } else {
873                records.push(ByPreference {
874                    hosts: vec![host],
875                    pref,
876                    is_secure: mx_lookup.secure,
877                    is_mx: true,
878                });
879            }
880        }
881    }
882
883    // Sort by preference
884    records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
885
886    // Sort the hosts at each preference level to produce the
887    // overall ordered list of hosts for this site
888    for mx in &mut records {
889        mx.hosts.sort();
890    }
891
892    Ok((records, mx_lookup.expires))
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
896#[repr(u8)]
897pub enum IpLookupStrategy {
898    /// Only query for A (Ipv4) records
899    Ipv4Only,
900    /// Only query for AAAA (Ipv6) records
901    Ipv6Only,
902    /// Query for A and AAAA in parallel
903    #[default]
904    Ipv4AndIpv6,
905    /// Query for Ipv6 if that fails, query for Ipv4
906    Ipv6ThenIpv4,
907    /// Query for Ipv4 if that fails, query for Ipv6 (default)
908    Ipv4ThenIpv6,
909}
910
911/// A set of resolved IP addresses together with whether the DNS lookup that
912/// produced them was DNSSEC validated (secure). The secure flag is what lets
913/// the delivery path decide DANE eligibility without performing a second
914/// lookup; see [`ResolvedAddress::is_secure`](kumo_log_types::ResolvedAddress).
915#[derive(Clone, Debug)]
916pub struct IpAddresses {
917    pub addrs: Vec<IpAddr>,
918    /// True only when every address here came from a DNSSEC-validated lookup.
919    pub secure: bool,
920}
921
922pub async fn ip_lookup(
923    key: &str,
924    resolver: Option<&dyn Resolver>,
925    strategy: IpLookupStrategy,
926) -> anyhow::Result<(Arc<IpAddresses>, Instant)> {
927    let key_fq = fully_qualify(key)?;
928
929    if resolver.is_none() {
930        if let Some(lookup) = IP_CACHE.lookup(&(key_fq.clone(), strategy)) {
931            return Ok((lookup.item, lookup.expiration.into()));
932        }
933    }
934
935    let (v4, v6) = match strategy {
936        IpLookupStrategy::Ipv4AndIpv6 => {
937            let (v4, v6) = tokio::join!(ipv4_lookup(key, resolver), ipv6_lookup(key, resolver));
938            (Some(v4), Some(v6))
939        }
940        IpLookupStrategy::Ipv4Only => (Some(ipv4_lookup(key, resolver).await), None),
941        IpLookupStrategy::Ipv6Only => (None, Some(ipv6_lookup(key, resolver).await)),
942        IpLookupStrategy::Ipv6ThenIpv4 => {
943            let v6 = ipv6_lookup(key, resolver).await;
944            match v6 {
945                Ok((answer, exp)) if answer.addrs.is_empty() => (
946                    Some(ipv4_lookup(key, resolver).await),
947                    Some(Ok((answer, exp))),
948                ),
949                Err(err) => (Some(ipv4_lookup(key, resolver).await), Some(Err(err))),
950                Ok(res) => (None, Some(Ok(res))),
951            }
952        }
953        IpLookupStrategy::Ipv4ThenIpv6 => {
954            let v4 = ipv4_lookup(key, resolver).await;
955            match v4 {
956                Ok((answer, exp)) if answer.addrs.is_empty() => (
957                    Some(Ok((answer, exp))),
958                    Some(ipv6_lookup(key, resolver).await),
959                ),
960                Err(err) => (Some(Err(err)), Some(ipv6_lookup(key, resolver).await)),
961                Ok(res) => (Some(Ok(res)), None),
962            }
963        }
964    };
965
966    let mut addrs = vec![];
967    let mut any_addr = false;
968    let mut all_secure = true;
969    let mut errors = vec![];
970    let mut expires: Option<Instant> = None;
971
972    for family in [v4, v6] {
973        match family {
974            Some(Ok((answer, exp))) => {
975                expires = Some(match expires {
976                    Some(existing) => exp.min(existing),
977                    None => exp,
978                });
979                if !answer.addrs.is_empty() {
980                    any_addr = true;
981                    all_secure &= answer.secure;
982                    addrs.extend_from_slice(&answer.addrs);
983                }
984            }
985            Some(Err(err)) => errors.push(err),
986            None => {}
987        }
988    }
989
990    if addrs.is_empty() && !errors.is_empty() {
991        return Err(errors.remove(0));
992    }
993
994    let result = Arc::new(IpAddresses {
995        addrs,
996        // Only claim secure if we actually have addresses and every family that
997        // contributed one was DNSSEC validated.
998        secure: any_addr && all_secure,
999    });
1000    let exp = expires.unwrap_or_else(Instant::now);
1001
1002    if resolver.is_none() {
1003        IP_CACHE
1004            .insert((key_fq, strategy), result.clone(), exp.into())
1005            .await;
1006    }
1007    Ok((result, exp))
1008}
1009
1010pub async fn ipv4_lookup(
1011    key: &str,
1012    resolver: Option<&dyn Resolver>,
1013) -> anyhow::Result<(Arc<IpAddresses>, Instant)> {
1014    let key_fq = fully_qualify(key)?;
1015    if resolver.is_none() {
1016        if let Some(lookup) = IPV4_CACHE.lookup(&key_fq) {
1017            return Ok((lookup.item, lookup.expiration.into()));
1018        }
1019    }
1020
1021    let answer = match resolver {
1022        Some(r) => r.resolve(key_fq.clone(), RecordType::A).await?,
1023        None => {
1024            RESOLVER
1025                .load()
1026                .resolve(key_fq.clone(), RecordType::A)
1027                .await?
1028        }
1029    };
1030    let result = Arc::new(IpAddresses {
1031        addrs: answer.as_addr(),
1032        secure: answer.secure,
1033    });
1034    let expires = answer.expires;
1035    if resolver.is_none() {
1036        IPV4_CACHE
1037            .insert(key_fq, result.clone(), expires.into())
1038            .await;
1039    }
1040    Ok((result, expires))
1041}
1042
1043pub async fn ipv6_lookup(
1044    key: &str,
1045    resolver: Option<&dyn Resolver>,
1046) -> anyhow::Result<(Arc<IpAddresses>, Instant)> {
1047    let key_fq = fully_qualify(key)?;
1048    if resolver.is_none() {
1049        if let Some(lookup) = IPV6_CACHE.lookup(&key_fq) {
1050            return Ok((lookup.item, lookup.expiration.into()));
1051        }
1052    }
1053
1054    let answer = match resolver {
1055        Some(r) => r.resolve(key_fq.clone(), RecordType::AAAA).await?,
1056        None => {
1057            RESOLVER
1058                .load()
1059                .resolve(key_fq.clone(), RecordType::AAAA)
1060                .await?
1061        }
1062    };
1063    let result = Arc::new(IpAddresses {
1064        addrs: answer.as_addr(),
1065        secure: answer.secure,
1066    });
1067    let expires = answer.expires;
1068    if resolver.is_none() {
1069        IPV6_CACHE
1070            .insert(key_fq, result.clone(), expires.into())
1071            .await;
1072    }
1073    Ok((result, expires))
1074}
1075
1076/// Given a list of host names, produce a pseudo-regex style alternation list
1077/// of the different elements of the hostnames.
1078/// The goal is to produce a more compact representation of the name list
1079/// with the common components factored out.
1080fn factor_names<S: AsRef<str>>(name_strings: &[S]) -> String {
1081    let mut max_element_count = 0;
1082
1083    let mut names = vec![];
1084
1085    for name in name_strings {
1086        let (name, opt_port) = match has_colon_port(name.as_ref()) {
1087            Some((name, port)) => (name, Some(port)),
1088            None => (name.as_ref(), None),
1089        };
1090        if let Ok(name) = fully_qualify(name) {
1091            names.push((name.to_lowercase(), opt_port));
1092        }
1093    }
1094
1095    let mut elements: Vec<Vec<&str>> = vec![];
1096
1097    let mut split_names = vec![];
1098    for (name, opt_port) in names {
1099        let mut fields: Vec<_> = name
1100            .iter()
1101            .map(|s| String::from_utf8_lossy(s).to_string())
1102            .collect();
1103        if let Some(port) = opt_port {
1104            fields.last_mut().map(|s| {
1105                s.push_str(&format!(":{port}"));
1106            });
1107        }
1108        fields.reverse();
1109        max_element_count = max_element_count.max(fields.len());
1110        split_names.push(fields);
1111    }
1112
1113    fn add_element<'a>(elements: &mut Vec<Vec<&'a str>>, field: &'a str, i: usize) {
1114        match elements.get_mut(i) {
1115            Some(ele) => {
1116                if !ele.contains(&field) {
1117                    ele.push(field);
1118                }
1119            }
1120            None => {
1121                elements.push(vec![field]);
1122            }
1123        }
1124    }
1125
1126    for fields in &split_names {
1127        for (i, field) in fields.iter().enumerate() {
1128            add_element(&mut elements, field, i);
1129        }
1130        for i in fields.len()..max_element_count {
1131            add_element(&mut elements, "?", i);
1132        }
1133    }
1134
1135    let mut result = vec![];
1136    for mut ele in elements {
1137        let has_q = ele.contains(&"?");
1138        ele.retain(|&e| e != "?");
1139        let mut item_text = if ele.len() == 1 {
1140            ele[0].to_string()
1141        } else {
1142            format!("({})", ele.join("|"))
1143        };
1144        if has_q {
1145            item_text.push('?');
1146        }
1147        result.push(item_text);
1148    }
1149    result.reverse();
1150
1151    result.join(".")
1152}
1153
1154#[cfg(test)]
1155mod test {
1156    use super::*;
1157
1158    fn answer(records: Vec<RData>, secure: bool, response_code: ResponseCode) -> Answer {
1159        Answer {
1160            canon_name: None,
1161            records,
1162            nxdomain: response_code == ResponseCode::NXDomain,
1163            secure,
1164            bogus: false,
1165            why_bogus: None,
1166            expires: Instant::now(),
1167            response_code,
1168        }
1169    }
1170
1171    fn sha256_tlsa(usage: CertUsage, selector: Selector) -> TLSA {
1172        TLSA::new(usage, selector, Matching::Sha256, vec![0u8; 32])
1173    }
1174
1175    fn dane_ee_record() -> TLSA {
1176        sha256_tlsa(CertUsage::DaneEe, Selector::Spki)
1177    }
1178
1179    fn cname_record() -> RData {
1180        use hickory_resolver::proto::rr::rdata::CNAME;
1181        RData::CNAME(CNAME(Name::from_ascii("target.unsigned.example.").unwrap()))
1182    }
1183
1184    #[test]
1185    fn cname_answer_classification() {
1186        // Secure CNAME alias: DANE-eligible at the original name.
1187        k9::assert_equal!(
1188            classify_cname_answer(
1189                "mx.example.com",
1190                Ok(answer(vec![cname_record()], true, ResponseCode::NoError))
1191            ),
1192            SecureCnameStatus::SecureAlias
1193        );
1194        // Secure NODATA (not an alias): not the secure-CNAME case.
1195        k9::assert_equal!(
1196            classify_cname_answer(
1197                "mx.example.com",
1198                Ok(answer(vec![], true, ResponseCode::NoError))
1199            ),
1200            SecureCnameStatus::NotSecureAlias
1201        );
1202        // A CNAME that was not DNSSEC validated cannot be trusted.
1203        k9::assert_equal!(
1204            classify_cname_answer(
1205                "mx.example.com",
1206                Ok(answer(vec![cname_record()], false, ResponseCode::NoError))
1207            ),
1208            SecureCnameStatus::NotSecureAlias
1209        );
1210        // NXDOMAIN: not an alias.
1211        k9::assert_equal!(
1212            classify_cname_answer(
1213                "mx.example.com",
1214                Ok(answer(vec![], true, ResponseCode::NXDomain))
1215            ),
1216            SecureCnameStatus::NotSecureAlias
1217        );
1218        // SERVFAIL leaves the status unknown: defer, do not downgrade.
1219        assert!(matches!(
1220            classify_cname_answer(
1221                "mx.example.com",
1222                Ok(answer(vec![], false, ResponseCode::ServFail))
1223            ),
1224            SecureCnameStatus::TempFail(_)
1225        ));
1226        // A resolver error is also a temporary failure.
1227        assert!(matches!(
1228            classify_cname_answer(
1229                "mx.example.com",
1230                Err(DnsError::ResolveFailed("boom".to_string()))
1231            ),
1232            SecureCnameStatus::TempFail(_)
1233        ));
1234    }
1235
1236    #[test]
1237    fn tlsa_usability() {
1238        // DANE-TA(2) and DANE-EE(3) are usable for DANE SMTP.
1239        assert!(tlsa_is_usable(&sha256_tlsa(
1240            CertUsage::DaneTa,
1241            Selector::Full
1242        )));
1243        assert!(tlsa_is_usable(&sha256_tlsa(
1244            CertUsage::DaneEe,
1245            Selector::Spki
1246        )));
1247        // PKIX-TA(0)/PKIX-EE(1) are not in scope for DANE SMTP (RFC 7672 3.1.3).
1248        assert!(!tlsa_is_usable(&sha256_tlsa(
1249            CertUsage::PkixTa,
1250            Selector::Full
1251        )));
1252        assert!(!tlsa_is_usable(&sha256_tlsa(
1253            CertUsage::PkixEe,
1254            Selector::Spki
1255        )));
1256        // Private and unassigned usages are unusable.
1257        assert!(!tlsa_is_usable(&sha256_tlsa(
1258            CertUsage::Private,
1259            Selector::Spki
1260        )));
1261        assert!(!tlsa_is_usable(&sha256_tlsa(
1262            CertUsage::Unassigned(200),
1263            Selector::Spki
1264        )));
1265        // Unknown selector/matching types and bad digest lengths are unusable.
1266        assert!(!tlsa_is_usable(&sha256_tlsa(
1267            CertUsage::DaneEe,
1268            Selector::Unassigned(7)
1269        )));
1270        assert!(!tlsa_is_usable(&TLSA::new(
1271            CertUsage::DaneEe,
1272            Selector::Spki,
1273            Matching::Unassigned(9),
1274            vec![0u8; 32]
1275        )));
1276        assert!(!tlsa_is_usable(&TLSA::new(
1277            CertUsage::DaneEe,
1278            Selector::Spki,
1279            Matching::Sha256,
1280            vec![0u8; 31]
1281        )));
1282        // Raw (full value, matching type 0) has no fixed length.
1283        assert!(tlsa_is_usable(&TLSA::new(
1284            CertUsage::DaneEe,
1285            Selector::Full,
1286            Matching::Raw,
1287            vec![0u8; 100]
1288        )));
1289    }
1290
1291    #[test]
1292    fn tlsa_answer_classification() {
1293        // Usable, secure DANE-EE record.
1294        k9::assert_equal!(
1295            classify_tlsa_answer(
1296                "mx.example.com",
1297                25,
1298                Ok(answer(
1299                    vec![RData::TLSA(dane_ee_record())],
1300                    true,
1301                    ResponseCode::NoError
1302                ))
1303            ),
1304            DaneStatus::Records(vec![dane_ee_record()])
1305        );
1306        // Secure NODATA: no TLSA records, not a DANE host.
1307        k9::assert_equal!(
1308            classify_tlsa_answer(
1309                "mx.example.com",
1310                25,
1311                Ok(answer(vec![], true, ResponseCode::NoError))
1312            ),
1313            DaneStatus::NotApplicable
1314        );
1315        // Secure NXDOMAIN: not a DANE host.
1316        k9::assert_equal!(
1317            classify_tlsa_answer(
1318                "mx.example.com",
1319                25,
1320                Ok(answer(vec![], true, ResponseCode::NXDomain))
1321            ),
1322            DaneStatus::NotApplicable
1323        );
1324        // TLSA records present but unvalidated must not be trusted.
1325        k9::assert_equal!(
1326            classify_tlsa_answer(
1327                "mx.example.com",
1328                25,
1329                Ok(answer(
1330                    vec![RData::TLSA(dane_ee_record())],
1331                    false,
1332                    ResponseCode::NoError
1333                ))
1334            ),
1335            DaneStatus::NotApplicable
1336        );
1337        // Secure records present, but only disallowed usages: unusable, so
1338        // STARTTLS is required without authentication.
1339        k9::assert_equal!(
1340            classify_tlsa_answer(
1341                "mx.example.com",
1342                25,
1343                Ok(answer(
1344                    vec![RData::TLSA(sha256_tlsa(CertUsage::PkixEe, Selector::Spki))],
1345                    true,
1346                    ResponseCode::NoError
1347                ))
1348            ),
1349            DaneStatus::Unusable
1350        );
1351        // SERVFAIL leaves the status unknown: defer, do not downgrade.
1352        assert!(matches!(
1353            classify_tlsa_answer(
1354                "mx.example.com",
1355                25,
1356                Ok(answer(vec![], false, ResponseCode::ServFail))
1357            ),
1358            DaneStatus::TempFail(_)
1359        ));
1360        // A resolver error is also a temporary failure.
1361        assert!(matches!(
1362            classify_tlsa_answer(
1363                "mx.example.com",
1364                25,
1365                Err(DnsError::ResolveFailed("boom".to_string()))
1366            ),
1367            DaneStatus::TempFail(_)
1368        ));
1369    }
1370
1371    /// Confirms that a DNSSEC-validating hickory resolver, via our adapter,
1372    /// reports signed records as secure and resolves unsigned records as
1373    /// insecure (rather than failing). Validation requires a DNSSEC-capable
1374    /// upstream reachable over TCP, since DNSKEY/RRSIG responses are large.
1375    #[cfg(feature = "live-dns-tests")]
1376    #[tokio::test]
1377    async fn hickory_dnssec_validation() {
1378        use hickory_resolver::config::{
1379            ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig,
1380        };
1381        use hickory_resolver::net::runtime::TokioRuntimeProvider;
1382        use hickory_resolver::TokioResolver;
1383
1384        let mut udp = ConnectionConfig::new(ProtocolConfig::Udp);
1385        udp.port = 53;
1386        let mut tcp = ConnectionConfig::new(ProtocolConfig::Tcp);
1387        tcp.port = 53;
1388        let config = ResolverConfig::from_parts(
1389            None,
1390            vec![],
1391            vec![NameServerConfig::new(
1392                "1.1.1.1".parse().unwrap(),
1393                true,
1394                vec![udp, tcp],
1395            )],
1396        );
1397        let mut builder =
1398            TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
1399        builder.options_mut().validate = true;
1400        let resolver = HickoryResolver::from(builder.build().unwrap());
1401
1402        let tlsa = resolver
1403            .resolve(
1404                fully_qualify("_25._tcp.do.havedane.net").unwrap(),
1405                RecordType::TLSA,
1406            )
1407            .await
1408            .unwrap();
1409        assert!(tlsa.secure, "signed TLSA should validate as secure");
1410        assert!(!tlsa.bogus);
1411        assert!(!tlsa.records.is_empty());
1412
1413        let unsigned = resolver
1414            .resolve(fully_qualify("google.com").unwrap(), RecordType::A)
1415            .await
1416            .unwrap();
1417        assert!(!unsigned.secure, "unsigned zone is not secure");
1418        assert!(!unsigned.bogus);
1419        assert!(
1420            !unsigned.records.is_empty(),
1421            "unsigned zone still resolves successfully"
1422        );
1423    }
1424
1425    /// A securely denied answer (NODATA/NXDOMAIN in a signed zone) must be
1426    /// reported as secure so that, for example, a securely proven "no MX" can
1427    /// engage DANE for the implicit MX. An insecure (unsigned) denial must
1428    /// remain insecure. Exercises the authority-section proof handling on the
1429    /// hickory backend.
1430    #[cfg(feature = "live-dns-tests")]
1431    #[tokio::test]
1432    async fn hickory_negative_answer_secure_bit() {
1433        use hickory_resolver::config::{
1434            ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig,
1435        };
1436        use hickory_resolver::net::runtime::TokioRuntimeProvider;
1437        use hickory_resolver::TokioResolver;
1438
1439        let mut udp = ConnectionConfig::new(ProtocolConfig::Udp);
1440        udp.port = 53;
1441        let mut tcp = ConnectionConfig::new(ProtocolConfig::Tcp);
1442        tcp.port = 53;
1443        let config = ResolverConfig::from_parts(
1444            None,
1445            vec![],
1446            vec![NameServerConfig::new(
1447                "1.1.1.1".parse().unwrap(),
1448                true,
1449                vec![udp, tcp],
1450            )],
1451        );
1452        let mut builder =
1453            TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
1454        builder.options_mut().validate = true;
1455        let resolver = HickoryResolver::from(builder.build().unwrap());
1456
1457        // Signed zone with no TLSA at the apex: a securely proven NODATA.
1458        let secure_nodata = resolver
1459            .resolve(fully_qualify("cloudflare.com").unwrap(), RecordType::TLSA)
1460            .await
1461            .unwrap();
1462        assert!(secure_nodata.records.is_empty());
1463        assert!(!secure_nodata.bogus);
1464        assert!(
1465            secure_nodata.secure,
1466            "securely denied NODATA in a signed zone should be secure"
1467        );
1468
1469        // Unsigned zone NODATA: must not be reported as secure.
1470        let insecure_nodata = resolver
1471            .resolve(fully_qualify("mail.anoebis.be").unwrap(), RecordType::AAAA)
1472            .await
1473            .unwrap();
1474        assert!(insecure_nodata.records.is_empty());
1475        assert!(!insecure_nodata.bogus);
1476        assert!(
1477            !insecure_nodata.secure,
1478            "NODATA in an unsigned zone must not be secure"
1479        );
1480    }
1481
1482    #[tokio::test]
1483    async fn literal_resolve() {
1484        let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
1485        k9::snapshot!(
1486            &v4_loopback,
1487            r#"
1488MailExchanger {
1489    domain_name: "[127.0.0.1]",
1490    hosts: [
1491        "127.0.0.1",
1492    ],
1493    site_name: "127.0.0.1",
1494    by_pref: {
1495        1: [
1496            "127.0.0.1",
1497        ],
1498    },
1499    is_domain_literal: true,
1500    is_secure: false,
1501    is_mx: false,
1502    expires: None,
1503}
1504"#
1505        );
1506        k9::snapshot!(
1507            v4_loopback
1508                .resolve_addresses(IpLookupStrategy::default())
1509                .await,
1510            r#"
1511Addresses(
1512    [
1513        ResolvedAddress {
1514            name: "127.0.0.1",
1515            addr: 127.0.0.1,
1516            is_secure: false,
1517        },
1518    ],
1519)
1520"#
1521        );
1522
1523        let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
1524        k9::snapshot!(
1525            &v6_loopback_non_conforming,
1526            r#"
1527MailExchanger {
1528    domain_name: "[::1]",
1529    hosts: [
1530        "::1",
1531    ],
1532    site_name: "::1",
1533    by_pref: {
1534        1: [
1535            "::1",
1536        ],
1537    },
1538    is_domain_literal: true,
1539    is_secure: false,
1540    is_mx: false,
1541    expires: None,
1542}
1543"#
1544        );
1545        k9::snapshot!(
1546            v6_loopback_non_conforming
1547                .resolve_addresses(IpLookupStrategy::default())
1548                .await,
1549            r#"
1550Addresses(
1551    [
1552        ResolvedAddress {
1553            name: "::1",
1554            addr: ::1,
1555            is_secure: false,
1556        },
1557    ],
1558)
1559"#
1560        );
1561
1562        let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
1563        k9::snapshot!(
1564            &v6_loopback,
1565            r#"
1566MailExchanger {
1567    domain_name: "[IPv6:::1]",
1568    hosts: [
1569        "::1",
1570    ],
1571    site_name: "::1",
1572    by_pref: {
1573        1: [
1574            "::1",
1575        ],
1576    },
1577    is_domain_literal: true,
1578    is_secure: false,
1579    is_mx: false,
1580    expires: None,
1581}
1582"#
1583        );
1584        k9::snapshot!(
1585            v6_loopback
1586                .resolve_addresses(IpLookupStrategy::default())
1587                .await,
1588            r#"
1589Addresses(
1590    [
1591        ResolvedAddress {
1592            name: "::1",
1593            addr: ::1,
1594            is_secure: false,
1595        },
1596    ],
1597)
1598"#
1599        );
1600    }
1601
1602    #[test]
1603    fn name_factoring() {
1604        assert_eq!(
1605            factor_names(&[
1606                "mta5.am0.yahoodns.net",
1607                "mta6.am0.yahoodns.net",
1608                "mta7.am0.yahoodns.net"
1609            ]),
1610            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1611        );
1612
1613        // Verify that the case is normalized to lowercase
1614        assert_eq!(
1615            factor_names(&[
1616                "mta5.AM0.yahoodns.net",
1617                "mta6.am0.yAHOodns.net",
1618                "mta7.am0.yahoodns.net"
1619            ]),
1620            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1621        );
1622
1623        // When the names have mismatched lengths, do we produce
1624        // something reasonable?
1625        assert_eq!(
1626            factor_names(&[
1627                "gmail-smtp-in.l.google.com",
1628                "alt1.gmail-smtp-in.l.google.com",
1629                "alt2.gmail-smtp-in.l.google.com",
1630                "alt3.gmail-smtp-in.l.google.com",
1631                "alt4.gmail-smtp-in.l.google.com",
1632            ]),
1633            "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com".to_string()
1634        );
1635
1636        assert_eq!(
1637            factor_names(&[
1638                "mta5.am0.yahoodns.net:123",
1639                "mta6.am0.yahoodns.net:123",
1640                "mta7.am0.yahoodns.net:123"
1641            ]),
1642            "(mta5|mta6|mta7).am0.yahoodns.net:123".to_string()
1643        );
1644        assert_eq!(
1645            factor_names(&[
1646                "mta5.am0.yahoodns.net:123",
1647                "mta6.am0.yahoodns.net:456",
1648                "mta7.am0.yahoodns.net:123"
1649            ]),
1650            "(mta5|mta6|mta7).am0.yahoodns.(net:123|net:456)".to_string()
1651        );
1652    }
1653
1654    /// Verify that the order is preserved and that we treat these two
1655    /// examples of differently ordered sets of the same names as two
1656    /// separate site name strings
1657    #[test]
1658    fn mx_order_name_factor() {
1659        assert_eq!(
1660            factor_names(&[
1661                "example-com.mail.protection.outlook.com.",
1662                "mx-biz.mail.am0.yahoodns.net.",
1663                "mx-biz.mail.am0.yahoodns.net.",
1664            ]),
1665            "(example-com|mx-biz).mail.(protection|am0).(outlook|yahoodns).(com|net)".to_string()
1666        );
1667        assert_eq!(
1668            factor_names(&[
1669                "mx-biz.mail.am0.yahoodns.net.",
1670                "mx-biz.mail.am0.yahoodns.net.",
1671                "example-com.mail.protection.outlook.com.",
1672            ]),
1673            "(mx-biz|example-com).mail.(am0|protection).(yahoodns|outlook).(net|com)".to_string()
1674        );
1675    }
1676
1677    #[cfg(feature = "live-dns-tests")]
1678    #[tokio::test]
1679    async fn lookup_gmail_mx() {
1680        let mut gmail = (*MailExchanger::resolve("gmail.com").await.unwrap()).clone();
1681        gmail.expires.take();
1682        k9::snapshot!(
1683            &gmail,
1684            r#"
1685MailExchanger {
1686    domain_name: "gmail.com.",
1687    hosts: [
1688        "gmail-smtp-in.l.google.com.",
1689        "alt1.gmail-smtp-in.l.google.com.",
1690        "alt2.gmail-smtp-in.l.google.com.",
1691        "alt3.gmail-smtp-in.l.google.com.",
1692        "alt4.gmail-smtp-in.l.google.com.",
1693    ],
1694    site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
1695    by_pref: {
1696        5: [
1697            "gmail-smtp-in.l.google.com.",
1698        ],
1699        10: [
1700            "alt1.gmail-smtp-in.l.google.com.",
1701        ],
1702        20: [
1703            "alt2.gmail-smtp-in.l.google.com.",
1704        ],
1705        30: [
1706            "alt3.gmail-smtp-in.l.google.com.",
1707        ],
1708        40: [
1709            "alt4.gmail-smtp-in.l.google.com.",
1710        ],
1711    },
1712    is_domain_literal: false,
1713    is_secure: false,
1714    is_mx: true,
1715    expires: None,
1716}
1717"#
1718        );
1719
1720        // This is a bad thing to have in a snapshot test really,
1721        // but the whole set of live-dns-tests are already inherently
1722        // unstable and flakey anyway.
1723        // The main thing we expect to see here is that the list of
1724        // names starts with alt4 and goes backwards through the priority
1725        // order such that the last element is gmail-smtp.
1726        // We expect the addresses within a given preference level to
1727        // be randomized, because that is what resolve_addresses does.
1728        k9::snapshot!(
1729            gmail.resolve_addresses(IpLookupStrategy::default()).await,
1730            r#"
1731Addresses(
1732    [
1733        ResolvedAddress {
1734            name: "alt4.gmail-smtp-in.l.google.com.",
1735            addr: 2607:f8b0:4023:401::1b,
1736        },
1737        ResolvedAddress {
1738            name: "alt4.gmail-smtp-in.l.google.com.",
1739            addr: 173.194.77.27,
1740        },
1741        ResolvedAddress {
1742            name: "alt3.gmail-smtp-in.l.google.com.",
1743            addr: 2607:f8b0:4023:1::1a,
1744        },
1745        ResolvedAddress {
1746            name: "alt3.gmail-smtp-in.l.google.com.",
1747            addr: 172.253.113.26,
1748        },
1749        ResolvedAddress {
1750            name: "alt2.gmail-smtp-in.l.google.com.",
1751            addr: 2607:f8b0:4001:c1d::1b,
1752        },
1753        ResolvedAddress {
1754            name: "alt2.gmail-smtp-in.l.google.com.",
1755            addr: 74.125.126.27,
1756        },
1757        ResolvedAddress {
1758            name: "alt1.gmail-smtp-in.l.google.com.",
1759            addr: 2607:f8b0:4003:c04::1b,
1760        },
1761        ResolvedAddress {
1762            name: "alt1.gmail-smtp-in.l.google.com.",
1763            addr: 108.177.104.27,
1764        },
1765        ResolvedAddress {
1766            name: "gmail-smtp-in.l.google.com.",
1767            addr: 2607:f8b0:4023:c06::1b,
1768        },
1769        ResolvedAddress {
1770            name: "gmail-smtp-in.l.google.com.",
1771            addr: 142.251.2.26,
1772        },
1773    ],
1774)
1775"#
1776        );
1777    }
1778
1779    #[cfg(feature = "live-dns-tests")]
1780    #[tokio::test]
1781    async fn lookup_punycode_no_mx_only_a() {
1782        let mx = MailExchanger::resolve("xn--bb-eka.at").await.unwrap();
1783        assert_eq!(mx.domain_name, "xn--bb-eka.at.");
1784        assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
1785    }
1786
1787    #[cfg(feature = "live-dns-tests")]
1788    #[tokio::test]
1789    async fn lookup_bogus_aasland() {
1790        let err = MailExchanger::resolve("not-mairs.aasland.com")
1791            .await
1792            .unwrap_err();
1793        k9::snapshot!(err, "MX lookup for not-mairs.aasland.com failed: NXDOMAIN");
1794    }
1795
1796    // Asserts a DNSSEC-secure result, so it only holds when the default
1797    // resolver validates, i.e. the unbound backend.
1798    #[cfg(all(feature = "live-dns-tests", feature = "default-unbound"))]
1799    #[tokio::test]
1800    async fn lookup_example_com() {
1801        // Has a NULL MX record
1802        let mx = MailExchanger::resolve("example.com").await.unwrap();
1803        k9::snapshot!(
1804            mx,
1805            r#"
1806MailExchanger {
1807    domain_name: "example.com.",
1808    hosts: [
1809        ".",
1810    ],
1811    site_name: "",
1812    by_pref: {
1813        0: [
1814            ".",
1815        ],
1816    },
1817    is_domain_literal: false,
1818    is_secure: true,
1819    is_mx: true,
1820}
1821"#
1822        );
1823    }
1824
1825    // Asserts a DNSSEC-secure result, so it only holds when the default
1826    // resolver validates, i.e. the unbound backend.
1827    #[cfg(all(feature = "live-dns-tests", feature = "default-unbound"))]
1828    #[tokio::test]
1829    async fn lookup_have_dane() {
1830        let mx = MailExchanger::resolve("do.havedane.net").await.unwrap();
1831        k9::snapshot!(
1832            mx,
1833            r#"
1834MailExchanger {
1835    domain_name: "do.havedane.net.",
1836    hosts: [
1837        "do.havedane.net.",
1838    ],
1839    site_name: "do.havedane.net",
1840    by_pref: {
1841        10: [
1842            "do.havedane.net.",
1843        ],
1844    },
1845    is_domain_literal: false,
1846    is_secure: true,
1847    is_mx: true,
1848}
1849"#
1850        );
1851    }
1852
1853    // Requires DNSSEC-validated TLSA records, so it only holds when the default
1854    // resolver validates, i.e. the unbound backend.
1855    #[cfg(all(feature = "live-dns-tests", feature = "default-unbound"))]
1856    #[tokio::test]
1857    async fn tlsa_have_dane() {
1858        let DaneStatus::Records(tlsa) = resolve_dane("do.havedane.net", 25).await.unwrap() else {
1859            panic!("expected usable DANE records");
1860        };
1861        k9::snapshot!(
1862            tlsa,
1863            "
1864[
1865    TLSA {
1866        cert_usage: TrustAnchor,
1867        selector: Spki,
1868        matching: Sha256,
1869        cert_data: [
1870            39,
1871            182,
1872            148,
1873            181,
1874            29,
1875            31,
1876            239,
1877            136,
1878            133,
1879            55,
1880            42,
1881            207,
1882            179,
1883            145,
1884            147,
1885            117,
1886            151,
1887            34,
1888            183,
1889            54,
1890            176,
1891            66,
1892            104,
1893            100,
1894            220,
1895            28,
1896            121,
1897            208,
1898            101,
1899            31,
1900            239,
1901            115,
1902        ],
1903    },
1904    TLSA {
1905        cert_usage: DomainIssued,
1906        selector: Spki,
1907        matching: Sha256,
1908        cert_data: [
1909            85,
1910            58,
1911            207,
1912            136,
1913            249,
1914            238,
1915            24,
1916            204,
1917            170,
1918            230,
1919            53,
1920            202,
1921            84,
1922            15,
1923            50,
1924            203,
1925            132,
1926            172,
1927            167,
1928            124,
1929            71,
1930            145,
1931            102,
1932            130,
1933            188,
1934            181,
1935            66,
1936            213,
1937            29,
1938            170,
1939            135,
1940            31,
1941        ],
1942    },
1943]
1944"
1945        );
1946    }
1947
1948    #[cfg(feature = "live-dns-tests")]
1949    #[tokio::test]
1950    async fn mx_lookup_www_example_com() {
1951        // Has no MX, should fall back to A lookup
1952        let mx = MailExchanger::resolve("www.example.com").await.unwrap();
1953        k9::snapshot!(
1954            mx,
1955            r#"
1956MailExchanger {
1957    domain_name: "www.example.com.",
1958    hosts: [
1959        "www.example.com.",
1960    ],
1961    site_name: "www.example.com",
1962    by_pref: {
1963        1: [
1964            "www.example.com.",
1965        ],
1966    },
1967    is_domain_literal: false,
1968    is_secure: false,
1969    is_mx: false,
1970}
1971"#
1972        );
1973    }
1974
1975    #[cfg(feature = "live-dns-tests")]
1976    #[tokio::test]
1977    async fn txt_lookup_gmail() {
1978        let name = Name::from_str_relaxed("_mta-sts.gmail.com").unwrap();
1979        let answer = get_resolver().resolve(name, RecordType::TXT).await.unwrap();
1980        k9::snapshot!(
1981            answer.as_txt(),
1982            r#"
1983[
1984    "v=STSv1; id=20190429T010101;",
1985]
1986"#
1987        );
1988    }
1989}