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