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