mailexchanger/
lib.rs

1use crate::site_name::factor_names;
2use anyhow::Context;
3use dns_resolver::{
4    get_resolver, has_colon_port, ip_lookup, DnsError, DomainClassification, IpLookupStrategy,
5    Name, Resolver,
6};
7use hickory_resolver::proto::op::ResponseCode;
8use hickory_resolver::proto::rr::{RData, RecordType};
9use kumo_address::host_or_socket::HostOrSocketAddress;
10use kumo_log_types::ResolvedAddress;
11use kumo_prometheus::declare_metric;
12use lruttl::declare_cache;
13use mta_sts::policy::MtaStsPolicy;
14pub use mta_sts::policy::PolicyMode;
15use rand::prelude::SliceRandom;
16use serde::Serialize;
17use std::collections::BTreeMap;
18use std::net::IpAddr;
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20use std::sync::{Arc, LazyLock};
21use std::time::{Duration, Instant};
22use tokio::sync::Semaphore;
23use tokio::time::timeout;
24
25mod site_name;
26
27/// Whether MX resolution consults MTA-STS policies. Defaults to true because
28/// honoring a destination's published MTA-STS policy is the correct default.
29/// Toggled via `kumo.dns.set_mta_sts_enabled`.
30static MTA_STS_ENABLED: AtomicBool = AtomicBool::new(true);
31
32pub fn set_mta_sts_enabled(enabled: bool) {
33    MTA_STS_ENABLED.store(enabled, Ordering::Relaxed);
34}
35
36pub fn is_mta_sts_enabled() -> bool {
37    MTA_STS_ENABLED.load(Ordering::Relaxed)
38}
39
40/// When a policy fetch fails transiently we don't want to pin a "no policy"
41/// result for the full DNS TTL, so we cap the cached entry to this interval
42/// to re-attempt the policy fetch sooner.
43const MTA_STS_FETCH_RETRY: Duration = Duration::from_secs(300);
44
45/// Maximum number of concurrent mx resolves permitted
46static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
47static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
48    LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
49
50/// 5 seconds in ms
51static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
52
53/// 5 minutes in ms
54static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
55
56/// The TTL for transient negative entries, in milliseconds.
57static MX_TRANSIENT_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(30 * 1000);
58
59pub fn set_mx_concurrency_limit(n: usize) {
60    MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
61}
62
63pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
64    let ms = duration
65        .as_millis()
66        .try_into()
67        .context("set_mx_timeout: duration is too large")?;
68    MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
69    Ok(())
70}
71
72pub fn get_mx_timeout() -> Duration {
73    Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
74}
75
76pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
77    let ms = duration
78        .as_millis()
79        .try_into()
80        .context("set_mx_negative_cache_ttl: duration is too large")?;
81    MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
82    Ok(())
83}
84
85pub fn get_mx_negative_ttl() -> Duration {
86    Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
87}
88
89pub fn set_mx_transient_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
90    let ms = duration
91        .as_millis()
92        .try_into()
93        .context("set_mx_transient_negative_cache_ttl: duration is too large")?;
94    MX_TRANSIENT_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
95    Ok(())
96}
97
98pub fn get_mx_transient_negative_ttl() -> Duration {
99    Duration::from_millis(MX_TRANSIENT_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
100}
101
102/// Returns the cache lifetime for an `MX_CACHE` lookup result.
103fn mx_cache_ttl(mx_result: &Result<Arc<MailExchanger>, MxResolveError>) -> Duration {
104    match mx_result {
105        Ok(mx) => match mx.expires {
106            Some(exp) => exp
107                .checked_duration_since(std::time::Instant::now())
108                .unwrap_or_else(|| Duration::from_secs(10)),
109            None => get_mx_negative_ttl(),
110        },
111        Err(err) => err.kind.negative_ttl(),
112    }
113}
114
115#[derive(Debug)]
116struct ByPreference {
117    pub hosts: Vec<String>,
118    pub pref: u16,
119    pub is_secure: bool,
120    pub is_mx: bool,
121}
122
123async fn lookup_mx_record(
124    domain_name: &Name,
125    resolver: Option<&dyn Resolver>,
126) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
127    lookup_mx_record_limited(
128        domain_name,
129        resolver,
130        &MX_CONCURRENCY_SEMA,
131        get_mx_timeout(),
132    )
133    .await
134}
135
136/// A guard that counts one lookup in `MX_PERMIT_WAITERS` while it waits for an
137/// MX concurrency permit. `Drop` removes it from the count, whether the
138/// permit is acquired or the wait is abandoned by a timeout.
139struct PermitWaitGuard;
140
141impl PermitWaitGuard {
142    fn enter() -> Self {
143        MX_PERMIT_WAITERS.inc();
144        Self
145    }
146}
147
148impl Drop for PermitWaitGuard {
149    fn drop(&mut self) {
150        MX_PERMIT_WAITERS.dec();
151    }
152}
153
154/// Perform the MX query for `domain_name`, bounding concurrency on
155/// `concurrency` and the whole operation (permit wait plus query) on
156/// `timeout_duration`.
157async fn lookup_mx_record_limited(
158    domain_name: &Name,
159    resolver: Option<&dyn Resolver>,
160    concurrency: &Semaphore,
161    timeout_duration: Duration,
162) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
163    // Records whether the MX concurrency permit was obtained before the
164    // timeout fired.
165    let permit_acquired = AtomicBool::new(false);
166    let mx_lookup = match timeout(timeout_duration, async {
167        let wait_guard = PermitWaitGuard::enter();
168        let _permit = concurrency.acquire().await;
169        // Drop here ends the wait explicitly on success. If the enclosing
170        // timeout instead drops this whole future first, the Drop of the guard
171        // still runs and removes the count.
172        drop(wait_guard);
173        // Relaxed is enough: this store and the load after the match below
174        // both happen in this function's own task, and no other task or
175        // thread ever touches `permit_acquired`.
176        permit_acquired.store(true, Ordering::Relaxed);
177        match resolver {
178            Some(r) => r.resolve(domain_name.clone(), RecordType::MX).await,
179            None => {
180                get_resolver()
181                    .resolve(domain_name.clone(), RecordType::MX)
182                    .await
183            }
184        }
185    })
186    .await
187    {
188        Ok(Ok(answer)) => answer,
189        Ok(Err(dns_err)) => {
190            return Err(MxResolveError {
191                kind: classify_dns_error(&dns_err),
192                message: dns_err.to_string(),
193            });
194        }
195        Err(_elapsed) => {
196            let (kind, message) = if permit_acquired.load(Ordering::Relaxed) {
197                MX_QUERY_TIMEOUT.inc();
198                (
199                    MxResolveFailure::Indeterminate,
200                    format!("MX query timed out after {timeout_duration:?}"),
201                )
202            } else {
203                MX_PERMIT_TIMEOUT.inc();
204                (
205                    MxResolveFailure::NeverQueried,
206                    format!(
207                        "timed out after {timeout_duration:?} waiting for an MX concurrency \
208                         permit; no query was sent"
209                    ),
210                )
211            };
212            return Err(MxResolveError { kind, message });
213        }
214    };
215    // A validating resolver can return a successful `Answer` that is really a
216    // failure or a forgery: a failure RCODE (SERVFAIL, REFUSED, ...) is a
217    // temporary error, and a DNSSEC-bogus answer must not be trusted even when
218    // it has records of its own. RFC 5321 5.1 requires retrying a temporary
219    // error rather than treating it as "no MX", and RFC 7672 2.1.1 requires
220    // treating a bogus answer as a lookup failure rather than as unsigned MX
221    // data we could deliver on.
222    if mx_lookup.bogus {
223        return Err(MxResolveError {
224            kind: MxResolveFailure::Indeterminate,
225            message: format!(
226                "MX records for {domain_name} are bogus: {}",
227                mx_lookup
228                    .why_bogus
229                    .as_deref()
230                    .unwrap_or("DNSSEC validation failed")
231            ),
232        });
233    }
234    match mx_lookup.response_code {
235        ResponseCode::NoError | ResponseCode::NXDomain => {}
236        rcode => {
237            return Err(MxResolveError {
238                kind: MxResolveFailure::Indeterminate,
239                message: format!("MX lookup for {domain_name} returned {rcode}"),
240            });
241        }
242    }
243
244    let mx_records = mx_lookup.records;
245
246    if mx_records.is_empty() {
247        if mx_lookup.nxdomain {
248            return Err(MxResolveError {
249                kind: MxResolveFailure::NxDomain,
250                message: "NXDOMAIN".to_string(),
251            });
252        }
253
254        // An empty answer reaching this point is NODATA. The domain's own
255        // A/AAAA records act as the implicit MX in that case. This implicit MX
256        // is secure exactly when the MX NODATA response was securely (DNSSEC)
257        // resolved, which is common for signed domains that don't publish an
258        // MX (e.g. many `.br` domains).
259        return Ok((
260            vec![ByPreference {
261                hosts: vec![domain_name.to_lowercase().to_ascii()],
262                pref: 1,
263                is_secure: mx_lookup.secure,
264                is_mx: false,
265            }],
266            mx_lookup.expires,
267        ));
268    }
269
270    let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
271
272    for mx_record in mx_records {
273        if let RData::MX(mx) = mx_record {
274            let pref = mx.preference;
275            let host = mx.exchange.to_lowercase().to_string();
276
277            if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
278                record.hosts.push(host);
279            } else {
280                records.push(ByPreference {
281                    hosts: vec![host],
282                    pref,
283                    is_secure: mx_lookup.secure,
284                    is_mx: true,
285                });
286            }
287        }
288    }
289
290    // Sort by preference
291    records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
292
293    // Sort the hosts at each preference level to produce the
294    // overall ordered list of hosts for this site
295    for mx in &mut records {
296        mx.hosts.sort();
297    }
298
299    Ok((records, mx_lookup.expires))
300}
301
302/// How resolving a domain's MX failed, classified by what the DNS exchange
303/// established. A caller that needs to act on a specific case (such as
304/// NXDOMAIN) can match on this after recovering the [`MxResolveError`] with
305/// `downcast_ref`, rather than matching on the message text.
306#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum MxResolveFailure {
308    /// Authoritative negative response: the resolver reported that the name
309    /// does not exist (NXDOMAIN). A resolver can return NXDOMAIN spuriously,
310    /// while its own upstream is still propagating a new registration, and will
311    /// stop doing so once that passes. A caller should re-check after a bounded
312    /// negative-cache lifetime rather than treat this as final.
313    NxDomain,
314    /// Indeterminate negative response: a query was issued but did not produce
315    /// an authoritative answer -- a timeout after the query was sent, a
316    /// SERVFAIL or other failure RCODE, or a resolver I/O error. The true state
317    /// of the name is unknown and a later query may resolve it.
318    Indeterminate,
319    /// No query was ever issued: the timeout elapsed while waiting for an MX
320    /// concurrency permit. Indicates local concurrency pressure, nothing about
321    /// the destination.
322    NeverQueried,
323    /// The MX set resolved, but the domain's MTA-STS enforce policy permits
324    /// none of its own MX hosts. Mail to the domain cannot be delivered until
325    /// its operator corrects the policy.
326    PolicyRejected,
327}
328
329impl MxResolveFailure {
330    /// Returns the negative-cache lifetime received by a failure of this kind.
331    /// An authoritative NXDOMAIN or an operator-gated policy rejection holds
332    /// for the full negative TTL. Indeterminate failures retry after a shorter
333    /// interval. A lookup that was never issued returns a zero lifetime, which
334    /// expires the cache entry immediately and makes the next caller
335    /// re-resolve.
336    fn negative_ttl(self) -> Duration {
337        match self {
338            MxResolveFailure::NxDomain | MxResolveFailure::PolicyRejected => get_mx_negative_ttl(),
339            MxResolveFailure::Indeterminate => get_mx_transient_negative_ttl(),
340            MxResolveFailure::NeverQueried => Duration::ZERO,
341        }
342    }
343}
344
345/// An MX resolution failure: its [`MxResolveFailure`] classification together
346/// with a human-readable `message`. `Display` writes `message` verbatim, which
347/// means `{err}` formatting (including `anyhow!("{err}")` and log output)
348/// reproduces that text. Conversion to `anyhow::Error` keeps this concrete
349/// type rather than erasing it: a caller holding the resulting `anyhow::Error`
350/// can recover the classification with `downcast_ref::<MxResolveError>()`.
351#[derive(Clone, Debug)]
352pub struct MxResolveError {
353    pub kind: MxResolveFailure,
354    pub message: String,
355}
356
357impl MxResolveError {
358    /// Whether the resolver returned NXDOMAIN for the name, for callers that
359    /// need to single out that case without inspecting the message text.
360    pub fn is_nxdomain(&self) -> bool {
361        self.kind == MxResolveFailure::NxDomain
362    }
363}
364
365impl std::fmt::Display for MxResolveError {
366    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
367        f.write_str(&self.message)
368    }
369}
370
371impl std::error::Error for MxResolveError {}
372
373/// Classify a resolver-level error. Both `DnsError` variants map to
374/// `MxResolveFailure::Indeterminate` because neither contains an authoritative
375/// answer.
376fn classify_dns_error(err: &DnsError) -> MxResolveFailure {
377    match err {
378        // The query reached the resolver but came back without an
379        // authoritative answer.
380        DnsError::ResolveFailed(_) => MxResolveFailure::Indeterminate,
381        // InvalidName is only ever constructed by the &str-parsing methods on
382        // Resolver, not by resolve(Name, RecordType), which this function's
383        // caller uses and which receives an already-parsed Name. The arm exists
384        // for exhaustiveness and maps to Indeterminate as the conservative
385        // default.
386        DnsError::InvalidName(_) => MxResolveFailure::Indeterminate,
387    }
388}
389
390#[derive(Clone, Debug, Serialize)]
391pub struct MailExchanger {
392    pub domain_name: String,
393    pub hosts: Vec<String>,
394    pub site_name: String,
395    pub by_pref: BTreeMap<u16, Vec<String>>,
396    pub is_domain_literal: bool,
397    /// DNSSEC verified
398    pub is_secure: bool,
399    pub is_mx: bool,
400    /// The applicable MTA-STS policy mode (`PolicyMode::None` when no policy
401    /// applies). `hosts`/`by_pref` already exclude any hosts disallowed by the
402    /// policy, so this is consulted only for TLS posture, not host gating.
403    pub mta_sts: PolicyMode,
404    #[serde(skip)]
405    expires: Option<Instant>,
406}
407
408declare_cache! {
409/// Caches domain name to computed set of MailExchanger records
410static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, MxResolveError>>::new("dns_resolver_mx", 64 * 1024);
411}
412
413declare_metric! {
414/// number of `MailExchanger::resolve` calls currently in progress.
415static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
416}
417
418declare_metric! {
419/// Total number of successful `MailExchanger::resolve` calls
420static MX_SUCCESS: IntCounter(
421        "dns_mx_resolve_status_ok");
422}
423
424declare_metric! {
425/// Total number of failed `MailExchanger::resolve` calls.
426///
427/// Spikes may indicate an issue with your DNS configuration
428/// or infrastructure, or may simply indicate that the traffic
429/// is destined for bogus addresses.
430static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
431}
432
433declare_metric! {
434/// Total number of MailExchanger::resolve calls satisfied by level 1 cache.
435///
436/// Redundant with the newer [lruttl_hit_count{cache_name="dns_resolver_mx"}](lruttl_hit_count.md)
437/// metric.
438static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
439}
440
441declare_metric! {
442/// Total number of MailExchanger::resolve calls that resulted in an MX DNS request to the next level of cache
443///
444/// Redundant with the newer [lruttl_miss_count{cache_name="dns_resolver_mx"}](lruttl_miss_count.md)
445/// metric.
446static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
447}
448
449declare_metric! {
450/// Total number of MX lookups that timed out while waiting for an MX
451/// concurrency permit before any query could be sent. Counts local
452/// concurrency pressure, not any response from the destination.
453static MX_PERMIT_TIMEOUT: IntCounter("dns_mx_resolve_permit_timeout");
454}
455
456declare_metric! {
457/// Total number of MX lookups that timed out after the query was sent, while
458/// waiting for the resolver to answer.
459static MX_QUERY_TIMEOUT: IntCounter("dns_mx_resolve_query_timeout");
460}
461
462declare_metric! {
463/// Number of MX lookups currently blocked waiting for an MX concurrency
464/// permit before their query can be sent.
465static MX_PERMIT_WAITERS: IntGauge("dns_mx_resolve_permit_waiters");
466}
467
468declare_metric! {
469/// Total number of MailExchanger::resolve calls that failed because the
470/// domain published an MTA-STS enforce policy that permits none of its own
471/// MX hosts. Such domains are undeliverable until they fix their policy.
472static MX_MTA_STS_IMPOSSIBLE: IntCounter("dns_mx_resolve_mta_sts_impossible");
473}
474
475/// The effect of an MTA-STS policy on a domain's resolved MX host set.
476#[derive(Debug, PartialEq, Eq)]
477enum StsEval {
478    /// No host pruning required; record this TLS posture.
479    Status(PolicyMode),
480    /// Enforce policy with partial coverage: keep only the hosts whose
481    /// index in the input is `true`.
482    Prune(Vec<bool>),
483    /// Enforce policy matches none of the hosts: the domain is undeliverable.
484    Impossible,
485}
486
487/// Evaluate an MTA-STS policy against `hosts` (in resolution order, lowercased,
488/// optionally `host:port`). Pure so it can be unit-tested without DNS/HTTP.
489fn evaluate_mta_sts(hosts: &[String], policy: &MtaStsPolicy) -> StsEval {
490    match policy.mode {
491        PolicyMode::None => StsEval::Status(PolicyMode::None),
492        PolicyMode::Testing => StsEval::Status(PolicyMode::Testing),
493        PolicyMode::Enforce => {
494            let matched: Vec<bool> = hosts
495                .iter()
496                .map(|h| {
497                    let label = match has_colon_port(h) {
498                        Some((label, _)) => label,
499                        None => h.as_str(),
500                    };
501                    policy.mx_name_matches(label)
502                })
503                .collect();
504            let match_count = matched.iter().filter(|m| **m).count();
505            if match_count == 0 {
506                StsEval::Impossible
507            } else if match_count == hosts.len() {
508                StsEval::Status(PolicyMode::Enforce)
509            } else {
510                StsEval::Prune(matched)
511            }
512        }
513    }
514}
515
516/// Fetch and apply the MTA-STS policy for `name_fq` to the resolved MX set,
517/// updating `by_pref`/`hosts`/`expires` in place. Returns the resolved policy
518/// mode (when one applies), or `Err(message)` if the domain's enforce policy
519/// permits none of its MX hosts and is therefore undeliverable.
520async fn apply_mta_sts(
521    name_fq: &Name,
522    by_pref: &mut Vec<ByPreference>,
523    hosts: &mut Vec<String>,
524    expires: &mut Instant,
525    resolver: Option<&dyn Resolver>,
526) -> Result<PolicyMode, String> {
527    let policy_domain = name_fq.to_ascii();
528    let policy_domain = policy_domain.trim_end_matches('.');
529
530    let policy = match mta_sts::get_policy_for_domain(policy_domain, resolver).await {
531        Ok(policy) => policy,
532        Err(err) => {
533            // A transient fetch failure must not be treated as "impossible";
534            // proceed as no-policy but re-attempt sooner than the full DNS TTL.
535            tracing::debug!("MTA-STS policy fetch for {policy_domain} failed: {err:#}");
536            *expires = (*expires).min(Instant::now() + MTA_STS_FETCH_RETRY);
537            return Ok(PolicyMode::None);
538        }
539    };
540
541    let mta_sts = match evaluate_mta_sts(hosts, &policy) {
542        StsEval::Status(status) => status,
543        StsEval::Prune(matched) => {
544            // Partial coverage: prune the disallowed hosts so the site resolves
545            // to only the permitted set (and rolls up only with others sharing
546            // that set).
547            let mut idx = 0;
548            for pref in by_pref.iter_mut() {
549                pref.hosts.retain(|_| {
550                    let keep = matched[idx];
551                    idx += 1;
552                    keep
553                });
554            }
555            by_pref.retain(|p| !p.hosts.is_empty());
556            *hosts = by_pref
557                .iter()
558                .flat_map(|p| p.hosts.iter().cloned())
559                .collect();
560            PolicyMode::Enforce
561        }
562        StsEval::Impossible => {
563            MX_MTA_STS_IMPOSSIBLE.inc();
564            return Err(format!(
565                "MTA-STS enforce policy for {policy_domain} permits none of its \
566                 MX hosts {hosts:?}; allowed mx patterns: {patterns:?}. The \
567                 destination is undeliverable until its MTA-STS policy is \
568                 corrected.",
569                patterns = policy.mx
570            ));
571        }
572    };
573
574    // Refresh in full: re-resolve when either the MX records or the policy
575    // expire. max_age is clamped on parse, but guard the addition anyway so an
576    // overflowing value expires immediately rather than panicking.
577    let policy_expires = Instant::now()
578        .checked_add(Duration::from_secs(policy.max_age))
579        .unwrap_or_else(Instant::now);
580    *expires = (*expires).min(policy_expires);
581    Ok(mta_sts)
582}
583
584impl MailExchanger {
585    pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
586        Self::resolve_via(domain_name, None).await
587    }
588
589    /// Like [`resolve`](Self::resolve), but performs DNS via the supplied
590    /// `resolver` when one is provided. A supplied resolver bypasses the shared
591    /// MX cache so callers (such as tests using a fixture resolver) get
592    /// hermetic, order-independent results.
593    pub async fn resolve_via(
594        domain_name: &str,
595        resolver: Option<&dyn Resolver>,
596    ) -> anyhow::Result<Arc<Self>> {
597        MX_IN_PROGRESS.inc();
598        let result = Self::resolve_impl(domain_name, resolver).await;
599        MX_IN_PROGRESS.dec();
600        if result.is_ok() {
601            MX_SUCCESS.inc();
602        } else {
603            MX_FAIL.inc();
604        }
605        result
606    }
607
608    async fn resolve_impl(
609        domain_name: &str,
610        resolver: Option<&dyn Resolver>,
611    ) -> anyhow::Result<Arc<Self>> {
612        let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
613            DomainClassification::Literal(addr) => {
614                let mut by_pref = BTreeMap::new();
615                by_pref.insert(1, vec![addr.to_string()]);
616                return Ok(Arc::new(Self {
617                    domain_name: domain_name.to_string(),
618                    hosts: vec![addr.to_string()],
619                    site_name: addr.to_string(),
620                    by_pref,
621                    is_domain_literal: true,
622                    is_secure: false,
623                    is_mx: false,
624                    mta_sts: PolicyMode::None,
625                    expires: None,
626                }));
627            }
628            DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
629        };
630
631        // A supplied resolver bypasses the shared MX cache so results are
632        // hermetic and order-independent.
633        if resolver.is_some() {
634            return Self::resolve_uncached(&name_fq, opt_port, domain_name, resolver)
635                .await?
636                .map_err(anyhow::Error::new);
637        }
638
639        let lookup_result = MX_CACHE
640            .get_or_try_insert(
641                &(name_fq.clone(), opt_port),
642                mx_cache_ttl,
643                Self::resolve_uncached(&name_fq, opt_port, domain_name, None),
644            )
645            .await
646            .map_err(|err| anyhow::anyhow!("{err}"))?;
647
648        if !lookup_result.is_fresh {
649            MX_CACHED.inc();
650        }
651
652        lookup_result.item.map_err(anyhow::Error::new)
653    }
654
655    async fn resolve_uncached(
656        name_fq: &Name,
657        opt_port: Option<u16>,
658        domain_name: &str,
659        resolver: Option<&dyn Resolver>,
660    ) -> anyhow::Result<Result<Arc<MailExchanger>, MxResolveError>> {
661        MX_QUERIES.inc();
662        let start = Instant::now();
663        let (mut by_pref, mut expires) = match lookup_mx_record(name_fq, resolver).await {
664            Ok((by_pref, expires)) => (by_pref, expires),
665            Err(err) => {
666                let error = format!(
667                    "MX lookup for {domain_name} failed after {elapsed:?}: {err}",
668                    elapsed = start.elapsed()
669                );
670                tracing::debug!(
671                    target: "mx_resolve",
672                    domain = domain_name,
673                    %error,
674                    "MX lookup failed; domain drops out of any site_name rollup"
675                );
676                return Ok(Err(MxResolveError {
677                    kind: err.kind,
678                    message: error,
679                }));
680            }
681        };
682
683        let mut hosts = vec![];
684        for pref in &mut by_pref {
685            for host in &mut pref.hosts {
686                if let Some(port) = opt_port {
687                    *host = format!("{host}:{port}");
688                };
689                hosts.push(host.to_string());
690            }
691        }
692
693        let is_secure = by_pref.iter().all(|p| p.is_secure);
694        let is_mx = by_pref.iter().all(|p| p.is_mx);
695
696        // Evaluate MTA-STS against this domain's own resolution, before
697        // site_name rollup. A domain whose enforce policy matches none of its
698        // MX hosts is undeliverable and fails resolution so that it
699        // self-isolates rather than affecting a shared site.
700        let mta_sts = if is_mx && is_mta_sts_enabled() {
701            match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
702                Ok(status) => status,
703                Err(error) => {
704                    tracing::debug!(
705                        target: "mx_resolve",
706                        domain = domain_name,
707                        %error,
708                        "MTA-STS evaluation failed; domain drops out of any site_name rollup"
709                    );
710                    return Ok(Err(MxResolveError {
711                        kind: MxResolveFailure::PolicyRejected,
712                        message: error,
713                    }));
714                }
715            }
716        } else {
717            PolicyMode::None
718        };
719
720        let by_pref = by_pref
721            .into_iter()
722            .map(|pref| (pref.pref, pref.hosts))
723            .collect();
724
725        let site_name = factor_names(&hosts);
726        tracing::debug!(
727            target: "mx_resolve",
728            domain = domain_name,
729            %site_name,
730            ?hosts,
731            is_mx,
732            is_secure,
733            elapsed_ms = start.elapsed().as_millis() as u64,
734            "resolved MX to site_name"
735        );
736        let mx = Self {
737            hosts,
738            domain_name: name_fq.to_ascii(),
739            site_name,
740            by_pref,
741            is_domain_literal: false,
742            is_secure,
743            is_mx,
744            mta_sts,
745            expires: Some(expires),
746        };
747
748        Ok(Ok(Arc::new(mx)))
749    }
750
751    pub fn has_expired(&self) -> bool {
752        match self.expires {
753            Some(deadline) => deadline <= Instant::now(),
754            None => false,
755        }
756    }
757
758    /// Returns the list of resolved MX hosts in *reverse* preference order.
759    ///
760    /// `max_addresses_per_host` caps the `A`/`AAAA` addresses kept from any
761    /// single MX host, and `max_plan_size` caps the total across all hosts.
762    /// Both bound the plan: a destination publishing a very large number of
763    /// addresses cannot force an unbounded plan. When a cap truncates the
764    /// list, the addresses that are dropped are from the least-preferred
765    /// hosts.
766    pub async fn resolve_addresses(
767        &self,
768        resolver: Option<&dyn Resolver>,
769        strategy: IpLookupStrategy,
770        max_plan_size: usize,
771        max_addresses_per_host: usize,
772    ) -> ResolvedMxAddresses {
773        let mut result = vec![];
774
775        // `by_pref` is a BTreeMap keyed by MX preference (lowest first);
776        // iterating it in key order visits the most-preferred hosts first.
777        'by_pref: for hosts in self.by_pref.values() {
778            let mut by_pref = vec![];
779
780            for mx_host in hosts {
781                // '.' is a null mx; skip trying to resolve it
782                if mx_host == "." {
783                    return ResolvedMxAddresses::NullMx;
784                }
785
786                // Handle the literal address case
787                let (mx_host, opt_port) = match has_colon_port(mx_host) {
788                    Some((domain_name, port)) => (domain_name, Some(port)),
789                    None => (mx_host.as_str(), None),
790                };
791                if let Ok(addr) = mx_host.parse::<IpAddr>() {
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: false,
800                    });
801                    continue;
802                }
803
804                match ip_lookup(mx_host, resolver, strategy).await {
805                    Err(err) => {
806                        tracing::error!("failed to resolve {mx_host}: {err:#}");
807                        continue;
808                    }
809                    Ok((lookup, _expires)) => {
810                        for addr in lookup.addrs.iter().take(max_addresses_per_host) {
811                            let mut addr: HostOrSocketAddress = (*addr).into();
812                            if let Some(port) = opt_port {
813                                addr.set_port(port);
814                            }
815                            by_pref.push(ResolvedAddress {
816                                name: mx_host.to_string(),
817                                addr,
818                                is_secure: lookup.secure,
819                            });
820                        }
821                    }
822                }
823            }
824
825            // Randomize the list of addresses within this preference
826            // level. This probablistically "load balances" outgoing
827            // traffic across MX hosts with equal preference value.
828            {
829                let mut rng = rand::thread_rng();
830                by_pref.shuffle(&mut rng);
831            }
832
833            for addr in by_pref {
834                if result.len() == max_plan_size {
835                    // Stop resolving lower-preference hosts entirely, rather
836                    // than resolving them and truncating afterward.
837                    break 'by_pref;
838                }
839                result.push(addr);
840            }
841        }
842
843        // Flip to the LIFO order the caller expects: the first candidate to
844        // try ends up as the last element.
845        result.reverse();
846        ResolvedMxAddresses::Addresses(result)
847    }
848}
849
850#[derive(Debug, Clone, Serialize)]
851pub enum ResolvedMxAddresses {
852    NullMx,
853    /// The list of addresses to which to connect, expressed
854    /// in LIFO order
855    Addresses(Vec<ResolvedAddress>),
856}
857
858#[cfg(test)]
859mod test {
860    use super::*;
861    use dns_resolver::{fully_qualify, TestResolver};
862
863    fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
864        let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
865        for m in mx {
866            text.push_str(&format!("\nmx: {m}"));
867        }
868        MtaStsPolicy::parse(&text).unwrap()
869    }
870
871    fn hosts(list: &[&str]) -> Vec<String> {
872        list.iter().map(|s| s.to_string()).collect()
873    }
874
875    #[test]
876    fn mta_sts_none_and_testing() {
877        assert_eq!(
878            evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
879            StsEval::Status(PolicyMode::None)
880        );
881        assert_eq!(
882            evaluate_mta_sts(
883                &hosts(&["mx01.mail.icloud.com."]),
884                &policy("testing", &["*.mx.cloudflare.net"])
885            ),
886            StsEval::Status(PolicyMode::Testing)
887        );
888    }
889
890    #[test]
891    fn mta_sts_enforce_full_match() {
892        assert_eq!(
893            evaluate_mta_sts(
894                &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
895                &policy("enforce", &["*.mail.icloud.com"])
896            ),
897            StsEval::Status(PolicyMode::Enforce)
898        );
899    }
900
901    #[test]
902    fn mta_sts_enforce_partial_prunes() {
903        // Second host is not permitted; expect a prune mask, not failure.
904        assert_eq!(
905            evaluate_mta_sts(
906                &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
907                &policy("enforce", &["*.mail.icloud.com"])
908            ),
909            StsEval::Prune(vec![true, false])
910        );
911    }
912
913    #[test]
914    fn mta_sts_enforce_impossible() {
915        // The icloud-hosted random domain whose policy only allows cloudflare:
916        // matches no host, so the domain is undeliverable.
917        assert_eq!(
918            evaluate_mta_sts(
919                &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
920                &policy("enforce", &["*.mx.cloudflare.net"])
921            ),
922            StsEval::Impossible
923        );
924    }
925
926    #[test]
927    fn mta_sts_enforce_strips_port() {
928        assert_eq!(
929            evaluate_mta_sts(
930                &hosts(&["mx01.mail.icloud.com.:587"]),
931                &policy("enforce", &["*.mail.icloud.com"])
932            ),
933            StsEval::Status(PolicyMode::Enforce)
934        );
935    }
936
937    #[tokio::test]
938    async fn site_identity_does_not_reorder_connection_preferences() {
939        let resolver = TestResolver::default()
940            .with_zone("$ORIGIN priority-a.example.\n@ 600 MX 10 mx1.targets.test.\n@ 600 MX 20 mx2.targets.test.\n")
941            .unwrap()
942            .with_zone("$ORIGIN priority-b.example.\n@ 600 MX 10 mx2.targets.test.\n@ 600 MX 20 mx1.targets.test.\n")
943            .unwrap();
944        let a = MailExchanger::resolve_via("priority-a.example", Some(&resolver))
945            .await
946            .unwrap();
947        let b = MailExchanger::resolve_via("priority-b.example", Some(&resolver))
948            .await
949            .unwrap();
950        assert_eq!(a.site_name, b.site_name);
951        assert_eq!(a.hosts, hosts(&["mx1.targets.test.", "mx2.targets.test."]));
952        assert_eq!(b.hosts, hosts(&["mx2.targets.test.", "mx1.targets.test."]));
953        assert_eq!(a.by_pref[&10], hosts(&["mx1.targets.test."]));
954        assert_eq!(b.by_pref[&10], hosts(&["mx2.targets.test."]));
955    }
956
957    #[tokio::test]
958    async fn literal_resolve() {
959        let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
960        k9::snapshot!(
961            &v4_loopback,
962            r#"
963MailExchanger {
964    domain_name: "[127.0.0.1]",
965    hosts: [
966        "127.0.0.1",
967    ],
968    site_name: "127.0.0.1",
969    by_pref: {
970        1: [
971            "127.0.0.1",
972        ],
973    },
974    is_domain_literal: true,
975    is_secure: false,
976    is_mx: false,
977    mta_sts: None,
978    expires: None,
979}
980"#
981        );
982        k9::snapshot!(
983            v4_loopback
984                .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
985                .await,
986            r#"
987Addresses(
988    [
989        ResolvedAddress {
990            name: "127.0.0.1",
991            addr: 127.0.0.1,
992            is_secure: false,
993        },
994    ],
995)
996"#
997        );
998
999        let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
1000        k9::snapshot!(
1001            &v6_loopback_non_conforming,
1002            r#"
1003MailExchanger {
1004    domain_name: "[::1]",
1005    hosts: [
1006        "::1",
1007    ],
1008    site_name: "::1",
1009    by_pref: {
1010        1: [
1011            "::1",
1012        ],
1013    },
1014    is_domain_literal: true,
1015    is_secure: false,
1016    is_mx: false,
1017    mta_sts: None,
1018    expires: None,
1019}
1020"#
1021        );
1022        k9::snapshot!(
1023            v6_loopback_non_conforming
1024                .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1025                .await,
1026            r#"
1027Addresses(
1028    [
1029        ResolvedAddress {
1030            name: "::1",
1031            addr: ::1,
1032            is_secure: false,
1033        },
1034    ],
1035)
1036"#
1037        );
1038
1039        let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
1040        k9::snapshot!(
1041            &v6_loopback,
1042            r#"
1043MailExchanger {
1044    domain_name: "[IPv6:::1]",
1045    hosts: [
1046        "::1",
1047    ],
1048    site_name: "::1",
1049    by_pref: {
1050        1: [
1051            "::1",
1052        ],
1053    },
1054    is_domain_literal: true,
1055    is_secure: false,
1056    is_mx: false,
1057    mta_sts: None,
1058    expires: None,
1059}
1060"#
1061        );
1062        k9::snapshot!(
1063            v6_loopback
1064                .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1065                .await,
1066            r#"
1067Addresses(
1068    [
1069        ResolvedAddress {
1070            name: "::1",
1071            addr: ::1,
1072            is_secure: false,
1073        },
1074    ],
1075)
1076"#
1077        );
1078    }
1079
1080    fn fixture_resolver(zones: &[&str]) -> TestResolver {
1081        let mut resolver = TestResolver::default();
1082        for zone in zones {
1083            resolver = resolver.with_zone(zone).unwrap();
1084        }
1085        resolver
1086    }
1087
1088    const GMAIL_ZONE: &str = r#"
1089$ORIGIN gmail.com.
1090@ 86400 MX 5 gmail-smtp-in.l.google.com.
1091@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
1092@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
1093@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
1094@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
1095"#;
1096
1097    const GMAIL_HOSTS_ZONE: &str = r#"
1098$ORIGIN l.google.com.
1099gmail-smtp-in 300 A 142.251.2.26
1100alt1.gmail-smtp-in 300 A 108.177.104.27
1101alt2.gmail-smtp-in 300 A 74.125.126.27
1102alt3.gmail-smtp-in 300 A 172.253.113.26
1103alt4.gmail-smtp-in 300 A 173.194.77.27
1104"#;
1105
1106    #[tokio::test]
1107    async fn lookup_gmail_mx() {
1108        let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1109        let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
1110            .await
1111            .unwrap())
1112        .clone();
1113        gmail.expires.take();
1114        k9::snapshot!(
1115            &gmail,
1116            r#"
1117MailExchanger {
1118    domain_name: "gmail.com.",
1119    hosts: [
1120        "gmail-smtp-in.l.google.com.",
1121        "alt1.gmail-smtp-in.l.google.com.",
1122        "alt2.gmail-smtp-in.l.google.com.",
1123        "alt3.gmail-smtp-in.l.google.com.",
1124        "alt4.gmail-smtp-in.l.google.com.",
1125    ],
1126    site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
1127    by_pref: {
1128        5: [
1129            "gmail-smtp-in.l.google.com.",
1130        ],
1131        10: [
1132            "alt1.gmail-smtp-in.l.google.com.",
1133        ],
1134        20: [
1135            "alt2.gmail-smtp-in.l.google.com.",
1136        ],
1137        30: [
1138            "alt3.gmail-smtp-in.l.google.com.",
1139        ],
1140        40: [
1141            "alt4.gmail-smtp-in.l.google.com.",
1142        ],
1143    },
1144    is_domain_literal: false,
1145    is_secure: false,
1146    is_mx: true,
1147    mta_sts: None,
1148    expires: None,
1149}
1150"#
1151        );
1152
1153        // The hosts are returned in reverse preference order (the last entry is
1154        // tried first). With one address per host the per-preference-level
1155        // shuffle in resolve_addresses is a no-op, so the order is stable.
1156        k9::snapshot!(
1157            gmail
1158                .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1159                .await,
1160            r#"
1161Addresses(
1162    [
1163        ResolvedAddress {
1164            name: "alt4.gmail-smtp-in.l.google.com.",
1165            addr: 173.194.77.27,
1166            is_secure: false,
1167        },
1168        ResolvedAddress {
1169            name: "alt3.gmail-smtp-in.l.google.com.",
1170            addr: 172.253.113.26,
1171            is_secure: false,
1172        },
1173        ResolvedAddress {
1174            name: "alt2.gmail-smtp-in.l.google.com.",
1175            addr: 74.125.126.27,
1176            is_secure: false,
1177        },
1178        ResolvedAddress {
1179            name: "alt1.gmail-smtp-in.l.google.com.",
1180            addr: 108.177.104.27,
1181            is_secure: false,
1182        },
1183        ResolvedAddress {
1184            name: "gmail-smtp-in.l.google.com.",
1185            addr: 142.251.2.26,
1186            is_secure: false,
1187        },
1188    ],
1189)
1190"#
1191        );
1192    }
1193
1194    #[tokio::test]
1195    async fn resolve_addresses_caps_plan() {
1196        // One preference level with many hosts, each publishing a large A
1197        // RRset: 32 hosts x 32 addresses = 1024 candidates with no cap. A
1198        // lower-preference backup host with its own large RRset is dropped
1199        // entirely once the total cap is reached.
1200        let hosts = 32;
1201        let addrs_per_host = 32;
1202        let mut zone = String::from("$ORIGIN example.com.\n");
1203        for h in 0..hosts {
1204            zone.push_str(&format!("@ 86400 MX 10 host{h}.example.com.\n"));
1205        }
1206        zone.push_str("@ 86400 MX 20 backup.example.com.\n");
1207        for h in 0..hosts {
1208            for i in 1..=addrs_per_host {
1209                zone.push_str(&format!("host{h} 300 A 10.0.{h}.{i}\n"));
1210            }
1211        }
1212        for i in 1..=addrs_per_host {
1213            zone.push_str(&format!("backup 300 A 10.1.0.{i}\n"));
1214        }
1215        let resolver = fixture_resolver(&[&zone]);
1216        let mx = MailExchanger::resolve_via("example.com", Some(&resolver))
1217            .await
1218            .unwrap();
1219
1220        // Without the caps the plan would hold all 1056 addresses, including
1221        // the lower-preference backup host.
1222        let uncapped = match mx
1223            .resolve_addresses(
1224                Some(&resolver),
1225                IpLookupStrategy::Ipv4Only,
1226                usize::MAX,
1227                usize::MAX,
1228            )
1229            .await
1230        {
1231            ResolvedMxAddresses::Addresses(a) => a,
1232            other => panic!("expected addresses, got {other:?}"),
1233        };
1234        k9::assert_equal!(uncapped.len(), (hosts + 1) * addrs_per_host);
1235        k9::assert_equal!(
1236            uncapped
1237                .iter()
1238                .filter(|a| a.name == "backup.example.com.")
1239                .count(),
1240            addrs_per_host
1241        );
1242
1243        // The default caps bound the total to max_plan_size, a host
1244        // contributes no more than max_addresses_per_host, and the
1245        // addresses dropped are the least-preferred ones: the backup host
1246        // is absent entirely.
1247        let capped = match mx
1248            .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1249            .await
1250        {
1251            ResolvedMxAddresses::Addresses(a) => a,
1252            other => panic!("expected addresses, got {other:?}"),
1253        };
1254        k9::assert_equal!(capped.len(), 50);
1255        k9::assert_equal!(
1256            capped
1257                .iter()
1258                .filter(|a| a.name == "backup.example.com.")
1259                .count(),
1260            0
1261        );
1262
1263        let mut per_host = std::collections::HashMap::new();
1264        for addr in &capped {
1265            *per_host.entry(addr.name.clone()).or_insert(0usize) += 1;
1266        }
1267        let max_from_one_host = per_host.values().copied().max().unwrap();
1268        assert!(
1269            max_from_one_host <= 10,
1270            "a single host contributed {max_from_one_host} addresses, exceeding the per-host cap"
1271        );
1272    }
1273
1274    #[tokio::test]
1275    async fn lookup_punycode_no_mx_only_a() {
1276        let resolver = fixture_resolver(&[r#"
1277$ORIGIN xn--bb-eka.at.
1278@ 300 A 192.0.2.5
1279"#]);
1280        let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
1281            .await
1282            .unwrap();
1283        assert_eq!(mx.domain_name, "xn--bb-eka.at.");
1284        assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
1285    }
1286
1287    #[tokio::test]
1288    async fn lookup_nxdomain() {
1289        // The fixture has no zone covering this name, so the MX lookup is
1290        // NXDOMAIN.
1291        let resolver = fixture_resolver(&[]);
1292        let name = fully_qualify("not-mairs.aasland.com").unwrap();
1293        let err = match lookup_mx_record(&name, Some(&resolver)).await {
1294            Ok(_) => panic!("expected NXDOMAIN"),
1295            Err(err) => err,
1296        };
1297        k9::assert_equal!(err.to_string(), "NXDOMAIN");
1298        k9::assert_equal!(err.kind, MxResolveFailure::NxDomain);
1299        assert!(err.is_nxdomain());
1300    }
1301
1302    #[tokio::test]
1303    async fn nxdomain_is_recoverable_through_anyhow() {
1304        // resolve_via converts the failure to anyhow. A caller must still be
1305        // able to recover the classification without parsing the message.
1306        let resolver = fixture_resolver(&[]);
1307        let err = MailExchanger::resolve_via("not-mairs.aasland.com", Some(&resolver))
1308            .await
1309            .unwrap_err();
1310        let mx_err = err
1311            .downcast_ref::<MxResolveError>()
1312            .expect("the typed MX error survives the anyhow conversion");
1313        assert!(mx_err.is_nxdomain());
1314        k9::assert_equal!(mx_err.kind, MxResolveFailure::NxDomain);
1315    }
1316
1317    #[test]
1318    fn classify_resolver_error_is_indeterminate() {
1319        // ResolveFailed means the query reached the resolver without producing
1320        // an authoritative answer, which is the Indeterminate case.
1321        k9::assert_equal!(
1322            classify_dns_error(&DnsError::ResolveFailed("SERVFAIL".to_string())),
1323            MxResolveFailure::Indeterminate
1324        );
1325        // Production never calls classify_dns_error with InvalidName, but this
1326        // pins its fallback value (the same as ResolveFailed) so a future
1327        // caller that does pass one gets a deliberate answer instead of an
1328        // unreviewed one.
1329        k9::assert_equal!(
1330            classify_dns_error(&DnsError::InvalidName("bad".to_string())),
1331            MxResolveFailure::Indeterminate
1332        );
1333    }
1334
1335    /// Resolver that sleeps for `delay` before failing, used to drive the
1336    /// query-timeout classification path.
1337    struct DelayResolver {
1338        delay: Duration,
1339    }
1340
1341    #[async_trait::async_trait]
1342    impl Resolver for DelayResolver {
1343        async fn resolve_ip(&self, _host: &str) -> Result<Vec<IpAddr>, DnsError> {
1344            unreachable!()
1345        }
1346        async fn resolve_mx(&self, _host: &str) -> Result<Vec<Name>, DnsError> {
1347            unreachable!()
1348        }
1349        async fn resolve_ptr(&self, _ip: IpAddr) -> Result<Vec<Name>, DnsError> {
1350            unreachable!()
1351        }
1352        async fn resolve(
1353            &self,
1354            _name: Name,
1355            _rrtype: RecordType,
1356        ) -> Result<dns_resolver::Answer, DnsError> {
1357            tokio::time::sleep(self.delay).await;
1358            Err(DnsError::ResolveFailed("delay elapsed".to_string()))
1359        }
1360    }
1361
1362    #[tokio::test]
1363    async fn deadline_before_permit_is_never_queried() {
1364        // `acquire` blocks until a permit exists. With none in this semaphore
1365        // it blocks forever, leaving the 50ms timeout below as the only thing
1366        // that fires.
1367        let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1368        let name = fully_qualify("gmail.com").unwrap();
1369        let no_permits = Semaphore::new(0);
1370        let before = MX_PERMIT_TIMEOUT.get();
1371        let err = match lookup_mx_record_limited(
1372            &name,
1373            Some(&resolver),
1374            &no_permits,
1375            Duration::from_millis(50),
1376        )
1377        .await
1378        {
1379            Ok(_) => panic!("expected a timeout before the permit was acquired"),
1380            Err(err) => err,
1381        };
1382        k9::assert_equal!(err.kind, MxResolveFailure::NeverQueried);
1383        k9::assert_equal!(
1384            err.message,
1385            "timed out after 50ms waiting for an MX concurrency permit; no query was sent"
1386        );
1387        k9::assert_equal!(MX_PERMIT_TIMEOUT.get() - before, 1);
1388    }
1389
1390    #[tokio::test]
1391    async fn query_timeout_is_indeterminate() {
1392        let resolver = DelayResolver {
1393            delay: Duration::from_secs(60),
1394        };
1395        let name = fully_qualify("gmail.com").unwrap();
1396        // Unlike `deadline_before_permit_is_never_queried`, this semaphore
1397        // starts with a permit available. The query reaches `DelayResolver` and
1398        // is already in flight when the 50ms timeout elapses.
1399        let one_permit = Semaphore::new(1);
1400        let before = MX_QUERY_TIMEOUT.get();
1401        let err = match lookup_mx_record_limited(
1402            &name,
1403            Some(&resolver),
1404            &one_permit,
1405            Duration::from_millis(50),
1406        )
1407        .await
1408        {
1409            Ok(_) => panic!("expected the query to exceed the timeout"),
1410            Err(err) => err,
1411        };
1412        k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1413        k9::assert_equal!(err.message, "MX query timed out after 50ms");
1414        k9::assert_equal!(MX_QUERY_TIMEOUT.get() - before, 1);
1415    }
1416
1417    const EXAMPLE_MX_ZONE: &str = r#"
1418$ORIGIN example.com.
1419@ 86400 MX 10 mx.example.com.
1420"#;
1421
1422    #[tokio::test]
1423    async fn servfail_mx_is_indeterminate_not_implicit_mx() {
1424        // A SERVFAIL on the MX query must be a temporary failure, not treated
1425        // as NODATA (which would synthesise the A/AAAA of the domain as an
1426        // implicit MX and deliver on the back of a failed lookup).
1427        let resolver = TestResolver::default().with_servfail("example.com");
1428        let name = fully_qualify("example.com").unwrap();
1429        let err = lookup_mx_record_limited(
1430            &name,
1431            Some(&resolver),
1432            &Semaphore::new(1),
1433            Duration::from_secs(5),
1434        )
1435        .await
1436        .unwrap_err();
1437        k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1438        k9::assert_equal!(
1439            err.message,
1440            "MX lookup for example.com. returned Server Failure"
1441        );
1442    }
1443
1444    #[tokio::test]
1445    async fn bogus_mx_is_rejected_not_used() {
1446        let name = fully_qualify("example.com").unwrap();
1447
1448        // Control: without the bogus flag the zone resolves to its MX host.
1449        let ok = TestResolver::default().with_zone(EXAMPLE_MX_ZONE).unwrap();
1450        let (records, _) =
1451            lookup_mx_record_limited(&name, Some(&ok), &Semaphore::new(1), Duration::from_secs(5))
1452                .await
1453                .unwrap();
1454        k9::assert_equal!(records[0].hosts, vec!["mx.example.com.".to_string()]);
1455
1456        // The same answer flagged DNSSEC-bogus: the forged MX records must not
1457        // be used, even though they are present.
1458        let resolver = TestResolver::default()
1459            .with_zone(EXAMPLE_MX_ZONE)
1460            .unwrap()
1461            .with_bogus("example.com");
1462        let err = lookup_mx_record_limited(
1463            &name,
1464            Some(&resolver),
1465            &Semaphore::new(1),
1466            Duration::from_secs(5),
1467        )
1468        .await
1469        .unwrap_err();
1470        k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1471        k9::assert_equal!(
1472            err.message,
1473            "MX records for example.com. are bogus: test bogus"
1474        );
1475    }
1476
1477    #[test]
1478    fn negative_ttl_classifies_by_kind() {
1479        // Pins the per-kind mapping of MxResolveFailure::negative_ttl against
1480        // regression.
1481        k9::assert_equal!(
1482            MxResolveFailure::NxDomain.negative_ttl(),
1483            get_mx_negative_ttl()
1484        );
1485        k9::assert_equal!(
1486            MxResolveFailure::PolicyRejected.negative_ttl(),
1487            get_mx_negative_ttl()
1488        );
1489        k9::assert_equal!(
1490            MxResolveFailure::Indeterminate.negative_ttl(),
1491            get_mx_transient_negative_ttl()
1492        );
1493        k9::assert_equal!(
1494            MxResolveFailure::NeverQueried.negative_ttl(),
1495            Duration::ZERO
1496        );
1497    }
1498
1499    /// Build a minimal successful `MailExchanger` for driving the cache
1500    /// directly. Leaves `expires` as `None`, which `mx_cache_ttl` treats as an
1501    /// unbounded success and caches for `get_mx_negative_ttl()`.
1502    fn fresh_mx(domain: &str) -> Arc<MailExchanger> {
1503        Arc::new(MailExchanger {
1504            domain_name: domain.to_string(),
1505            hosts: vec![format!("mx.{domain}.")],
1506            site_name: format!("mx.{domain}"),
1507            by_pref: BTreeMap::new(),
1508            is_domain_literal: false,
1509            is_secure: false,
1510            is_mx: true,
1511            mta_sts: PolicyMode::None,
1512            expires: None,
1513        })
1514    }
1515
1516    #[tokio::test]
1517    async fn indeterminate_failure_cached_then_requeries() {
1518        // Calls get_or_try_insert on MX_CACHE directly, with mx_cache_ttl as
1519        // the real ttl_func, to exercise the negative-TTL selection in
1520        // mx_cache_ttl end to end. nextest runs each test in its own process,
1521        // and the shared MX_CACHE is private to this test.
1522        tokio::time::pause();
1523        let key = (fully_qualify("transient-ttl-test.invalid").unwrap(), None);
1524        let transient = get_mx_transient_negative_ttl();
1525
1526        let first = MX_CACHE
1527            .get_or_try_insert(&key, mx_cache_ttl, async {
1528                Ok::<_, anyhow::Error>(Err(MxResolveError {
1529                    kind: MxResolveFailure::Indeterminate,
1530                    message: "SERVFAIL".to_string(),
1531                }))
1532            })
1533            .await
1534            .unwrap();
1535        assert!(first.is_fresh);
1536        assert!(first.item.is_err());
1537
1538        // Before the transient TTL elapses the cached failure is returned.
1539        tokio::time::advance(transient / 2).await;
1540        let cached = MX_CACHE
1541            .get_or_try_insert(&key, mx_cache_ttl, async {
1542                // Panics if polled, which fails the test if the cache wrongly
1543                // re-queries instead of returning the cached failure.
1544                panic!("must not re-query before the transient TTL elapses");
1545                #[allow(unreachable_code)]
1546                Ok::<Result<Arc<MailExchanger>, MxResolveError>, anyhow::Error>(Ok(fresh_mx(
1547                    "transient-ttl-test.invalid",
1548                )))
1549            })
1550            .await
1551            .unwrap();
1552        assert!(!cached.is_fresh);
1553        assert!(cached.item.is_err());
1554
1555        // Once it elapses the next lookup re-queries and can now succeed.
1556        tokio::time::advance(transient).await;
1557        let refreshed = MX_CACHE
1558            .get_or_try_insert(&key, mx_cache_ttl, async {
1559                Ok::<_, anyhow::Error>(Ok(fresh_mx("transient-ttl-test.invalid")))
1560            })
1561            .await
1562            .unwrap();
1563        assert!(refreshed.is_fresh);
1564        assert!(refreshed.item.is_ok());
1565    }
1566
1567    #[tokio::test]
1568    async fn never_queried_failure_is_not_cached() {
1569        // A NeverQueried failure gets a zero TTL, which is already expired the
1570        // instant it is stored. The next lookup re-queries immediately, with no
1571        // time having to pass.
1572        let key = (fully_qualify("never-queried-test.invalid").unwrap(), None);
1573
1574        let first = MX_CACHE
1575            .get_or_try_insert(&key, mx_cache_ttl, async {
1576                Ok::<_, anyhow::Error>(Err(MxResolveError {
1577                    kind: MxResolveFailure::NeverQueried,
1578                    message: "permit wait timed out".to_string(),
1579                }))
1580            })
1581            .await
1582            .unwrap();
1583        assert!(first.is_fresh);
1584        assert!(first.item.is_err());
1585
1586        let refreshed = MX_CACHE
1587            .get_or_try_insert(&key, mx_cache_ttl, async {
1588                Ok::<_, anyhow::Error>(Ok(fresh_mx("never-queried-test.invalid")))
1589            })
1590            .await
1591            .unwrap();
1592        assert!(refreshed.is_fresh);
1593        assert!(refreshed.item.is_ok());
1594    }
1595
1596    #[tokio::test]
1597    async fn lookup_null_mx() {
1598        let resolver = fixture_resolver(&[r#"
1599$ORIGIN example.com.
1600@ 3600 MX 0 .
1601"#]);
1602        let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
1603            .await
1604            .unwrap())
1605        .clone();
1606        mx.expires.take();
1607        k9::snapshot!(
1608            &mx,
1609            r#"
1610MailExchanger {
1611    domain_name: "example.com.",
1612    hosts: [
1613        ".",
1614    ],
1615    site_name: "",
1616    by_pref: {
1617        0: [
1618            ".",
1619        ],
1620    },
1621    is_domain_literal: false,
1622    is_secure: false,
1623    is_mx: true,
1624    mta_sts: None,
1625    expires: None,
1626}
1627"#
1628        );
1629    }
1630
1631    #[tokio::test]
1632    async fn lookup_single_mx() {
1633        let resolver = fixture_resolver(&[r#"
1634$ORIGIN do.havedane.net.
1635@ 300 MX 10 do.havedane.net.
1636"#]);
1637        let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1638            .await
1639            .unwrap())
1640        .clone();
1641        mx.expires.take();
1642        k9::snapshot!(
1643            &mx,
1644            r#"
1645MailExchanger {
1646    domain_name: "do.havedane.net.",
1647    hosts: [
1648        "do.havedane.net.",
1649    ],
1650    site_name: "do.havedane.net",
1651    by_pref: {
1652        10: [
1653            "do.havedane.net.",
1654        ],
1655    },
1656    is_domain_literal: false,
1657    is_secure: false,
1658    is_mx: true,
1659    mta_sts: None,
1660    expires: None,
1661}
1662"#
1663        );
1664    }
1665
1666    #[tokio::test]
1667    async fn mx_lookup_no_mx_falls_back_to_a() {
1668        // The zone exists (so the lookup is not NXDOMAIN) but has no MX record
1669        // for www, so resolution falls back to the domain's own A record.
1670        let resolver = fixture_resolver(&[r#"
1671$ORIGIN example.com.
1672www 300 A 192.0.2.1
1673"#]);
1674        let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1675            .await
1676            .unwrap())
1677        .clone();
1678        mx.expires.take();
1679        k9::snapshot!(
1680            &mx,
1681            r#"
1682MailExchanger {
1683    domain_name: "www.example.com.",
1684    hosts: [
1685        "www.example.com.",
1686    ],
1687    site_name: "www.example.com",
1688    by_pref: {
1689        1: [
1690            "www.example.com.",
1691        ],
1692    },
1693    is_domain_literal: false,
1694    is_secure: false,
1695    is_mx: false,
1696    mta_sts: None,
1697    expires: None,
1698}
1699"#
1700        );
1701    }
1702}