dns_resolver/
lib.rs

1use anyhow::Context;
2use arc_swap::ArcSwap;
3pub use hickory_resolver::proto::rr::rdata::tlsa::TLSA;
4pub use hickory_resolver::proto::rr::Name;
5use hickory_resolver::proto::rr::{RData, RecordType};
6use hickory_resolver::proto::ProtoError;
7use kumo_address::host::HostAddress;
8use kumo_address::host_or_socket::HostOrSocketAddress;
9use kumo_address::resolvable::ResolvableSocketAddr;
10use kumo_log_types::ResolvedAddress;
11use kumo_prometheus::declare_metric;
12use lruttl::declare_cache;
13use rand::prelude::SliceRandom;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::net::{IpAddr, Ipv6Addr, SocketAddr};
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::{Arc, LazyLock};
19use std::time::{Duration, Instant};
20use tokio::sync::Semaphore;
21use tokio::time::timeout;
22
23mod resolver;
24#[cfg(feature = "unbound")]
25pub use resolver::UnboundResolver;
26pub use resolver::{
27    ptr_host, reverse_ip, AggregateResolver, DnsError, HickoryResolver, IpDisplay, Resolver,
28    TestResolver,
29};
30
31// An `ArcSwap` can only hold `Sized` types, so we cannot stuff a `dyn Resolver` directly into it.
32// Instead, the documentation recommends adding a level of indirection, so we wrap the `Resolver`
33// trait object in a `Box`. In the context of DNS requests, the additional pointer chasing should
34// not be a significant performance concern.
35static RESOLVER: LazyLock<ArcSwap<Box<dyn Resolver>>> =
36    LazyLock::new(|| ArcSwap::from_pointee(Box::new(default_resolver())));
37
38declare_cache! {
39/// Caches domain name to computed set of MailExchanger records
40static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, String>>::new("dns_resolver_mx", 64 * 1024);
41}
42declare_cache! {
43/// Caches domain name to ipv4 records
44static IPV4_CACHE: LruCacheWithTtl<Name, Arc<Vec<IpAddr>>>::new("dns_resolver_ipv4", 1024);
45}
46declare_cache! {
47/// Caches domain name to ipv6 records
48static IPV6_CACHE: LruCacheWithTtl<Name, Arc<Vec<IpAddr>>>::new("dns_resolver_ipv6", 1024);
49}
50declare_cache! {
51/// Caches domain name to the combined set of ipv4 and ipv6 records
52static IP_CACHE: LruCacheWithTtl<(Name, IpLookupStrategy), Arc<Vec<IpAddr>>>::new("dns_resolver_ip", 1024);
53}
54
55/// Maximum number of concurrent mx resolves permitted
56static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
57static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
58    LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
59
60/// 5 seconds in ms
61static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
62
63/// 5 minutes in ms
64static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
65
66declare_metric! {
67/// number of `MailExchanger::resolve` calls currently in progress.
68static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
69}
70
71declare_metric! {
72/// Total number of successful `MailExchanger::resolve` calls
73static MX_SUCCESS: IntCounter(
74        "dns_mx_resolve_status_ok");
75}
76
77declare_metric! {
78/// Total number of failed `MailExchanger::resolve` calls.
79///
80/// Spikes may indicate an issue with your DNS configuration
81/// or infrastructure, or may simply indicate that the traffic
82/// is destined for bogus addresses.
83static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
84}
85
86declare_metric! {
87/// Total number of MailExchanger::resolve calls satisfied by level 1 cache.
88///
89/// Redundant with the newer [lruttl_hit_count{cache_name="dns_resolver_mx"}](lruttl_hit_count.md)
90/// metric.
91static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
92}
93
94declare_metric! {
95/// Total number of MailExchanger::resolve calls that resulted in an MX DNS request to the next level of cache
96///
97/// Redundant with the newer [lruttl_miss_count{cache_name="dns_resolver_mx"}](lruttl_miss_count.md)
98/// metric.
99static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
100}
101
102fn default_resolver() -> impl Resolver {
103    #[cfg(feature = "default-unbound")]
104    return UnboundResolver::new().unwrap();
105    #[cfg(not(feature = "default-unbound"))]
106    return HickoryResolver::new().expect("Parsing /etc/resolv.conf failed");
107}
108
109pub fn set_mx_concurrency_limit(n: usize) {
110    MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
111}
112
113pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
114    let ms = duration
115        .as_millis()
116        .try_into()
117        .context("set_mx_timeout: duration is too large")?;
118    MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
119    Ok(())
120}
121
122pub fn get_mx_timeout() -> Duration {
123    Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
124}
125
126pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
127    let ms = duration
128        .as_millis()
129        .try_into()
130        .context("set_mx_negative_cache_ttl: duration is too large")?;
131    MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
132    Ok(())
133}
134
135pub fn get_mx_negative_ttl() -> Duration {
136    Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
137}
138
139#[derive(Clone, Debug, Serialize)]
140pub struct MailExchanger {
141    pub domain_name: String,
142    pub hosts: Vec<String>,
143    pub site_name: String,
144    pub by_pref: BTreeMap<u16, Vec<String>>,
145    pub is_domain_literal: bool,
146    /// DNSSEC verified
147    pub is_secure: bool,
148    pub is_mx: bool,
149    #[serde(skip)]
150    expires: Option<Instant>,
151}
152
153pub fn fully_qualify(domain_name: &str) -> Result<Name, ProtoError> {
154    let mut name = Name::from_str_relaxed(domain_name)?.to_lowercase();
155
156    // Treat it as fully qualified
157    name.set_fqdn(true);
158
159    Ok(name)
160}
161
162pub fn reconfigure_resolver(resolver: impl Resolver) {
163    RESOLVER.store(Arc::new(Box::new(resolver)));
164}
165
166pub fn get_resolver() -> Arc<Box<dyn Resolver>> {
167    RESOLVER.load_full()
168}
169
170/// Resolves TLSA records for a destination name and port according to
171/// <https://datatracker.ietf.org/doc/html/rfc6698#appendix-B.2>
172pub async fn resolve_dane(hostname: &str, port: u16) -> anyhow::Result<Vec<TLSA>> {
173    let name = fully_qualify(&format!("_{port}._tcp.{hostname}"))?;
174    let answer = RESOLVER.load().resolve(name, RecordType::TLSA).await?;
175    tracing::debug!("resolve_dane {hostname}:{port} TLSA answer is: {answer:?}");
176
177    if answer.bogus {
178        // Bogus records are either tampered with, or due to misconfiguration
179        // of the local resolver
180        anyhow::bail!(
181            "DANE result for {hostname}:{port} unusable because: {}",
182            answer
183                .why_bogus
184                .as_deref()
185                .unwrap_or("DNSSEC validation failed")
186        );
187    }
188
189    let mut result = vec![];
190    // We ignore TLSA records unless they are validated; in other words,
191    // we'll return an empty list (without raising an error) if the resolver
192    // is not configured to verify DNSSEC
193    if answer.secure {
194        for r in &answer.records {
195            if let RData::TLSA(tlsa) = r {
196                result.push(tlsa.clone());
197            }
198        }
199        // DNS results are unordered. For the sake of tests,
200        // sort these records.
201        // Unfortunately, the TLSA type is nor Ord so we
202        // convert to string and order by that, which is a bit
203        // wasteful but the cardinality of TLSA records is
204        // generally low
205        result.sort_by_key(|a| a.to_string());
206    }
207
208    tracing::info!("resolve_dane {hostname}:{port} result is: {result:?}");
209
210    Ok(result)
211}
212
213/// If the provided parameter ends with `:PORT` and `PORT` is a valid u16,
214/// then crack apart and return the LABEL and PORT number portions.
215/// Otherwise, returns None
216pub fn has_colon_port(a: &str) -> Option<(&str, u16)> {
217    let (label, maybe_port) = a.rsplit_once(':')?;
218
219    // v6 addresses can look like `::1` and confuse us. Try not
220    // to be confused here
221    if label.contains(':') {
222        return None;
223    }
224
225    let port = maybe_port.parse::<u16>().ok()?;
226    Some((label, port))
227}
228
229/// Helper to reason about a domain name string.
230/// It can either be name that needs to be resolved, or some kind
231/// of IP literal.
232/// We also allow for an optional port number to be present in
233/// the domain name string.
234pub enum DomainClassification {
235    /// A DNS Name pending resolution, plus an optional port number
236    Domain(Name, Option<u16>),
237    /// A literal IP address (no port), or socket address (with port)
238    Literal(HostOrSocketAddress),
239}
240
241impl DomainClassification {
242    pub fn classify(domain_name: &str) -> anyhow::Result<Self> {
243        let (domain_name, mut opt_port) = match has_colon_port(domain_name) {
244            Some((domain_name, port)) => (domain_name, Some(port)),
245            None => (domain_name, None),
246        };
247
248        if domain_name.starts_with('[') {
249            if !domain_name.ends_with(']') {
250                anyhow::bail!(
251                    "domain_name `{domain_name}` is a malformed literal \
252                     domain with no trailing `]`"
253                );
254            }
255
256            let lowered = domain_name.to_ascii_lowercase();
257            let literal = &lowered[1..lowered.len() - 1];
258
259            let literal = match has_colon_port(literal) {
260                Some((_, _)) if opt_port.is_some() => {
261                    anyhow::bail!("invalid address: `{domain_name}` specifies a port both inside and outside a literal address enclosed in square brackets");
262                }
263                Some((literal, port)) => {
264                    opt_port.replace(port);
265                    literal
266                }
267                None => literal,
268            };
269
270            if let Some(v6_literal) = literal.strip_prefix("ipv6:") {
271                match v6_literal.parse::<Ipv6Addr>() {
272                    Ok(addr) => {
273                        let mut host_addr: HostOrSocketAddress = addr.into();
274                        if let Some(port) = opt_port {
275                            host_addr.set_port(port);
276                        }
277                        return Ok(Self::Literal(host_addr));
278                    }
279                    Err(err) => {
280                        anyhow::bail!("invalid ipv6 address: `{v6_literal}`: {err:#}");
281                    }
282                }
283            }
284
285            // Try to interpret the literal as either an IPv4 or IPv6 address.
286            // Note that RFC5321 doesn't actually permit using an untagged
287            // IPv6 address, so this is non-conforming behavior.
288            match literal.parse::<IpAddr>() {
289                Ok(ip_addr) => {
290                    let mut host_addr: HostOrSocketAddress = ip_addr.into();
291                    if let Some(port) = opt_port {
292                        host_addr.set_port(port);
293                    }
294                    return Ok(Self::Literal(host_addr));
295                }
296                Err(err) => {
297                    anyhow::bail!("invalid address: `{literal}`: {err:#}");
298                }
299            }
300        }
301
302        let name_fq = fully_qualify(domain_name)?;
303        Ok(Self::Domain(name_fq, opt_port))
304    }
305
306    pub fn has_port(&self) -> bool {
307        match self {
308            Self::Domain(_, Some(_)) => true,
309            Self::Domain(_, None) => false,
310            Self::Literal(addr) => addr.port().is_some(),
311        }
312    }
313}
314
315pub async fn resolve_a_or_aaaa(
316    domain_name: &str,
317    resolver: Option<&dyn Resolver>,
318    strategy: IpLookupStrategy,
319) -> anyhow::Result<Vec<ResolvedAddress>> {
320    if domain_name.starts_with('[') {
321        // It's a literal address, no DNS lookup necessary
322
323        if !domain_name.ends_with(']') {
324            anyhow::bail!(
325                "domain_name `{domain_name}` is a malformed literal \
326                     domain with no trailing `]`"
327            );
328        }
329
330        let lowered = domain_name.to_ascii_lowercase();
331        let literal = &lowered[1..lowered.len() - 1];
332
333        if let Some(v6_literal) = literal.strip_prefix("ipv6:") {
334            match v6_literal.parse::<Ipv6Addr>() {
335                Ok(addr) => {
336                    return Ok(vec![ResolvedAddress {
337                        name: domain_name.to_string(),
338                        addr: std::net::IpAddr::V6(addr).into(),
339                    }]);
340                }
341                Err(err) => {
342                    anyhow::bail!("invalid ipv6 address: `{v6_literal}`: {err:#}");
343                }
344            }
345        }
346
347        // Try to interpret the literal as either an IPv4 or IPv6 address.
348        // Note that RFC5321 doesn't actually permit using an untagged
349        // IPv6 address, so this is non-conforming behavior.
350        match literal.parse::<HostAddress>() {
351            Ok(addr) => {
352                return Ok(vec![ResolvedAddress {
353                    name: domain_name.to_string(),
354                    addr: addr.into(),
355                }]);
356            }
357            Err(err) => {
358                anyhow::bail!("invalid address: `{literal}`: {err:#}");
359            }
360        }
361    } else {
362        // Maybe its a unix domain socket path
363        if let Ok(addr) = domain_name.parse::<HostAddress>() {
364            return Ok(vec![ResolvedAddress {
365                name: domain_name.to_string(),
366                addr: addr.into(),
367            }]);
368        }
369    }
370
371    match ip_lookup(domain_name, resolver, strategy).await {
372        Ok((addrs, _expires)) => {
373            let addrs = addrs
374                .iter()
375                .map(|&addr| ResolvedAddress {
376                    name: domain_name.to_string(),
377                    addr: addr.into(),
378                })
379                .collect();
380            Ok(addrs)
381        }
382        Err(err) => anyhow::bail!("{err:#}"),
383    }
384}
385
386/// Resolve a [`ResolvableSocketAddr`] to a list of concrete [`ResolvedAddress`]es.
387///
388/// For `UnixDomain`, `V4` and `V6` arms, no DNS lookup is performed; a single
389/// `ResolvedAddress` is returned whose `addr` carries the supplied port (for
390/// the IP arms). For the `Hostname` arm, an A/AAAA lookup is performed via
391/// [`ip_lookup`] and each returned IP is paired with the supplied port.
392pub async fn resolve_socket_addr(
393    addr: &ResolvableSocketAddr,
394    resolver: Option<&dyn Resolver>,
395    strategy: IpLookupStrategy,
396) -> anyhow::Result<Vec<ResolvedAddress>> {
397    match addr {
398        ResolvableSocketAddr::UnixDomain(unix) => {
399            let name = match unix.as_pathname() {
400                Some(path) => path.display().to_string(),
401                None => "<unbound unix domain>".to_string(),
402            };
403            Ok(vec![ResolvedAddress {
404                name,
405                addr: HostOrSocketAddress::UnixDomain(unix.clone()),
406            }])
407        }
408        ResolvableSocketAddr::V4(sa) => Ok(vec![ResolvedAddress {
409            name: sa.to_string(),
410            addr: HostOrSocketAddress::V4Socket(sa.clone()),
411        }]),
412        ResolvableSocketAddr::V6(sa) => Ok(vec![ResolvedAddress {
413            name: sa.to_string(),
414            addr: HostOrSocketAddress::V6Socket(sa.clone()),
415        }]),
416        ResolvableSocketAddr::Hostname { host, port } => {
417            match ip_lookup(host, resolver, strategy).await {
418                Ok((addrs, _expires)) => Ok(addrs
419                    .iter()
420                    .map(|&ip| {
421                        let sa = SocketAddr::new(ip, *port);
422                        ResolvedAddress {
423                            name: host.clone(),
424                            addr: sa.into(),
425                        }
426                    })
427                    .collect()),
428                Err(err) => anyhow::bail!("{err:#}"),
429            }
430        }
431    }
432}
433
434impl MailExchanger {
435    pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
436        MX_IN_PROGRESS.inc();
437        let result = Self::resolve_impl(domain_name).await;
438        MX_IN_PROGRESS.dec();
439        if result.is_ok() {
440            MX_SUCCESS.inc();
441        } else {
442            MX_FAIL.inc();
443        }
444        result
445    }
446
447    async fn resolve_impl(domain_name: &str) -> anyhow::Result<Arc<Self>> {
448        let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
449            DomainClassification::Literal(addr) => {
450                let mut by_pref = BTreeMap::new();
451                by_pref.insert(1, vec![addr.to_string()]);
452                return Ok(Arc::new(Self {
453                    domain_name: domain_name.to_string(),
454                    hosts: vec![addr.to_string()],
455                    site_name: addr.to_string(),
456                    by_pref,
457                    is_domain_literal: true,
458                    is_secure: false,
459                    is_mx: false,
460                    expires: None,
461                }));
462            }
463            DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
464        };
465
466        let lookup_result = MX_CACHE
467            .get_or_try_insert(
468                &(name_fq.clone(), opt_port),
469                |mx_result| {
470                    if let Ok(mx) = mx_result {
471                        if let Some(exp) = mx.expires {
472                            return exp
473                                .checked_duration_since(std::time::Instant::now())
474                                .unwrap_or_else(|| Duration::from_secs(10));
475                        }
476                    }
477                    get_mx_negative_ttl()
478                },
479                async {
480                    MX_QUERIES.inc();
481                    let start = Instant::now();
482                    let (mut by_pref, expires) = match lookup_mx_record(&name_fq).await {
483                        Ok((by_pref, expires)) => (by_pref, expires),
484                        Err(err) => {
485                            let error = format!(
486                                "MX lookup for {domain_name} failed after {elapsed:?}: {err:#}",
487                                elapsed = start.elapsed()
488                            );
489                            return Ok::<Result<Arc<MailExchanger>, String>, anyhow::Error>(Err(
490                                error,
491                            ));
492                        }
493                    };
494
495                    let mut hosts = vec![];
496                    for pref in &mut by_pref {
497                        for host in &mut pref.hosts {
498                            if let Some(port) = opt_port {
499                                *host = format!("{host}:{port}");
500                            };
501                            hosts.push(host.to_string());
502                        }
503                    }
504
505                    let is_secure = by_pref.iter().all(|p| p.is_secure);
506                    let is_mx = by_pref.iter().all(|p| p.is_mx);
507
508                    let by_pref = by_pref
509                        .into_iter()
510                        .map(|pref| (pref.pref, pref.hosts))
511                        .collect();
512
513                    let site_name = factor_names(&hosts);
514                    let mx = Self {
515                        hosts,
516                        domain_name: name_fq.to_ascii(),
517                        site_name,
518                        by_pref,
519                        is_domain_literal: false,
520                        is_secure,
521                        is_mx,
522                        expires: Some(expires),
523                    };
524
525                    Ok(Ok(Arc::new(mx)))
526                },
527            )
528            .await
529            .map_err(|err| anyhow::anyhow!("{err}"))?;
530
531        if !lookup_result.is_fresh {
532            MX_CACHED.inc();
533        }
534
535        lookup_result.item.map_err(|err| anyhow::anyhow!("{err}"))
536    }
537
538    pub fn has_expired(&self) -> bool {
539        match self.expires {
540            Some(deadline) => deadline <= Instant::now(),
541            None => false,
542        }
543    }
544
545    /// Returns the list of resolve MX hosts in *reverse* preference
546    /// order; the first one to try is the last element.
547    /// smtp_dispatcher.rs relies on this ordering, as it will pop
548    /// off candidates until it has exhausted its connection plan.
549    pub async fn resolve_addresses(&self, strategy: IpLookupStrategy) -> ResolvedMxAddresses {
550        let mut result = vec![];
551
552        for hosts in self.by_pref.values().rev() {
553            let mut by_pref = vec![];
554
555            for mx_host in hosts {
556                // '.' is a null mx; skip trying to resolve it
557                if mx_host == "." {
558                    return ResolvedMxAddresses::NullMx;
559                }
560
561                // Handle the literal address case
562                let (mx_host, opt_port) = match has_colon_port(mx_host) {
563                    Some((domain_name, port)) => (domain_name, Some(port)),
564                    None => (mx_host.as_str(), None),
565                };
566                if let Ok(addr) = mx_host.parse::<IpAddr>() {
567                    let mut addr: HostOrSocketAddress = addr.into();
568                    if let Some(port) = opt_port {
569                        addr.set_port(port);
570                    }
571                    by_pref.push(ResolvedAddress {
572                        name: mx_host.to_string(),
573                        addr: addr.into(),
574                    });
575                    continue;
576                }
577
578                match ip_lookup(mx_host, None, strategy).await {
579                    Err(err) => {
580                        tracing::error!("failed to resolve {mx_host}: {err:#}");
581                        continue;
582                    }
583                    Ok((addresses, _expires)) => {
584                        for addr in addresses.iter() {
585                            let mut addr: HostOrSocketAddress = (*addr).into();
586                            if let Some(port) = opt_port {
587                                addr.set_port(port);
588                            }
589                            by_pref.push(ResolvedAddress {
590                                name: mx_host.to_string(),
591                                addr,
592                            });
593                        }
594                    }
595                }
596            }
597
598            // Randomize the list of addresses within this preference
599            // level. This probablistically "load balances" outgoing
600            // traffic across MX hosts with equal preference value.
601            let mut rng = rand::thread_rng();
602            by_pref.shuffle(&mut rng);
603            result.append(&mut by_pref);
604        }
605        ResolvedMxAddresses::Addresses(result)
606    }
607}
608
609#[derive(Debug, Clone, Serialize)]
610pub enum ResolvedMxAddresses {
611    NullMx,
612    /// The list of addresses to which to connect, expressed
613    /// in LIFO order
614    Addresses(Vec<ResolvedAddress>),
615}
616
617struct ByPreference {
618    hosts: Vec<String>,
619    pref: u16,
620    is_secure: bool,
621    is_mx: bool,
622}
623
624async fn lookup_mx_record(domain_name: &Name) -> anyhow::Result<(Vec<ByPreference>, Instant)> {
625    let mx_lookup = timeout(get_mx_timeout(), async {
626        let _permit = MX_CONCURRENCY_SEMA.acquire().await;
627        RESOLVER
628            .load()
629            .resolve(domain_name.clone(), RecordType::MX)
630            .await
631    })
632    .await??;
633    let mx_records = mx_lookup.records;
634
635    if mx_records.is_empty() {
636        if mx_lookup.nxdomain {
637            anyhow::bail!("NXDOMAIN");
638        }
639
640        return Ok((
641            vec![ByPreference {
642                hosts: vec![domain_name.to_lowercase().to_ascii()],
643                pref: 1,
644                is_secure: false,
645                is_mx: false,
646            }],
647            mx_lookup.expires,
648        ));
649    }
650
651    let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
652
653    for mx_record in mx_records {
654        if let RData::MX(mx) = mx_record {
655            let pref = mx.preference;
656            let host = mx.exchange.to_lowercase().to_string();
657
658            if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
659                record.hosts.push(host);
660            } else {
661                records.push(ByPreference {
662                    hosts: vec![host],
663                    pref,
664                    is_secure: mx_lookup.secure,
665                    is_mx: true,
666                });
667            }
668        }
669    }
670
671    // Sort by preference
672    records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
673
674    // Sort the hosts at each preference level to produce the
675    // overall ordered list of hosts for this site
676    for mx in &mut records {
677        mx.hosts.sort();
678    }
679
680    Ok((records, mx_lookup.expires))
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
684#[repr(u8)]
685pub enum IpLookupStrategy {
686    /// Only query for A (Ipv4) records
687    Ipv4Only,
688    /// Only query for AAAA (Ipv6) records
689    Ipv6Only,
690    /// Query for A and AAAA in parallel
691    #[default]
692    Ipv4AndIpv6,
693    /// Query for Ipv6 if that fails, query for Ipv4
694    Ipv6ThenIpv4,
695    /// Query for Ipv4 if that fails, query for Ipv6 (default)
696    Ipv4ThenIpv6,
697}
698
699pub async fn ip_lookup(
700    key: &str,
701    resolver: Option<&dyn Resolver>,
702    strategy: IpLookupStrategy,
703) -> anyhow::Result<(Arc<Vec<IpAddr>>, Instant)> {
704    let key_fq = fully_qualify(key)?;
705
706    if resolver.is_none() {
707        if let Some(lookup) = IP_CACHE.lookup(&(key_fq.clone(), strategy)) {
708            return Ok((lookup.item, lookup.expiration.into()));
709        }
710    }
711
712    let (v4, v6) = match strategy {
713        IpLookupStrategy::Ipv4AndIpv6 => {
714            let (v4, v6) = tokio::join!(ipv4_lookup(key, resolver), ipv6_lookup(key, resolver));
715            (Some(v4), Some(v6))
716        }
717        IpLookupStrategy::Ipv4Only => (Some(ipv4_lookup(key, resolver).await), None),
718        IpLookupStrategy::Ipv6Only => (None, Some(ipv6_lookup(key, resolver).await)),
719        IpLookupStrategy::Ipv6ThenIpv4 => {
720            let v6 = ipv6_lookup(key, resolver).await;
721            match v6 {
722                Ok((addrs, exp)) if addrs.is_empty() => (
723                    Some(ipv4_lookup(key, resolver).await),
724                    Some(Ok((addrs, exp))),
725                ),
726                Err(err) => (Some(ipv4_lookup(key, resolver).await), Some(Err(err))),
727                Ok(res) => (None, Some(Ok(res))),
728            }
729        }
730        IpLookupStrategy::Ipv4ThenIpv6 => {
731            let v4 = ipv4_lookup(key, resolver).await;
732            match v4 {
733                Ok((addrs, exp)) if addrs.is_empty() => (
734                    Some(Ok((addrs, exp))),
735                    Some(ipv6_lookup(key, resolver).await),
736                ),
737                Err(err) => (Some(Err(err)), Some(ipv6_lookup(key, resolver).await)),
738                Ok(res) => (Some(Ok(res)), None),
739            }
740        }
741    };
742
743    let mut results = vec![];
744    let mut errors = vec![];
745    let mut expires = None;
746
747    match v4 {
748        Some(Ok((addrs, exp))) => {
749            expires.replace(exp);
750            for a in addrs.iter() {
751                results.push(*a);
752            }
753        }
754        Some(Err(err)) => errors.push(err),
755        None => {}
756    }
757
758    match v6 {
759        Some(Ok((addrs, exp))) => {
760            let exp = match expires.take() {
761                Some(existing) => exp.min(existing),
762                None => exp,
763            };
764            expires.replace(exp);
765
766            for a in addrs.iter() {
767                results.push(*a);
768            }
769        }
770        Some(Err(err)) => errors.push(err),
771        None => {}
772    }
773
774    if results.is_empty() && !errors.is_empty() {
775        return Err(errors.remove(0));
776    }
777
778    let addr = Arc::new(results);
779    let exp = expires.take().unwrap_or_else(Instant::now);
780
781    if resolver.is_none() {
782        IP_CACHE
783            .insert((key_fq, strategy), addr.clone(), exp.into())
784            .await;
785    }
786    Ok((addr, exp))
787}
788
789pub async fn ipv4_lookup(
790    key: &str,
791    resolver: Option<&dyn Resolver>,
792) -> anyhow::Result<(Arc<Vec<IpAddr>>, Instant)> {
793    let key_fq = fully_qualify(key)?;
794    if resolver.is_none() {
795        if let Some(lookup) = IPV4_CACHE.lookup(&key_fq) {
796            return Ok((lookup.item, lookup.expiration.into()));
797        }
798    }
799
800    let answer = match resolver {
801        Some(r) => r.resolve(key_fq.clone(), RecordType::A).await?,
802        None => {
803            RESOLVER
804                .load()
805                .resolve(key_fq.clone(), RecordType::A)
806                .await?
807        }
808    };
809    let ips = answer.as_addr();
810
811    let ips = Arc::new(ips);
812    let expires = answer.expires;
813    if resolver.is_none() {
814        IPV4_CACHE.insert(key_fq, ips.clone(), expires.into()).await;
815    }
816    Ok((ips, expires))
817}
818
819pub async fn ipv6_lookup(
820    key: &str,
821    resolver: Option<&dyn Resolver>,
822) -> anyhow::Result<(Arc<Vec<IpAddr>>, Instant)> {
823    let key_fq = fully_qualify(key)?;
824    if resolver.is_none() {
825        if let Some(lookup) = IPV6_CACHE.lookup(&key_fq) {
826            return Ok((lookup.item, lookup.expiration.into()));
827        }
828    }
829
830    let answer = match resolver {
831        Some(r) => r.resolve(key_fq.clone(), RecordType::AAAA).await?,
832        None => {
833            RESOLVER
834                .load()
835                .resolve(key_fq.clone(), RecordType::AAAA)
836                .await?
837        }
838    };
839    let ips = answer.as_addr();
840
841    let ips = Arc::new(ips);
842    let expires = answer.expires;
843    if resolver.is_none() {
844        IPV6_CACHE.insert(key_fq, ips.clone(), expires.into()).await;
845    }
846    Ok((ips, expires))
847}
848
849/// Given a list of host names, produce a pseudo-regex style alternation list
850/// of the different elements of the hostnames.
851/// The goal is to produce a more compact representation of the name list
852/// with the common components factored out.
853fn factor_names<S: AsRef<str>>(name_strings: &[S]) -> String {
854    let mut max_element_count = 0;
855
856    let mut names = vec![];
857
858    for name in name_strings {
859        let (name, opt_port) = match has_colon_port(name.as_ref()) {
860            Some((name, port)) => (name, Some(port)),
861            None => (name.as_ref(), None),
862        };
863        if let Ok(name) = fully_qualify(name) {
864            names.push((name.to_lowercase(), opt_port));
865        }
866    }
867
868    let mut elements: Vec<Vec<&str>> = vec![];
869
870    let mut split_names = vec![];
871    for (name, opt_port) in names {
872        let mut fields: Vec<_> = name
873            .iter()
874            .map(|s| String::from_utf8_lossy(s).to_string())
875            .collect();
876        if let Some(port) = opt_port {
877            fields.last_mut().map(|s| {
878                s.push_str(&format!(":{port}"));
879            });
880        }
881        fields.reverse();
882        max_element_count = max_element_count.max(fields.len());
883        split_names.push(fields);
884    }
885
886    fn add_element<'a>(elements: &mut Vec<Vec<&'a str>>, field: &'a str, i: usize) {
887        match elements.get_mut(i) {
888            Some(ele) => {
889                if !ele.contains(&field) {
890                    ele.push(field);
891                }
892            }
893            None => {
894                elements.push(vec![field]);
895            }
896        }
897    }
898
899    for fields in &split_names {
900        for (i, field) in fields.iter().enumerate() {
901            add_element(&mut elements, field, i);
902        }
903        for i in fields.len()..max_element_count {
904            add_element(&mut elements, "?", i);
905        }
906    }
907
908    let mut result = vec![];
909    for mut ele in elements {
910        let has_q = ele.contains(&"?");
911        ele.retain(|&e| e != "?");
912        let mut item_text = if ele.len() == 1 {
913            ele[0].to_string()
914        } else {
915            format!("({})", ele.join("|"))
916        };
917        if has_q {
918            item_text.push('?');
919        }
920        result.push(item_text);
921    }
922    result.reverse();
923
924    result.join(".")
925}
926
927#[cfg(test)]
928mod test {
929    use super::*;
930
931    #[tokio::test]
932    async fn literal_resolve() {
933        let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
934        k9::snapshot!(
935            &v4_loopback,
936            r#"
937MailExchanger {
938    domain_name: "[127.0.0.1]",
939    hosts: [
940        "127.0.0.1",
941    ],
942    site_name: "127.0.0.1",
943    by_pref: {
944        1: [
945            "127.0.0.1",
946        ],
947    },
948    is_domain_literal: true,
949    is_secure: false,
950    is_mx: false,
951    expires: None,
952}
953"#
954        );
955        k9::snapshot!(
956            v4_loopback
957                .resolve_addresses(IpLookupStrategy::default())
958                .await,
959            r#"
960Addresses(
961    [
962        ResolvedAddress {
963            name: "127.0.0.1",
964            addr: 127.0.0.1,
965        },
966    ],
967)
968"#
969        );
970
971        let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
972        k9::snapshot!(
973            &v6_loopback_non_conforming,
974            r#"
975MailExchanger {
976    domain_name: "[::1]",
977    hosts: [
978        "::1",
979    ],
980    site_name: "::1",
981    by_pref: {
982        1: [
983            "::1",
984        ],
985    },
986    is_domain_literal: true,
987    is_secure: false,
988    is_mx: false,
989    expires: None,
990}
991"#
992        );
993        k9::snapshot!(
994            v6_loopback_non_conforming
995                .resolve_addresses(IpLookupStrategy::default())
996                .await,
997            r#"
998Addresses(
999    [
1000        ResolvedAddress {
1001            name: "::1",
1002            addr: ::1,
1003        },
1004    ],
1005)
1006"#
1007        );
1008
1009        let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
1010        k9::snapshot!(
1011            &v6_loopback,
1012            r#"
1013MailExchanger {
1014    domain_name: "[IPv6:::1]",
1015    hosts: [
1016        "::1",
1017    ],
1018    site_name: "::1",
1019    by_pref: {
1020        1: [
1021            "::1",
1022        ],
1023    },
1024    is_domain_literal: true,
1025    is_secure: false,
1026    is_mx: false,
1027    expires: None,
1028}
1029"#
1030        );
1031        k9::snapshot!(
1032            v6_loopback
1033                .resolve_addresses(IpLookupStrategy::default())
1034                .await,
1035            r#"
1036Addresses(
1037    [
1038        ResolvedAddress {
1039            name: "::1",
1040            addr: ::1,
1041        },
1042    ],
1043)
1044"#
1045        );
1046    }
1047
1048    #[test]
1049    fn name_factoring() {
1050        assert_eq!(
1051            factor_names(&[
1052                "mta5.am0.yahoodns.net",
1053                "mta6.am0.yahoodns.net",
1054                "mta7.am0.yahoodns.net"
1055            ]),
1056            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1057        );
1058
1059        // Verify that the case is normalized to lowercase
1060        assert_eq!(
1061            factor_names(&[
1062                "mta5.AM0.yahoodns.net",
1063                "mta6.am0.yAHOodns.net",
1064                "mta7.am0.yahoodns.net"
1065            ]),
1066            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1067        );
1068
1069        // When the names have mismatched lengths, do we produce
1070        // something reasonable?
1071        assert_eq!(
1072            factor_names(&[
1073                "gmail-smtp-in.l.google.com",
1074                "alt1.gmail-smtp-in.l.google.com",
1075                "alt2.gmail-smtp-in.l.google.com",
1076                "alt3.gmail-smtp-in.l.google.com",
1077                "alt4.gmail-smtp-in.l.google.com",
1078            ]),
1079            "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com".to_string()
1080        );
1081
1082        assert_eq!(
1083            factor_names(&[
1084                "mta5.am0.yahoodns.net:123",
1085                "mta6.am0.yahoodns.net:123",
1086                "mta7.am0.yahoodns.net:123"
1087            ]),
1088            "(mta5|mta6|mta7).am0.yahoodns.net:123".to_string()
1089        );
1090        assert_eq!(
1091            factor_names(&[
1092                "mta5.am0.yahoodns.net:123",
1093                "mta6.am0.yahoodns.net:456",
1094                "mta7.am0.yahoodns.net:123"
1095            ]),
1096            "(mta5|mta6|mta7).am0.yahoodns.(net:123|net:456)".to_string()
1097        );
1098    }
1099
1100    /// Verify that the order is preserved and that we treat these two
1101    /// examples of differently ordered sets of the same names as two
1102    /// separate site name strings
1103    #[test]
1104    fn mx_order_name_factor() {
1105        assert_eq!(
1106            factor_names(&[
1107                "example-com.mail.protection.outlook.com.",
1108                "mx-biz.mail.am0.yahoodns.net.",
1109                "mx-biz.mail.am0.yahoodns.net.",
1110            ]),
1111            "(example-com|mx-biz).mail.(protection|am0).(outlook|yahoodns).(com|net)".to_string()
1112        );
1113        assert_eq!(
1114            factor_names(&[
1115                "mx-biz.mail.am0.yahoodns.net.",
1116                "mx-biz.mail.am0.yahoodns.net.",
1117                "example-com.mail.protection.outlook.com.",
1118            ]),
1119            "(mx-biz|example-com).mail.(am0|protection).(yahoodns|outlook).(net|com)".to_string()
1120        );
1121    }
1122
1123    #[cfg(feature = "live-dns-tests")]
1124    #[tokio::test]
1125    async fn lookup_gmail_mx() {
1126        let mut gmail = (*MailExchanger::resolve("gmail.com").await.unwrap()).clone();
1127        gmail.expires.take();
1128        k9::snapshot!(
1129            &gmail,
1130            r#"
1131MailExchanger {
1132    domain_name: "gmail.com.",
1133    hosts: [
1134        "gmail-smtp-in.l.google.com.",
1135        "alt1.gmail-smtp-in.l.google.com.",
1136        "alt2.gmail-smtp-in.l.google.com.",
1137        "alt3.gmail-smtp-in.l.google.com.",
1138        "alt4.gmail-smtp-in.l.google.com.",
1139    ],
1140    site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
1141    by_pref: {
1142        5: [
1143            "gmail-smtp-in.l.google.com.",
1144        ],
1145        10: [
1146            "alt1.gmail-smtp-in.l.google.com.",
1147        ],
1148        20: [
1149            "alt2.gmail-smtp-in.l.google.com.",
1150        ],
1151        30: [
1152            "alt3.gmail-smtp-in.l.google.com.",
1153        ],
1154        40: [
1155            "alt4.gmail-smtp-in.l.google.com.",
1156        ],
1157    },
1158    is_domain_literal: false,
1159    is_secure: false,
1160    is_mx: true,
1161    expires: None,
1162}
1163"#
1164        );
1165
1166        // This is a bad thing to have in a snapshot test really,
1167        // but the whole set of live-dns-tests are already inherently
1168        // unstable and flakey anyway.
1169        // The main thing we expect to see here is that the list of
1170        // names starts with alt4 and goes backwards through the priority
1171        // order such that the last element is gmail-smtp.
1172        // We expect the addresses within a given preference level to
1173        // be randomized, because that is what resolve_addresses does.
1174        k9::snapshot!(
1175            gmail.resolve_addresses(IpLookupStrategy::default()).await,
1176            r#"
1177Addresses(
1178    [
1179        ResolvedAddress {
1180            name: "alt4.gmail-smtp-in.l.google.com.",
1181            addr: 2607:f8b0:4023:401::1b,
1182        },
1183        ResolvedAddress {
1184            name: "alt4.gmail-smtp-in.l.google.com.",
1185            addr: 173.194.77.27,
1186        },
1187        ResolvedAddress {
1188            name: "alt3.gmail-smtp-in.l.google.com.",
1189            addr: 2607:f8b0:4023:1::1a,
1190        },
1191        ResolvedAddress {
1192            name: "alt3.gmail-smtp-in.l.google.com.",
1193            addr: 172.253.113.26,
1194        },
1195        ResolvedAddress {
1196            name: "alt2.gmail-smtp-in.l.google.com.",
1197            addr: 2607:f8b0:4001:c1d::1b,
1198        },
1199        ResolvedAddress {
1200            name: "alt2.gmail-smtp-in.l.google.com.",
1201            addr: 74.125.126.27,
1202        },
1203        ResolvedAddress {
1204            name: "alt1.gmail-smtp-in.l.google.com.",
1205            addr: 2607:f8b0:4003:c04::1b,
1206        },
1207        ResolvedAddress {
1208            name: "alt1.gmail-smtp-in.l.google.com.",
1209            addr: 108.177.104.27,
1210        },
1211        ResolvedAddress {
1212            name: "gmail-smtp-in.l.google.com.",
1213            addr: 2607:f8b0:4023:c06::1b,
1214        },
1215        ResolvedAddress {
1216            name: "gmail-smtp-in.l.google.com.",
1217            addr: 142.251.2.26,
1218        },
1219    ],
1220)
1221"#
1222        );
1223    }
1224
1225    #[cfg(feature = "live-dns-tests")]
1226    #[tokio::test]
1227    async fn lookup_punycode_no_mx_only_a() {
1228        let mx = MailExchanger::resolve("xn--bb-eka.at").await.unwrap();
1229        assert_eq!(mx.domain_name, "xn--bb-eka.at.");
1230        assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
1231    }
1232
1233    #[cfg(feature = "live-dns-tests")]
1234    #[tokio::test]
1235    async fn lookup_bogus_aasland() {
1236        let err = MailExchanger::resolve("not-mairs.aasland.com")
1237            .await
1238            .unwrap_err();
1239        k9::snapshot!(err, "MX lookup for not-mairs.aasland.com failed: NXDOMAIN");
1240    }
1241
1242    #[cfg(feature = "live-dns-tests")]
1243    #[tokio::test]
1244    async fn lookup_example_com() {
1245        // Has a NULL MX record
1246        let mx = MailExchanger::resolve("example.com").await.unwrap();
1247        k9::snapshot!(
1248            mx,
1249            r#"
1250MailExchanger {
1251    domain_name: "example.com.",
1252    hosts: [
1253        ".",
1254    ],
1255    site_name: "",
1256    by_pref: {
1257        0: [
1258            ".",
1259        ],
1260    },
1261    is_domain_literal: false,
1262    is_secure: true,
1263    is_mx: true,
1264}
1265"#
1266        );
1267    }
1268
1269    #[cfg(feature = "live-dns-tests")]
1270    #[tokio::test]
1271    async fn lookup_have_dane() {
1272        let mx = MailExchanger::resolve("do.havedane.net").await.unwrap();
1273        k9::snapshot!(
1274            mx,
1275            r#"
1276MailExchanger {
1277    domain_name: "do.havedane.net.",
1278    hosts: [
1279        "do.havedane.net.",
1280    ],
1281    site_name: "do.havedane.net",
1282    by_pref: {
1283        10: [
1284            "do.havedane.net.",
1285        ],
1286    },
1287    is_domain_literal: false,
1288    is_secure: true,
1289    is_mx: true,
1290}
1291"#
1292        );
1293    }
1294
1295    #[cfg(feature = "live-dns-tests")]
1296    #[tokio::test]
1297    async fn tlsa_have_dane() {
1298        let tlsa = resolve_dane("do.havedane.net", 25).await.unwrap();
1299        k9::snapshot!(
1300            tlsa,
1301            "
1302[
1303    TLSA {
1304        cert_usage: TrustAnchor,
1305        selector: Spki,
1306        matching: Sha256,
1307        cert_data: [
1308            39,
1309            182,
1310            148,
1311            181,
1312            29,
1313            31,
1314            239,
1315            136,
1316            133,
1317            55,
1318            42,
1319            207,
1320            179,
1321            145,
1322            147,
1323            117,
1324            151,
1325            34,
1326            183,
1327            54,
1328            176,
1329            66,
1330            104,
1331            100,
1332            220,
1333            28,
1334            121,
1335            208,
1336            101,
1337            31,
1338            239,
1339            115,
1340        ],
1341    },
1342    TLSA {
1343        cert_usage: DomainIssued,
1344        selector: Spki,
1345        matching: Sha256,
1346        cert_data: [
1347            85,
1348            58,
1349            207,
1350            136,
1351            249,
1352            238,
1353            24,
1354            204,
1355            170,
1356            230,
1357            53,
1358            202,
1359            84,
1360            15,
1361            50,
1362            203,
1363            132,
1364            172,
1365            167,
1366            124,
1367            71,
1368            145,
1369            102,
1370            130,
1371            188,
1372            181,
1373            66,
1374            213,
1375            29,
1376            170,
1377            135,
1378            31,
1379        ],
1380    },
1381]
1382"
1383        );
1384    }
1385
1386    #[cfg(feature = "live-dns-tests")]
1387    #[tokio::test]
1388    async fn mx_lookup_www_example_com() {
1389        // Has no MX, should fall back to A lookup
1390        let mx = MailExchanger::resolve("www.example.com").await.unwrap();
1391        k9::snapshot!(
1392            mx,
1393            r#"
1394MailExchanger {
1395    domain_name: "www.example.com.",
1396    hosts: [
1397        "www.example.com.",
1398    ],
1399    site_name: "www.example.com",
1400    by_pref: {
1401        1: [
1402            "www.example.com.",
1403        ],
1404    },
1405    is_domain_literal: false,
1406    is_secure: false,
1407    is_mx: false,
1408}
1409"#
1410        );
1411    }
1412
1413    #[cfg(feature = "live-dns-tests")]
1414    #[tokio::test]
1415    async fn txt_lookup_gmail() {
1416        let name = Name::from_str_relaxed("_mta-sts.gmail.com").unwrap();
1417        let answer = get_resolver().resolve(name, RecordType::TXT).await.unwrap();
1418        k9::snapshot!(
1419            answer.as_txt(),
1420            r#"
1421[
1422    "v=STSv1; id=20190429T010101;",
1423]
1424"#
1425        );
1426    }
1427}