mailexchanger/
lib.rs

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