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