dns_resolver/
lib.rs

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