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