Skip to main content

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::{
50        Arc, LazyLock,
51        atomic::{AtomicBool, Ordering::Relaxed},
52    },
53    time::Duration,
54};
55
56use hickory_resolver::{
57    TokioResolver,
58    config::{CLOUDFLARE, NameServerConfig, QUAD9, ResolverConfig, ResolverOpts},
59    net::{NetError, runtime::TokioRuntimeProvider},
60};
61use once_cell::sync::OnceCell;
62use reqwest::dns::{Addrs, Name, Resolve, Resolving};
63use tracing::*;
64
65mod constants;
66mod static_resolver;
67pub(crate) use static_resolver::*;
68
69pub(crate) const DEFAULT_POSITIVE_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(1800);
70pub(crate) const DEFAULT_OVERALL_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
71pub(crate) const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
72
73impl ClientBuilder {
74    /// Override the DNS resolver implementation used by the underlying http client.
75    /// This forces the use of an independent request executor (via [`Self::non_shared`]).
76    pub fn dns_resolver<R: Resolve + 'static>(mut self, resolver: Arc<R>) -> Self {
77        self = self.non_shared();
78        // because of the call to non-shared this conditional should always run.
79        if let Some(rb) = self.reqwest_client_builder {
80            self.reqwest_client_builder = Some(rb.dns_resolver(resolver));
81        }
82        self.use_secure_dns = false;
83        self
84    }
85
86    /// Override the DNS resolver implementation used by the underlying http client. If
87    /// [`Self::dns_resolver`] is called directly that will take priority over this, there is no
88    /// need to call both.
89    /// This forces the use of an independent request executor (via [`Self::non_shared`]).
90    pub fn no_hickory_dns(mut self) -> Self {
91        self = self.non_shared();
92        self.use_secure_dns = false;
93        self
94    }
95}
96
97// n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated.
98// this is fine, as the OS can deallocate the terminated program faster than we can free memory
99// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
100static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
101    tracing::debug!("Initializing shared DNS resolver");
102    HickoryDnsResolver {
103        use_shared: false, // prevent infinite recursion
104        ..Default::default()
105    }
106});
107
108#[derive(Debug, thiserror::Error)]
109#[allow(missing_docs)]
110/// Error occurring while resolving a hostname into an IP address.
111pub enum ResolveError {
112    #[error("invalid name: {0}")]
113    InvalidNameError(String),
114    #[error("hickory-dns resolver error: {0}")]
115    ResolveError(#[from] NetError),
116    #[error("high level lookup timed out")]
117    Timeout,
118    #[error("hostname not found in static lookup table")]
119    StaticLookupMiss,
120}
121
122impl ResolveError {
123    /// Returns true if the error is a timeout.
124    pub fn is_timeout(&self) -> bool {
125        matches!(
126            self,
127            ResolveError::Timeout | ResolveError::ResolveError(NetError::Timeout)
128        )
129    }
130}
131
132/// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait.
133///
134/// Typical use involves instantiating using the `Default` implementation and then resolving using
135/// methods or trait implementations.
136///
137/// The default initialization uses a shared underlying `AsyncResolver`. If a thread local resolver
138/// is required use `thread_resolver()` to build a resolver with an independently instantiated
139/// internal `AsyncResolver`.
140#[derive(Debug, Clone)]
141pub struct HickoryDnsResolver {
142    // Since we might not have been called in the context of a
143    // Tokio Runtime in initialization, so we must delay the actual
144    // construction of the resolver.
145    state: Arc<OnceCell<TokioResolver>>,
146    use_system: Arc<AtomicBool>,
147    system_resolver: Arc<OnceCell<TokioResolver>>,
148    static_base: Option<Arc<OnceCell<StaticResolver>>>,
149    use_shared: bool,
150    /// Overall timeout for dns lookup associated with any individual host resolution. For example,
151    /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
152    overall_dns_timeout: Duration,
153}
154
155impl Default for HickoryDnsResolver {
156    fn default() -> Self {
157        Self {
158            state: Default::default(),
159            use_system: Arc::new(AtomicBool::new(false)),
160            system_resolver: Default::default(),
161            static_base: Some(Default::default()),
162            use_shared: true,
163            overall_dns_timeout: DEFAULT_OVERALL_LOOKUP_TIMEOUT,
164        }
165    }
166}
167
168impl Resolve for HickoryDnsResolver {
169    fn resolve(&self, name: Name) -> Resolving {
170        let use_system = self.use_system.load(std::sync::atomic::Ordering::Relaxed);
171        let use_shared = self.use_shared;
172        let result = if use_system {
173            self.system_resolver
174                .get_or_try_init(|| HickoryDnsResolver::new_resolver_system(use_shared))
175        } else {
176            self.state
177                .get_or_try_init(|| HickoryDnsResolver::new_resolver(use_shared))
178        };
179
180        let resolver = match result {
181            Ok(r) => r.clone(),
182            Err(err) => return Box::pin(return_err(err)),
183        };
184
185        let maybe_static = self.static_base.clone();
186        let overall_dns_timeout = self.overall_dns_timeout;
187        Box::pin(async move {
188            resolve(
189                name,
190                resolver,
191                maybe_static,
192                use_shared,
193                overall_dns_timeout,
194            )
195            .await
196            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
197        })
198    }
199}
200
201async fn return_err(e: ResolveError) -> Result<Addrs, Box<dyn std::error::Error + Send + Sync>> {
202    Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
203}
204
205async fn resolve(
206    name: Name,
207    resolver: TokioResolver,
208    maybe_static: Option<Arc<OnceCell<StaticResolver>>>,
209    independent: bool,
210    overall_dns_timeout: Duration,
211) -> Result<Addrs, ResolveError> {
212    // try checking the static table to see if any of the addresses in the table have been
213    // looked up previously within the timeout to where we are not yet ready to try the
214    // default resolver yet again.
215    if let Some(ref static_resolver) = maybe_static {
216        let resolver =
217            static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
218
219        if let Some(addrs) = resolver.pre_resolve(name.as_str()) {
220            let addrs: Addrs =
221                Box::new(addrs.into_iter().map(|ip_addr| SocketAddr::new(ip_addr, 0)));
222            return Ok(addrs);
223        }
224    }
225
226    // Attempt a lookup using the primary resolver
227    let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
228    let primary_err = match resolve_fut.await {
229        Err(_) => ResolveError::Timeout,
230        Ok(Ok(lookup)) => {
231            // Shuffle so that successive connection attempts cycle through all
232            // returned IPs rather than always hitting the same first address.
233            let mut ips = Vec::from_iter(lookup.iter());
234            fastrand::shuffle(&mut ips);
235            let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
236            return Ok(addrs);
237        }
238        Ok(Err(e)) => {
239            // on failure use the fall back system configured DNS resolver
240            if !e.is_no_records_found() {
241                warn!("primary DNS failed w/ error: {e}");
242            }
243            e.into()
244        }
245    };
246
247    // If no record has been found and a static map of fallback addresses is configured
248    // check the table for our entry
249    if let Some(ref static_resolver) = maybe_static {
250        debug!("checking static");
251        let resolver =
252            static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
253
254        if let Ok(addrs) = resolver.resolve(name).await {
255            return Ok(addrs);
256        }
257    }
258
259    Err(primary_err)
260}
261
262impl HickoryDnsResolver {
263    /// Returns an instance of the shared resolver.
264    pub fn shared() -> Self {
265        SHARED_RESOLVER.clone()
266    }
267
268    /// Attempt to resolve a domain name to a set of ['IpAddr']s
269    pub async fn resolve_str(
270        &self,
271        name: &str,
272    ) -> Result<impl Iterator<Item = IpAddr> + use<>, ResolveError> {
273        let n =
274            Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?;
275        let use_system = self.use_system.load(std::sync::atomic::Ordering::Relaxed);
276        let resolver = if use_system {
277            self.system_resolver
278                .get_or_try_init(|| HickoryDnsResolver::new_resolver_system(self.use_shared))?
279                .clone()
280        } else {
281            self.state
282                .get_or_try_init(|| HickoryDnsResolver::new_resolver(self.use_shared))?
283                .clone()
284        };
285
286        resolve(
287            n,
288            resolver,
289            self.static_base.clone(),
290            self.use_shared,
291            self.overall_dns_timeout,
292        )
293        .await
294        .map(|addrs| addrs.map(|socket_addr| socket_addr.ip()))
295    }
296
297    /// Create a (lazy-initialized) resolver that is not shared across threads.
298    pub fn thread_resolver() -> Self {
299        Self {
300            use_shared: false,
301            ..Default::default()
302        }
303    }
304
305    fn new_resolver(use_shared: bool) -> Result<TokioResolver, ResolveError> {
306        // using a closure here is slightly gross, but this makes sure that if the
307        // lazy-init returns an error it can be handled by the client
308        if use_shared {
309            SHARED_RESOLVER.state.get_or_try_init(new_resolver).cloned()
310        } else {
311            new_resolver()
312        }
313    }
314
315    fn new_resolver_system(use_shared: bool) -> Result<TokioResolver, ResolveError> {
316        // using a closure here is slightly gross, but this makes sure that if the
317        // lazy-init returns an error it can be handled by the client
318        if !use_shared {
319            new_resolver_system()
320        } else {
321            Ok(SHARED_RESOLVER
322                .system_resolver
323                .get_or_try_init(new_resolver_system)?
324                .clone())
325        }
326    }
327
328    fn new_static_fallback(use_shared: bool) -> StaticResolver {
329        if use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
330            shared_resolver
331                .get_or_init(new_default_static_fallback)
332                .clone()
333        } else {
334            new_default_static_fallback()
335        }
336    }
337
338    /// Swap the primary internal resolver to the system resolver rather than the
339    /// configured custom resolver.
340    pub fn use_system_resolver(&self) {
341        self.use_system.store(true, Relaxed);
342
343        if self.use_shared {
344            SHARED_RESOLVER.use_system_resolver();
345        }
346    }
347
348    /// Swap the primary internal resolver to the configured custom resolver rather than the
349    /// system resolver.
350    pub fn use_configured_resolver(&self) {
351        self.use_system.store(false, Relaxed);
352
353        if self.use_shared {
354            SHARED_RESOLVER.use_configured_resolver();
355        }
356    }
357
358    /// Clear entries from the static table that would return entries during the pre-resolve stage.
359    /// This means that all lookups will attempt to use the network resolver again before the static
360    /// table is consulted.
361    ///
362    /// Entries elevated to pre-resolve from fallback (added from default or using
363    /// [`set_fallback`]`) will have their cache timeout cleared. Entries added directly to
364    /// pre-resolve (using [`Self::set_static_preresolve`]) will be removed.
365    pub fn clear_preresolve(&self) {
366        debug!("clearing pre-resolve table");
367        if let Some(cell) = &self.static_base
368            && let Some(static_base) = cell.get()
369        {
370            static_base.clear_preresolve()
371        }
372    }
373
374    /// Get the current map of hostnames to addresses used in the fallback static lookup stage if one
375    /// exists.
376    pub fn get_static_fallbacks(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
377        Some(self.static_base.as_ref()?.get()?.get_fallback_addrs())
378    }
379
380    /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup
381    pub fn set_fallback_addrs(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
382        debug!("setting fallback entries for {:?}", addrs.keys());
383        if self.static_base.is_none() {
384            let cell = OnceCell::new();
385            self.static_base = Some(Arc::new(cell));
386        }
387        self.static_base
388            .as_ref()
389            .unwrap()
390            .get_or_init(|| Self::new_static_fallback(self.use_shared))
391            .set_fallback(addrs);
392    }
393
394    /// Get the current map of hostnames to addresses used in the preresolve static lookup stage
395    /// if one exists.
396    pub fn get_static_preresolve(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
397        Some(self.static_base.as_ref()?.get()?.get_preresolve_addrs())
398    }
399
400    /// Set (or overwrite) the map of addresses used in the preresolve static hostname lookup
401    pub fn set_static_preresolve(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
402        debug!("setting pre-resolve entries for {:?}", addrs.keys());
403        if self.static_base.is_none() {
404            let cell = OnceCell::new();
405            self.static_base = Some(Arc::new(cell));
406        }
407        self.static_base
408            .as_ref()
409            .unwrap()
410            .get_or_init(|| Self::new_static_fallback(self.use_shared))
411            .set_preresolve(addrs);
412    }
413
414    /// Successfully resolved addresses are cached for a minimum of 30 minutes
415    /// Individual lookup Timeouts are set to 3 seconds
416    /// Number of retries after lookup failure before giving up is set to (default) to 2
417    /// Lookup order is set to (default) A then AAAA
418    /// Number or parallel lookup is set to (default) 2
419    /// Nameserver selection uses the (default) EWMA statistics / performance based strategy
420    fn default_options() -> ResolverOpts {
421        let mut opts = ResolverOpts::default();
422        // Always cache successful responses for queries received by this resolver for 30 min minimum.
423        opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
424        opts.timeout = DEFAULT_QUERY_TIMEOUT;
425        opts.attempts = 0;
426
427        opts
428    }
429
430    /// Get the list of currently available nameserver configs.
431    pub fn all_configured_name_servers(&self) -> Vec<NameServerConfig> {
432        default_nameserver_group()
433    }
434
435    /// Do a trial resolution using each nameserver individually to test which are working and which
436    /// fail to complete a lookup. This will always try the full set of default configured resolvers.
437    pub async fn trial_nameservers(&self) {
438        let nameservers = default_nameserver_group();
439        for (ns, result) in trial_nameservers_inner(&nameservers).await {
440            if let Err(e) = result {
441                warn!("trial {ns:?} errored: {e}");
442            } else {
443                info!("trial {ns:?} succeeded");
444            }
445        }
446    }
447}
448
449/// Create a new resolver with a custom DoT based configuration. The options are overridden to look
450/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm.
451///
452/// Individual lookup Timeouts are set to 3 seconds
453/// Number of retries after lookup failure before giving up Defaults to 2
454/// Lookup order is set to (default) A then AAAA
455///
456/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup.
457/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs.
458fn new_resolver() -> Result<TokioResolver, ResolveError> {
459    let name_servers = default_nameserver_group_ipv4_only();
460
461    configure_and_build_resolver(name_servers)
462}
463
464fn configure_and_build_resolver(
465    name_servers: Vec<NameServerConfig>,
466) -> Result<TokioResolver, ResolveError> {
467    let options = HickoryDnsResolver::default_options();
468    info!("building new configured resolver");
469    debug!("configuring resolver with {options:?}, {name_servers:?}");
470
471    let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
472    let mut resolver_builder =
473        TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
474
475    resolver_builder = resolver_builder.with_options(options);
476
477    Ok(resolver_builder.build()?)
478}
479
480fn filter_ipv4(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
481    nameservers
482        .into_iter()
483        .filter(|ns| ns.ip.is_ipv4())
484        .collect()
485}
486
487#[allow(unused)]
488fn filter_ipv6(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
489    nameservers
490        .into_iter()
491        .filter(|ns| ns.ip.is_ipv6())
492        .collect()
493}
494
495#[allow(unused)]
496fn default_nameserver_group() -> Vec<NameServerConfig> {
497    QUAD9
498        .tls()
499        .chain(QUAD9.https())
500        .chain(CLOUDFLARE.tls())
501        .chain(CLOUDFLARE.https())
502        .collect()
503}
504
505fn default_nameserver_group_ipv4_only() -> Vec<NameServerConfig> {
506    filter_ipv4(default_nameserver_group())
507}
508
509#[allow(unused)]
510fn default_nameserver_group_ipv6_only() -> Vec<NameServerConfig> {
511    filter_ipv6(default_nameserver_group())
512}
513
514/// Create a new resolver with the default configuration, which reads from the system DNS config
515/// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6
516/// addresses to work with "happy eyeballs" algorithm.
517fn new_resolver_system() -> Result<TokioResolver, ResolveError> {
518    let mut resolver_builder = TokioResolver::builder_tokio()?;
519
520    let options = HickoryDnsResolver::default_options();
521    info!("building new fallback system resolver");
522    debug!("fallback system resolver with {options:?}");
523
524    resolver_builder = resolver_builder.with_options(options);
525
526    Ok(resolver_builder.build()?)
527}
528
529fn new_default_static_fallback() -> StaticResolver {
530    StaticResolver::new().with_fallback(constants::default_static_addrs())
531}
532
533/// Do a trial resolution using each nameserver individually to test which are working and which
534/// fail to complete a lookup.
535async fn trial_nameservers_inner(
536    name_servers: &[NameServerConfig],
537) -> Vec<(NameServerConfig, Result<(), ResolveError>)> {
538    let mut trial_lookups = tokio::task::JoinSet::new();
539
540    for name_server in name_servers {
541        let ns = name_server.clone();
542        trial_lookups.spawn(async { (ns.clone(), trial_lookup(ns, "example.com").await) });
543    }
544
545    trial_lookups.join_all().await
546}
547
548/// Create an independent resolver that has only the provided nameserver and do one lookup for the
549/// provided query target.
550async fn trial_lookup(name_server: NameServerConfig, query: &str) -> Result<(), ResolveError> {
551    debug!("running ns trial {name_server:?} query={query}");
552
553    let resolver = configure_and_build_resolver(vec![name_server])?;
554
555    match tokio::time::timeout(DEFAULT_OVERALL_LOOKUP_TIMEOUT, resolver.ipv4_lookup(query)).await {
556        Ok(Ok(_)) => Ok(()),
557        Ok(Err(e)) => Err(e.into()),
558        Err(_) => Err(ResolveError::Timeout),
559    }
560}
561
562#[cfg(test)]
563mod test {
564    use super::*;
565    use itertools::Itertools;
566    use std::{
567        collections::HashMap,
568        net::{IpAddr, Ipv4Addr, Ipv6Addr},
569    };
570
571    /// IP addresses guaranteed to fail attempts to resolve
572    ///
573    /// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
574    const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
575        IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
576        IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
577        IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
578        IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
579    ];
580
581    #[tokio::test]
582    async fn reqwest_with_custom_dns() {
583        let var_name = HickoryDnsResolver::default();
584        let resolver = var_name;
585        let client = reqwest::ClientBuilder::new()
586            .dns_resolver(resolver)
587            .build()
588            .unwrap();
589
590        let resp = client
591            .get("http://ifconfig.me:80")
592            .send()
593            .await
594            .unwrap()
595            .bytes()
596            .await
597            .unwrap();
598
599        assert!(!resp.is_empty());
600    }
601
602    #[tokio::test]
603    async fn dns_lookup() -> Result<(), ResolveError> {
604        let resolver = HickoryDnsResolver::default();
605
606        let domain = "ifconfig.me";
607        let addrs = resolver.resolve_str(domain).await?;
608
609        assert!(addrs.into_iter().next().is_some());
610
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn static_resolver_as_fallback() -> Result<(), ResolveError> {
616        let example_domain = "non-existent.nymvpn.com";
617        let mut resolver = HickoryDnsResolver {
618            ..Default::default()
619        };
620
621        let result = resolver.resolve_str(example_domain).await;
622        assert!(result.is_err()); // should be NXDomain
623
624        resolver.static_base = Some(Default::default());
625
626        let mut addr_map = HashMap::new();
627        let example_ip4: IpAddr = "10.10.10.10".parse().unwrap();
628        let example_ip6: IpAddr = "dead::beef".parse().unwrap();
629        addr_map.insert(example_domain.to_string(), vec![example_ip4, example_ip6]);
630
631        resolver.set_fallback_addrs(addr_map);
632
633        let mut addrs = resolver.resolve_str(example_domain).await?;
634        assert!(addrs.contains(&example_ip4));
635        assert!(addrs.contains(&example_ip6));
636        Ok(())
637    }
638
639    // Test the nameserver trial functionality with mostly nameservers guaranteed to be broken and
640    // one that should work.
641    #[tokio::test]
642    async fn trial_nameservers() {
643        let good_cf_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
644
645        let mut ns_ips = GUARANTEED_BROKEN_IPS_1.to_vec();
646        ns_ips.push(good_cf_ip);
647
648        let domain = Arc::<str>::from("cloudflare-dns.com");
649        let path = Arc::<str>::from("/dns-query");
650        let broken_ns_https = GUARANTEED_BROKEN_IPS_1
651            .iter()
652            .chain([&good_cf_ip])
653            .map(|ip| NameServerConfig::https(*ip, domain.clone(), Some(path.clone())))
654            .collect::<Vec<_>>();
655
656        for (ns, result) in trial_nameservers_inner(&broken_ns_https).await {
657            if ns.ip == good_cf_ip {
658                assert!(result.is_ok())
659            } else {
660                assert!(result.is_err())
661            }
662        }
663    }
664
665    mod failure_test {
666        use super::*;
667
668        // Create a resolver that behaves the same as the custom configured router, except for the fact
669        // that it is guaranteed to fail.
670        fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
671            info!("building new faulty resolver");
672
673            let domain = Arc::<str>::from("cloudflare-dns.com");
674            let path = Arc::<str>::from("/dns-query");
675            let broken_ns_group = GUARANTEED_BROKEN_IPS_1
676                .iter()
677                .map(|ip| NameServerConfig::tls(*ip, domain.clone()))
678                .chain(
679                    GUARANTEED_BROKEN_IPS_1
680                        .iter()
681                        .map(|ip| NameServerConfig::https(*ip, domain.clone(), Some(path.clone())))
682                        .collect::<Vec<_>>(),
683                )
684                .collect::<Vec<_>>();
685
686            configure_and_build_resolver(broken_ns_group)
687        }
688
689        #[tokio::test]
690        async fn dns_lookup_failures() -> Result<(), ResolveError> {
691            let time_start = std::time::Instant::now();
692
693            let r = OnceCell::new();
694            r.set(build_broken_resolver().expect("failed to build resolver"))
695                .expect("broken resolver init error");
696
697            // create a new resolver that won't mess with the shared resolver used by other tests
698            let resolver = HickoryDnsResolver {
699                use_shared: false,
700                state: Arc::new(r),
701                overall_dns_timeout: Duration::from_secs(5),
702                ..Default::default()
703            };
704            build_broken_resolver()?;
705            let domain = "ifconfig.me";
706            let result = resolver.resolve_str(domain).await;
707            assert!(result.is_err_and(|e| e.is_timeout()));
708
709            let duration = time_start.elapsed();
710            assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
711
712            Ok(())
713        }
714
715        #[tokio::test]
716        async fn fallback_to_static() -> Result<(), ResolveError> {
717            let r = OnceCell::new();
718            r.set(build_broken_resolver().expect("failed to build resolver"))
719                .expect("broken resolver init error");
720
721            // create a new resolver that won't mess with the shared resolver used by other tests
722            let resolver = HickoryDnsResolver {
723                use_shared: false,
724                state: Arc::new(r),
725                static_base: Some(Default::default()),
726                overall_dns_timeout: Duration::from_secs(5),
727                ..Default::default()
728            };
729            build_broken_resolver()?;
730
731            // successful lookup using fallback to static resolver
732            let domain = "nymvpn.com";
733            let _ = resolver
734                .resolve_str(domain)
735                .await
736                .expect("failed to resolve address in static lookup");
737
738            // unsuccessful lookup - primary times out, and not in static table
739            let domain = "non-existent.nymtech.net";
740            let result = resolver.resolve_str(domain).await;
741            assert!(result.is_err_and(|e| e.is_timeout()));
742
743            Ok(())
744        }
745
746        #[tokio::test]
747        #[cfg(any())] // #[ignore] we run --ignore in CI/CD assuming it just means slow -_-
748        // This test impacts the state of the shared resolver and as such is disabled to avoid
749        // interference with other tests.
750        //
751        // this test is dependent of external network setup -- i.e. blocking all traffic to the
752        // default resolvers. Otherwise the default resolvers will succeed without using the static
753        // fallback, making the test pointless
754        async fn dns_lookup_failure_on_shared() -> Result<(), ResolveError> {
755            let resolver1 = HickoryDnsResolver::shared();
756
757            let time_start = std::time::Instant::now();
758            // create a new resolver that uses the shared resolver
759            let resolver = HickoryDnsResolver::shared();
760
761            // successful lookup using fallback to static resolver
762            let domain = "rpc.nymtech.net";
763            let _ = resolver
764                .resolve_str(domain)
765                .await
766                .expect("failed to resolve address in static lookup");
767
768            let lookup_dur = Instant::now() - time_start;
769            assert!(
770                lookup_dur > resolver.overall_dns_timeout,
771                "expected lookup timeout - took {}ms",
772                (lookup_dur).as_millis()
773            );
774
775            let time_start = std::time::Instant::now();
776            // successful lookup using pre-resolve entry promoted from fallback
777            let domain = "rpc.nymtech.net";
778            let _ = resolver1
779                .resolve_str(domain)
780                .await
781                .expect("domain expected to be in pre-resolve");
782
783            // this lookup should basically be instant as we are using pre-resolve
784            let lookup_dur = std::time::Instant::now() - time_start;
785            assert!(
786                lookup_dur < Duration::from_millis(10),
787                "expected instant - took {}ms",
788                (lookup_dur).as_millis()
789            );
790
791            // unsuccessful lookup - primary times out, and not in static table
792            let domain = "non-existent.nymtech.net";
793            let result = resolver.resolve_str(domain).await;
794            assert!(result.is_err());
795            // assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
796            // assert!(result.is_err_and(|e| matches!(e, ResolveError::ResolveError(e) if e.is_nx_domain())));
797            Ok(())
798        }
799
800        #[tokio::test]
801        #[cfg(any())] // #[ignore] we run --ignore in CI/CD assuming it just means slow -_-
802        // This test impacts the state of the shared resolver and as such is disabled to avoid
803        // interference with other tests.
804        async fn setting_dns_fallbacks_with_shared_resolver() -> Result<(), ResolveError> {
805            let resolver1 = HickoryDnsResolver::shared();
806
807            // create a new resolver that uses the shared resolver
808            let mut resolver = HickoryDnsResolver::shared();
809
810            let example_domains = [
811                String::from("static1.nymvpn.com"),
812                String::from("static2.nymvpn.com"),
813            ];
814            let mut addr_map1 = HashMap::new();
815            addr_map1.insert(
816                example_domains[0].clone(),
817                vec![Ipv4Addr::new(10, 10, 10, 10).into()],
818            );
819            addr_map1.insert(
820                example_domains[1].clone(),
821                vec![Ipv4Addr::new(1, 1, 1, 1).into()],
822            );
823
824            resolver.set_static_preresolve(addr_map1);
825
826            let time_start = std::time::Instant::now();
827            // successful lookup using pre-resolve entry promoted from fallback
828            let _ = resolver1
829                .resolve_str(&example_domains[0])
830                .await
831                .expect("domain expected to be in pre-resolve");
832
833            // this lookup should basically be instant as we are using pre-resolve
834            let lookup_dur = std::time::Instant::now() - time_start;
835            assert!(
836                lookup_dur < Duration::from_millis(10),
837                "expected instant - took {}ms",
838                (lookup_dur).as_millis()
839            );
840
841            // After clearing the pre-resolve in one instance of the shared resolver ...
842            resolver.clear_preresolve();
843
844            // ... other instances have their pre-resolve entries cleared.
845            let prereslve_lookup = resolver1
846                .static_base
847                .as_ref()
848                .unwrap()
849                .get()
850                .unwrap()
851                .pre_resolve(&example_domains[0]);
852            assert!(prereslve_lookup.is_none());
853
854            Ok(())
855        }
856    }
857}