nym_http_api_client/
dns.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! DNS resolver configuration for internal lookups.
5//!
6//! The resolver itself is the set combination of the cloudflare, and quad9 endpoints supporting DoH
7//! and DoT.
8//!
9//! ```rust
10//! use nym_http_api_client::HickoryDnsResolver;
11//! # use nym_http_api_client::ResolveError;
12//! # type Err = ResolveError;
13//! # async fn run() -> Result<(), Err> {
14//! let resolver = HickoryDnsResolver::default();
15//! resolver.resolve_str("example.com").await?;
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! ## Fallbacks
21//!
22//! **System Resolver --** This resolver supports an optional fallback mechanism where, should the
23//! DNS-over-TLS resolution fail, a followup resolution will be done using the hosts configured
24//! default (e.g. `/etc/resolve.conf` on linux).
25//!
26//! This is disabled by default and can be enabled using `enable_system_fallback`.
27//!
28//! **Static Table --**  There is also a second optional fallback mechanism that allows a static map
29//! to be used as a last resort. This can help when DNS encounters errors due to blocked resolvers
30//! or unknown conditions. This is enabled by default, and can be customized if building a new
31//! resolver.
32//!
33//! ## IPv4 / IPv6
34//!
35//! By default the resolver uses only IPv4 nameservers, and is configured to do `A` lookups first,
36//! and only do `AAAA` if no `A` record is available.
37//!
38//! ---
39//!
40//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the `hickory-resolver` crate
41#![deny(missing_docs)]
42
43use crate::ClientBuilder;
44
45use std::{
46    collections::HashMap,
47    net::{IpAddr, SocketAddr},
48    str::FromStr,
49    sync::{Arc, LazyLock},
50    time::Duration,
51};
52
53use hickory_resolver::{
54    TokioResolver,
55    config::{NameServerConfig, NameServerConfigGroup, ResolverConfig, ResolverOpts},
56    lookup_ip::LookupIpIntoIter,
57    name_server::TokioConnectionProvider,
58};
59use once_cell::sync::OnceCell;
60use reqwest::dns::{Addrs, Name, Resolve, Resolving};
61use tracing::*;
62
63mod constants;
64mod static_resolver;
65pub(crate) use static_resolver::*;
66
67pub(crate) const DEFAULT_POSITIVE_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(1800);
68pub(crate) const DEFAULT_OVERALL_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
69pub(crate) const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
70
71impl ClientBuilder {
72    /// Override the DNS resolver implementation used by the underlying http client.
73    pub fn dns_resolver<R: Resolve + 'static>(mut self, resolver: Arc<R>) -> Self {
74        self.reqwest_client_builder = self.reqwest_client_builder.dns_resolver(resolver);
75        self.use_secure_dns = false;
76        self
77    }
78
79    /// Override the DNS resolver implementation used by the underlying http client.
80    pub fn no_hickory_dns(mut self) -> Self {
81        self.use_secure_dns = false;
82        self
83    }
84}
85
86// n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated.
87// this is fine, as the OS can deallocate the terminated program faster than we can free memory
88// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
89static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
90    tracing::debug!("Initializing shared DNS resolver");
91    HickoryDnsResolver {
92        use_shared: false, // prevent infinite recursion
93        ..Default::default()
94    }
95});
96
97#[derive(Debug, thiserror::Error)]
98#[allow(missing_docs)]
99/// Error occurring while resolving a hostname into an IP address.
100pub enum ResolveError {
101    #[error("invalid name: {0}")]
102    InvalidNameError(String),
103    #[error("hickory-dns resolver error: {0}")]
104    ResolveError(#[from] hickory_resolver::ResolveError),
105    #[error("high level lookup timed out")]
106    Timeout,
107    #[error("hostname not found in static lookup table")]
108    StaticLookupMiss,
109}
110
111impl ResolveError {
112    /// Returns true if the error is a timeout.
113    pub fn is_timeout(&self) -> bool {
114        matches!(self, ResolveError::Timeout)
115    }
116}
117
118/// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait.
119///
120/// Typical use involves instantiating using the `Default` implementation and then resolving using
121/// methods or trait implementations.
122///
123/// The default initialization uses a shared underlying `AsyncResolver`. If a thread local resolver
124/// is required use `thread_resolver()` to build a resolver with an independently instantiated
125/// internal `AsyncResolver`.
126#[derive(Debug, Clone)]
127pub struct HickoryDnsResolver {
128    // Since we might not have been called in the context of a
129    // Tokio Runtime in initialization, so we must delay the actual
130    // construction of the resolver.
131    state: Arc<OnceCell<TokioResolver>>,
132    fallback: Option<Arc<OnceCell<TokioResolver>>>,
133    static_base: Option<Arc<OnceCell<StaticResolver>>>,
134    use_shared: bool,
135    /// Overall timeout for dns lookup associated with any individual host resolution. For example,
136    /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
137    overall_dns_timeout: Duration,
138}
139
140impl Default for HickoryDnsResolver {
141    fn default() -> Self {
142        Self {
143            state: Default::default(),
144            fallback: Default::default(),
145            static_base: Some(Default::default()),
146            use_shared: true,
147            overall_dns_timeout: DEFAULT_OVERALL_LOOKUP_TIMEOUT,
148        }
149    }
150}
151
152impl Resolve for HickoryDnsResolver {
153    fn resolve(&self, name: Name) -> Resolving {
154        let resolver = self.state.clone();
155        let maybe_fallback = self.fallback.clone();
156        let maybe_static = self.static_base.clone();
157        let use_shared = self.use_shared;
158        let overall_dns_timeout = self.overall_dns_timeout;
159        Box::pin(async move {
160            resolve(
161                name,
162                resolver,
163                maybe_fallback,
164                maybe_static,
165                use_shared,
166                overall_dns_timeout,
167            )
168            .await
169            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
170        })
171    }
172}
173
174async fn resolve(
175    name: Name,
176    resolver: Arc<OnceCell<TokioResolver>>,
177    maybe_fallback: Option<Arc<OnceCell<TokioResolver>>>,
178    maybe_static: Option<Arc<OnceCell<StaticResolver>>>,
179    independent: bool,
180    overall_dns_timeout: Duration,
181) -> Result<Addrs, ResolveError> {
182    let resolver = resolver.get_or_init(|| HickoryDnsResolver::new_resolver(independent));
183
184    // try checking the static table to see if any of the addresses in the table have been
185    // looked up previously within the timeout to where we are not yet ready to try the
186    // default resolver yet again.
187    if let Some(ref static_resolver) = maybe_static {
188        let resolver =
189            static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
190
191        if let Some(addrs) = resolver.pre_resolve(name.as_str()) {
192            let addrs: Addrs =
193                Box::new(addrs.into_iter().map(|ip_addr| SocketAddr::new(ip_addr, 0)));
194            return Ok(addrs);
195        }
196    }
197
198    // Attempt a lookup using the primary resolver
199    let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
200    let primary_err = match resolve_fut.await {
201        Err(_) => ResolveError::Timeout,
202        Ok(Ok(lookup)) => {
203            let addrs: Addrs = Box::new(SocketAddrs {
204                iter: lookup.into_iter(),
205            });
206            return Ok(addrs);
207        }
208        Ok(Err(e)) => {
209            // on failure use the fall back system configured DNS resolver
210            if !e.is_no_records_found() {
211                warn!("primary DNS failed w/ error: {e}");
212            }
213            e.into()
214        }
215    };
216
217    // If the primary resolver encountered an error, attempt a lookup using the fallback
218    // resolver if one is configured.
219    if let Some(ref fallback) = maybe_fallback {
220        let resolver =
221            fallback.get_or_try_init(|| HickoryDnsResolver::new_resolver_system(independent))?;
222
223        let resolve_fut =
224            tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
225        if let Ok(Ok(lookup)) = resolve_fut.await {
226            let addrs: Addrs = Box::new(SocketAddrs {
227                iter: lookup.into_iter(),
228            });
229            return Ok(addrs);
230        }
231    }
232
233    // If no record has been found and a static map of fallback addresses is configured
234    // check the table for our entry
235    if let Some(ref static_resolver) = maybe_static {
236        debug!("checking static");
237        let resolver =
238            static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
239
240        if let Ok(addrs) = resolver.resolve(name).await {
241            return Ok(addrs);
242        }
243    }
244
245    Err(primary_err)
246}
247
248struct SocketAddrs {
249    iter: LookupIpIntoIter,
250}
251
252impl Iterator for SocketAddrs {
253    type Item = SocketAddr;
254
255    fn next(&mut self) -> Option<Self::Item> {
256        self.iter.next().map(|ip_addr| SocketAddr::new(ip_addr, 0))
257    }
258}
259
260impl HickoryDnsResolver {
261    /// Attempt to resolve a domain name to a set of ['IpAddr']s
262    pub async fn resolve_str(
263        &self,
264        name: &str,
265    ) -> Result<impl Iterator<Item = IpAddr> + use<>, ResolveError> {
266        let n =
267            Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?;
268        resolve(
269            n,
270            self.state.clone(),
271            self.fallback.clone(),
272            self.static_base.clone(),
273            self.use_shared,
274            self.overall_dns_timeout,
275        )
276        .await
277        .map(|addrs| addrs.map(|socket_addr| socket_addr.ip()))
278    }
279
280    /// Create a (lazy-initialized) resolver that is not shared across threads.
281    pub fn thread_resolver() -> Self {
282        Self {
283            use_shared: false,
284            ..Default::default()
285        }
286    }
287
288    fn new_resolver(use_shared: bool) -> TokioResolver {
289        // using a closure here is slightly gross, but this makes sure that if the
290        // lazy-init returns an error it can be handled by the client
291        if use_shared {
292            SHARED_RESOLVER.state.get_or_init(new_resolver).clone()
293        } else {
294            new_resolver()
295        }
296    }
297
298    fn new_resolver_system(use_shared: bool) -> Result<TokioResolver, ResolveError> {
299        // using a closure here is slightly gross, but this makes sure that if the
300        // lazy-init returns an error it can be handled by the client
301        if !use_shared || SHARED_RESOLVER.fallback.is_none() {
302            new_resolver_system()
303        } else {
304            Ok(SHARED_RESOLVER
305                .fallback
306                .as_ref()
307                .unwrap()
308                .get_or_try_init(new_resolver_system)?
309                .clone())
310        }
311    }
312
313    fn new_static_fallback(use_shared: bool) -> StaticResolver {
314        if use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
315            shared_resolver
316                .get_or_init(new_default_static_fallback)
317                .clone()
318        } else {
319            new_default_static_fallback()
320        }
321    }
322
323    /// Enable fallback to the system default resolver if the primary (DoX) resolver fails
324    pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> {
325        self.fallback = Some(Default::default());
326        let _ = self
327            .fallback
328            .as_ref()
329            .unwrap()
330            .get_or_try_init(new_resolver_system)?;
331
332        // IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
333        // if self.use_shared {
334        //     SHARED_RESOLVER.enable_system_fallback()?;
335        // }
336        Ok(())
337    }
338
339    /// Disable fallback resolution. If the primary resolver fails the error is
340    /// returned immediately
341    pub fn disable_system_fallback(&mut self) {
342        self.fallback = None;
343
344        // // IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
345        // if self.use_shared {
346        //     SHARED_RESOLVER.fallback = None;
347        // }
348    }
349
350    /// Get the current map of hostname to address in use by the fallback static lookup if one
351    /// exists.
352    pub fn get_static_fallbacks(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
353        Some(self.static_base.as_ref()?.get()?.get_addrs())
354    }
355
356    /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup
357    pub fn set_static_fallbacks(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
358        let cell = OnceCell::new();
359        cell.set(StaticResolver::new(addrs))
360            .expect("infallible assign");
361        self.static_base = Some(Arc::new(cell));
362    }
363
364    /// Successfully resolved addresses are cached for a minimum of 30 minutes
365    /// Individual lookup Timeouts are set to 3 seconds
366    /// Number of retries after lookup failure before giving up is set to (default) to 2
367    /// Lookup order is set to (default) A then AAAA
368    /// Number or parallel lookup is set to (default) 2
369    /// Nameserver selection uses the (default) EWMA statistics / performance based strategy
370    fn default_options() -> ResolverOpts {
371        let mut opts = ResolverOpts::default();
372        // Always cache successful responses for queries received by this resolver for 30 min minimum.
373        opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
374        opts.timeout = DEFAULT_QUERY_TIMEOUT;
375        opts.attempts = 0;
376
377        opts
378    }
379
380    /// Get the list of currently available nameserver configs.
381    pub fn all_configured_name_servers(&self) -> Vec<NameServerConfig> {
382        default_nameserver_group().to_vec()
383    }
384
385    /// Get the list of currently used nameserver configs.
386    pub fn active_name_servers(&self) -> Vec<NameServerConfig> {
387        if !self.use_shared {
388            return self
389                .state
390                .get()
391                .map(|r| r.config().name_servers().to_vec())
392                .unwrap_or(self.all_configured_name_servers());
393        }
394
395        SHARED_RESOLVER.active_name_servers()
396    }
397
398    /// Do a trial resolution using each nameserver individually to test which are working and which
399    /// fail to complete a lookup. This will always try the full set of default configured resolvers.
400    pub async fn trial_nameservers(&self) {
401        let nameservers = default_nameserver_group();
402        for (ns, result) in trial_nameservers_inner(&nameservers).await {
403            if let Err(e) = result {
404                warn!("trial {ns:?} errored: {e}");
405            } else {
406                info!("trial {ns:?} succeeded");
407            }
408        }
409    }
410}
411
412/// Create a new resolver with a custom DoT based configuration. The options are overridden to look
413/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm.
414///
415/// Individual lookup Timeouts are set to 3 seconds
416/// Number of retries after lookup failure before giving up Defaults to 2
417/// Lookup order is set to (default) A then AAAA
418///
419/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup.
420/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs.
421fn new_resolver() -> TokioResolver {
422    let name_servers = default_nameserver_group_ipv4_only();
423
424    configure_and_build_resolver(name_servers)
425}
426
427fn configure_and_build_resolver<G>(name_servers: G) -> TokioResolver
428where
429    G: Into<NameServerConfigGroup>,
430{
431    let options = HickoryDnsResolver::default_options();
432    let name_servers: NameServerConfigGroup = name_servers.into();
433    info!("building new configured resolver");
434    debug!("configuring resolver with {options:?}, {name_servers:?}");
435
436    let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
437    let mut resolver_builder =
438        TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
439
440    resolver_builder = resolver_builder.with_options(options);
441
442    resolver_builder.build()
443}
444
445fn filter_ipv4(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
446    nameservers
447        .as_ref()
448        .iter()
449        .filter(|ns| ns.socket_addr.is_ipv4())
450        .cloned()
451        .collect()
452}
453
454#[allow(unused)]
455fn filter_ipv6(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
456    nameservers
457        .as_ref()
458        .iter()
459        .filter(|ns| ns.socket_addr.is_ipv6())
460        .cloned()
461        .collect()
462}
463
464#[allow(unused)]
465fn default_nameserver_group() -> NameServerConfigGroup {
466    let mut name_servers = NameServerConfigGroup::quad9_tls();
467    name_servers.merge(NameServerConfigGroup::quad9_https());
468    name_servers.merge(NameServerConfigGroup::cloudflare_tls());
469    name_servers.merge(NameServerConfigGroup::cloudflare_https());
470    name_servers
471}
472
473fn default_nameserver_group_ipv4_only() -> NameServerConfigGroup {
474    filter_ipv4(&default_nameserver_group() as &[NameServerConfig]).into()
475}
476
477#[allow(unused)]
478fn default_nameserver_group_ipv6_only() -> NameServerConfigGroup {
479    filter_ipv6(&default_nameserver_group() as &[NameServerConfig]).into()
480}
481
482/// Create a new resolver with the default configuration, which reads from the system DNS config
483/// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6
484/// addresses to work with "happy eyeballs" algorithm.
485fn new_resolver_system() -> Result<TokioResolver, ResolveError> {
486    let mut resolver_builder = TokioResolver::builder_tokio()?;
487
488    let options = HickoryDnsResolver::default_options();
489    info!("building new fallback system resolver");
490    debug!("fallback system resolver with {options:?}");
491
492    resolver_builder = resolver_builder.with_options(options);
493
494    Ok(resolver_builder.build())
495}
496
497fn new_default_static_fallback() -> StaticResolver {
498    StaticResolver::new(constants::default_static_addrs())
499}
500
501/// Do a trial resolution using each nameserver individually to test which are working and which
502/// fail to complete a lookup.
503async fn trial_nameservers_inner(
504    name_servers: &[NameServerConfig],
505) -> Vec<(NameServerConfig, Result<(), ResolveError>)> {
506    let mut trial_lookups = tokio::task::JoinSet::new();
507
508    for name_server in name_servers {
509        let ns = name_server.clone();
510        trial_lookups.spawn(async { (ns.clone(), trial_lookup(ns, "example.com").await) });
511    }
512
513    trial_lookups.join_all().await
514}
515
516/// Create an independent resolver that has only the provided nameserver and do one lookup for the
517/// provided query target.
518async fn trial_lookup(name_server: NameServerConfig, query: &str) -> Result<(), ResolveError> {
519    debug!("running ns trial {name_server:?} query={query}");
520
521    let resolver = configure_and_build_resolver(vec![name_server]);
522
523    match tokio::time::timeout(DEFAULT_OVERALL_LOOKUP_TIMEOUT, resolver.ipv4_lookup(query)).await {
524        Ok(Ok(_)) => Ok(()),
525        Ok(Err(e)) => Err(e.into()),
526        Err(_) => Err(ResolveError::Timeout),
527    }
528}
529
530#[cfg(test)]
531mod test {
532    use super::*;
533    use itertools::Itertools;
534    use std::collections::HashMap;
535    use std::{
536        net::{IpAddr, Ipv4Addr, Ipv6Addr},
537        time::Instant,
538    };
539
540    /// IP addresses guaranteed to fail attempts to resolve
541    ///
542    /// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
543    const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
544        IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
545        IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
546        IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
547        IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
548    ];
549
550    #[tokio::test]
551    async fn reqwest_with_custom_dns() {
552        let var_name = HickoryDnsResolver::default();
553        let resolver = var_name;
554        let client = reqwest::ClientBuilder::new()
555            .dns_resolver(resolver.into())
556            .build()
557            .unwrap();
558
559        let resp = client
560            .get("http://ifconfig.me:80")
561            .send()
562            .await
563            .unwrap()
564            .bytes()
565            .await
566            .unwrap();
567
568        assert!(!resp.is_empty());
569    }
570
571    #[tokio::test]
572    async fn dns_lookup() -> Result<(), ResolveError> {
573        let resolver = HickoryDnsResolver::default();
574
575        let domain = "ifconfig.me";
576        let addrs = resolver.resolve_str(domain).await?;
577
578        assert!(addrs.into_iter().next().is_some());
579
580        Ok(())
581    }
582
583    #[tokio::test]
584    async fn static_resolver_as_fallback() -> Result<(), ResolveError> {
585        let example_domain = "non-existent.nymvpn.com";
586        let mut resolver = HickoryDnsResolver {
587            ..Default::default()
588        };
589
590        let result = resolver.resolve_str(example_domain).await;
591        assert!(result.is_err()); // should be NXDomain
592
593        resolver.static_base = Some(Default::default());
594
595        let mut addr_map = HashMap::new();
596        let example_ip4: IpAddr = "10.10.10.10".parse().unwrap();
597        let example_ip6: IpAddr = "dead::beef".parse().unwrap();
598        addr_map.insert(example_domain.to_string(), vec![example_ip4, example_ip6]);
599
600        resolver.set_static_fallbacks(addr_map);
601
602        let mut addrs = resolver.resolve_str(example_domain).await?;
603        assert!(addrs.contains(&example_ip4));
604        assert!(addrs.contains(&example_ip6));
605        Ok(())
606    }
607
608    // Test the nameserver trial functionality with mostly nameservers guaranteed to be broken and
609    // one that should work.
610    #[tokio::test]
611    async fn trial_nameservers() {
612        let good_cf_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
613
614        let mut ns_ips = GUARANTEED_BROKEN_IPS_1.to_vec();
615        ns_ips.push(good_cf_ip);
616
617        let broken_ns_https = NameServerConfigGroup::from_ips_https(
618            &ns_ips,
619            443,
620            "cloudflare-dns.com".to_string(),
621            true,
622        );
623
624        let inner = configure_and_build_resolver(broken_ns_https);
625
626        // create a new resolver that won't mess with the shared resolver used by other tests
627        let resolver = HickoryDnsResolver {
628            use_shared: false,
629            state: Arc::new(OnceCell::with_value(inner)),
630            static_base: Some(Default::default()),
631            ..Default::default()
632        };
633
634        let name_servers = resolver.state.get().unwrap().config().name_servers();
635        for (ns, result) in trial_nameservers_inner(name_servers).await {
636            if ns.socket_addr.ip() == good_cf_ip {
637                assert!(result.is_ok())
638            } else {
639                assert!(result.is_err())
640            }
641        }
642    }
643
644    mod failure_test {
645        use super::*;
646
647        // Create a resolver that behaves the same as the custom configured router, except for the fact
648        // that it is guaranteed to fail.
649        fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
650            info!("building new faulty resolver");
651
652            let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
653                GUARANTEED_BROKEN_IPS_1,
654                853,
655                "cloudflare-dns.com".to_string(),
656                true,
657            );
658            let broken_ns_https = NameServerConfigGroup::from_ips_https(
659                GUARANTEED_BROKEN_IPS_1,
660                443,
661                "cloudflare-dns.com".to_string(),
662                true,
663            );
664            broken_ns_group.merge(broken_ns_https);
665
666            Ok(configure_and_build_resolver(broken_ns_group))
667        }
668
669        #[tokio::test]
670        async fn dns_lookup_failures() -> Result<(), ResolveError> {
671            let time_start = std::time::Instant::now();
672
673            let r = OnceCell::new();
674            r.set(build_broken_resolver().expect("failed to build resolver"))
675                .expect("broken resolver init error");
676
677            // create a new resolver that won't mess with the shared resolver used by other tests
678            let resolver = HickoryDnsResolver {
679                use_shared: false,
680                state: Arc::new(r),
681                overall_dns_timeout: Duration::from_secs(5),
682                ..Default::default()
683            };
684            build_broken_resolver()?;
685            let domain = "ifconfig.me";
686            let result = resolver.resolve_str(domain).await;
687            assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
688
689            let duration = time_start.elapsed();
690            assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
691
692            Ok(())
693        }
694
695        #[tokio::test]
696        async fn fallback_to_static() -> Result<(), ResolveError> {
697            let r = OnceCell::new();
698            r.set(build_broken_resolver().expect("failed to build resolver"))
699                .expect("broken resolver init error");
700
701            // create a new resolver that won't mess with the shared resolver used by other tests
702            let resolver = HickoryDnsResolver {
703                use_shared: false,
704                state: Arc::new(r),
705                static_base: Some(Default::default()),
706                overall_dns_timeout: Duration::from_secs(5),
707                ..Default::default()
708            };
709            build_broken_resolver()?;
710
711            // successful lookup using fallback to static resolver
712            let domain = "nymvpn.com";
713            let _ = resolver
714                .resolve_str(domain)
715                .await
716                .expect("failed to resolve address in static lookup");
717
718            // unsuccessful lookup - primary times out, and not in static table
719            let domain = "non-existent.nymtech.net";
720            let result = resolver.resolve_str(domain).await;
721            assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
722
723            Ok(())
724        }
725
726        #[test]
727        fn default_resolver_uses_ipv4_only_nameservers() {
728            let resolver = HickoryDnsResolver::thread_resolver();
729            resolver
730                .active_name_servers()
731                .iter()
732                .all(|cfg| cfg.socket_addr.is_ipv4());
733
734            SHARED_RESOLVER
735                .active_name_servers()
736                .iter()
737                .all(|cfg| cfg.socket_addr.is_ipv4());
738        }
739
740        #[tokio::test]
741        #[ignore]
742        // this test is dependent of external network setup -- i.e. blocking all traffic to the default
743        // resolvers. Otherwise the default resolvers will succeed without using the static fallback,
744        // making the test pointless
745        async fn dns_lookup_failure_on_shared() -> Result<(), ResolveError> {
746            let time_start = Instant::now();
747            let r = OnceCell::new();
748            r.set(build_broken_resolver().expect("failed to build resolver"))
749                .expect("broken resolver init error");
750
751            // create a new resolver that won't mess with the shared resolver used by other tests
752            let resolver = HickoryDnsResolver::default();
753
754            // successful lookup using fallback to static resolver
755            let domain = "rpc.nymtech.net";
756            let _ = resolver
757                .resolve_str(domain)
758                .await
759                .expect("failed to resolve address in static lookup");
760
761            println!(
762                "{}ms resolved {domain}",
763                (Instant::now() - time_start).as_millis()
764            );
765
766            // unsuccessful lookup - primary times out, and not in static table
767            let domain = "non-existent.nymtech.net";
768            let result = resolver.resolve_str(domain).await;
769            assert!(result.is_err());
770            // assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
771            // assert!(result.is_err_and(|e| matches!(e, ResolveError::ResolveError(e) if e.is_nx_domain())));
772            Ok(())
773        }
774    }
775}