Skip to main content

nym_http_api_client/dns/
mod.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 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::new();
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/resolv.conf` on linux).
25//!
26//! This is disabled by default and can be enabled using [`HickoryDnsResolver::use_system_resolver`].
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//! ## Connection provider
39//!
40//! [`HickoryDnsResolver`] is generic over the [`ConnectionProvider`] used by the underlying
41//! `hickory-resolver` crate, defaulting to [`TokioRuntimeProvider`] so that it behaves exactly
42//! like a `TokioResolver` wrapper out of the box. A different connection provider can be supplied
43//! by naming it explicitly, e.g. `HickoryDnsResolver::<MyProvider>::default()`, though process-wide
44//! sharing (see [`HickoryDnsResolver::shared`]) is only available for the default provider.
45//!
46//! ---
47//!
48//! Requires the `https-ring`, `tls-ring`, `webpki-roots` features for the `hickory-resolver` crate
49#![deny(missing_docs)]
50
51use crate::ClientBuilder;
52
53use std::{
54    collections::HashMap,
55    net::{IpAddr, SocketAddr},
56    str::FromStr,
57    sync::{
58        Arc, LazyLock,
59        atomic::{AtomicBool, Ordering::Relaxed},
60    },
61    time::Duration,
62};
63
64use arc_swap::ArcSwap;
65use hickory_resolver::{
66    ConnectionProvider, Resolver,
67    config::{CLOUDFLARE, NameServerConfig, QUAD9, ResolverConfig, ResolverOpts},
68    net::{NetError, runtime::TokioRuntimeProvider},
69};
70use once_cell::sync::OnceCell;
71use reqwest::dns::{Addrs, Name, Resolve, Resolving};
72use tracing::*;
73
74use nym_network_defaults::mainnet::dns as constants;
75mod static_resolver;
76mod trial;
77pub(crate) use static_resolver::*;
78
79pub(crate) const DEFAULT_POSITIVE_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(1800);
80pub(crate) const DEFAULT_OVERALL_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
81pub(crate) const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
82
83impl ClientBuilder {
84    /// Override the DNS resolver implementation used by the underlying http client.
85    /// This forces the use of an independent request executor (via [`Self::non_shared`]).
86    pub fn dns_resolver<R: Resolve + 'static>(mut self, resolver: Arc<R>) -> Self {
87        self = self.non_shared();
88        // because of the call to non-shared this conditional should always run.
89        if let Some(rb) = self.reqwest_client_builder {
90            self.reqwest_client_builder = Some(rb.dns_resolver(resolver));
91        }
92        self.use_secure_dns = false;
93        self
94    }
95
96    /// Disables the hickory-dns async resolver in favor of the `reqwest` default threadpool using
97    /// `getaddrinfo`.
98    ///
99    /// If [`Self::dns_resolver`] is called, there is no need to call this as well.
100    ///
101    /// This forces the use of an independent request executor (via [`Self::non_shared`]).
102    pub fn no_hickory_dns(mut self) -> Self {
103        self = self.non_shared();
104        self.use_secure_dns = false;
105        self
106    }
107}
108
109// n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated.
110// this is fine, as the OS can deallocate the terminated program faster than we can free memory
111// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
112static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
113    tracing::debug!("Initializing shared DNS resolver");
114    HickoryDnsResolver {
115        use_shared: false, // prevent infinite recursion
116        ..Default::default()
117    }
118});
119
120/// Associates a [`ConnectionProvider`] with the process-wide shared resolver instance backing
121/// [`HickoryDnsResolver::shared`], if one exists for that provider.
122///
123/// [`TokioRuntimeProvider`] is the only connection provider with shared state; every other
124/// connection provider always builds an independent resolver, so `use_shared`-style sharing has
125/// no effect for them.
126pub trait SharedResolverState: ConnectionProvider + Default {
127    /// The process-wide shared resolver instance for this connection provider, if one exists.
128    fn shared_resolver() -> Option<&'static HickoryDnsResolver<Self>> {
129        None
130    }
131}
132
133impl SharedResolverState for TokioRuntimeProvider {
134    fn shared_resolver() -> Option<&'static HickoryDnsResolver<Self>> {
135        Some(&SHARED_RESOLVER)
136    }
137}
138
139#[derive(Debug, thiserror::Error)]
140#[allow(missing_docs)]
141/// Error occurring while resolving a hostname into an IP address.
142pub enum ResolveError {
143    #[error("invalid name: {0}")]
144    InvalidNameError(String),
145    #[error("hickory-dns resolver error: {0}")]
146    ResolveError(#[from] NetError),
147    #[error("high level lookup timed out")]
148    Timeout,
149    #[error("hostname not found in static lookup table")]
150    StaticLookupMiss,
151}
152
153impl ResolveError {
154    /// Returns true if the error is a timeout.
155    pub fn is_timeout(&self) -> bool {
156        matches!(
157            self,
158            ResolveError::Timeout | ResolveError::ResolveError(NetError::Timeout)
159        )
160    }
161}
162
163/// Wrapper around a `hickory-resolver` [`Resolver`], which implements the `Resolve` trait.
164///
165/// Generic over the [`ConnectionProvider`] used by the underlying resolver, defaulting to
166/// [`TokioRuntimeProvider`] (i.e. a `TokioResolver`) so existing callers are unaffected. See the
167/// [module docs](self#connection-provider) for details on using a different provider.
168///
169/// Typical use involves instantiating using the `Default` implementation and then resolving using
170/// methods or trait implementations.
171///
172/// The default initialization uses a shared underlying resolver. If a thread local resolver is
173/// required use `thread_resolver()` to build a resolver with an independently instantiated
174/// internal resolver.
175#[derive(Debug, Clone)]
176pub struct HickoryDnsResolver<C: ConnectionProvider = TokioRuntimeProvider> {
177    // Since we might not have been called in the context of a
178    // Tokio Runtime during initialization, we must delay the actual
179    // construction of the resolver. This is swappable (rather than a plain `OnceCell`) so that
180    // [`Self::set_name_servers`] can invalidate it and force a rebuild against the new
181    // nameserver group on the next lookup.
182    state: Arc<ArcSwap<OnceCell<Resolver<C>>>>,
183    use_system: Arc<AtomicBool>,
184    system_resolver: Arc<OnceCell<Resolver<C>>>,
185    static_base: Arc<OnceCell<StaticResolver>>,
186    /// Nameserver group used to build `state` when it needs (re)constructing.
187    name_servers: Arc<ArcSwap<Vec<NameServerConfig>>>,
188    use_shared: bool,
189    /// Overall timeout for dns lookup associated with any individual host resolution. For example,
190    /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
191    overall_dns_timeout: Duration,
192}
193
194impl<C: ConnectionProvider> Default for HickoryDnsResolver<C> {
195    fn default() -> Self {
196        Self {
197            state: Default::default(),
198            use_system: Arc::new(AtomicBool::new(false)),
199            system_resolver: Default::default(),
200            static_base: Default::default(),
201            name_servers: Arc::new(ArcSwap::from_pointee(default_nameserver_group_ipv4_only())),
202            use_shared: true,
203            overall_dns_timeout: DEFAULT_OVERALL_LOOKUP_TIMEOUT,
204        }
205    }
206}
207
208impl HickoryDnsResolver<TokioRuntimeProvider> {
209    /// Construct the default, Tokio-backed resolver.
210    ///
211    /// Prefer this over `Default::default()` / `HickoryDnsResolver::default()`: since
212    /// [`HickoryDnsResolver`] is generic over its connection provider, `Default` is implemented for
213    /// every valid provider. Building a `HickoryDnsResolver` through the `Default` trait therefore
214    /// requires a type annotation, e.g. `let resolver: HickoryDnsResolver<TokioRuntimeProvider> =
215    /// HickoryDnsResolver::default();`. `new` is inherent to [`TokioRuntimeProvider`] specifically,
216    /// so it resolves without an explicit type annotation.
217    pub fn new() -> Self {
218        Self::default()
219    }
220}
221
222impl<C: SharedResolverState> Resolve for HickoryDnsResolver<C> {
223    fn resolve(&self, name: Name) -> Resolving {
224        let use_system = self.use_system.load(std::sync::atomic::Ordering::Relaxed);
225        let use_shared = self.use_shared;
226        let result: Result<Resolver<C>, ResolveError> = if use_system {
227            self.system_resolver
228                .get_or_try_init(|| Self::new_resolver_system(use_shared))
229                .cloned()
230        } else {
231            self.build_configured_resolver()
232        };
233
234        let resolver = match result {
235            Ok(r) => r,
236            Err(err) => return Box::pin(return_err(err)),
237        };
238
239        let maybe_static = self.static_base.clone();
240        let overall_dns_timeout = self.overall_dns_timeout;
241        Box::pin(async move {
242            resolve(
243                name,
244                resolver,
245                maybe_static,
246                use_shared,
247                overall_dns_timeout,
248            )
249            .await
250            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
251        })
252    }
253}
254
255async fn return_err(e: ResolveError) -> Result<Addrs, Box<dyn std::error::Error + Send + Sync>> {
256    Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
257}
258
259async fn resolve<C: SharedResolverState>(
260    name: Name,
261    resolver: Resolver<C>,
262    static_base: Arc<OnceCell<StaticResolver>>,
263    independent: bool,
264    overall_dns_timeout: Duration,
265) -> Result<Addrs, ResolveError> {
266    // try checking the static table to see if any of the addresses in the table have been
267    // looked up previously within the timeout to where we are not yet ready to try the
268    // default resolver yet again.
269    let static_resolver =
270        static_base.get_or_init(|| HickoryDnsResolver::<C>::new_static_fallback(independent));
271
272    if let Some(addrs) = static_resolver.pre_resolve(name.as_str()) {
273        let addrs: Addrs = Box::new(addrs.into_iter().map(|ip_addr| SocketAddr::new(ip_addr, 0)));
274        return Ok(addrs);
275    }
276
277    // Attempt a lookup using the primary resolver
278    let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
279    let primary_err = match resolve_fut.await {
280        Err(_) => ResolveError::Timeout,
281        Ok(Ok(lookup)) => {
282            // Shuffle so that successive connection attempts cycle through all
283            // returned IPs rather than always hitting the same first address.
284            let mut ips = Vec::from_iter(lookup.iter());
285            fastrand::shuffle(&mut ips);
286            let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
287            return Ok(addrs);
288        }
289        Ok(Err(e)) => {
290            // on failure use the fall back system configured DNS resolver
291            if !e.is_no_records_found() {
292                warn!("primary DNS failed w/ error: {e}");
293            }
294            e.into()
295        }
296    };
297
298    // If no record has been found check the static table of fallback addresses for our entry
299    debug!("checking static");
300    if let Ok(addrs) = static_resolver.resolve(name).await {
301        return Ok(addrs);
302    }
303
304    Err(primary_err)
305}
306
307impl<C: SharedResolverState> HickoryDnsResolver<C> {
308    /// Returns an instance of the process-wide shared resolver for this connection provider, if
309    /// one exists (see [`SharedResolverState`]). Falls back to an independent instance (as if
310    /// built via [`Self::default`]) for connection providers without shared state.
311    pub fn shared() -> Self {
312        C::shared_resolver().cloned().unwrap_or_default()
313    }
314
315    /// Attempt to resolve a domain name to a set of [`IpAddr`]s
316    pub async fn resolve_str(
317        &self,
318        name: &str,
319    ) -> Result<impl Iterator<Item = IpAddr> + use<C>, ResolveError> {
320        let n =
321            Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?;
322        let use_system = self.use_system.load(std::sync::atomic::Ordering::Relaxed);
323        let resolver = if use_system {
324            self.system_resolver
325                .get_or_try_init(|| Self::new_resolver_system(self.use_shared))?
326                .clone()
327        } else {
328            self.build_configured_resolver()?
329        };
330
331        resolve(
332            n,
333            resolver,
334            self.static_base.clone(),
335            self.use_shared,
336            self.overall_dns_timeout,
337        )
338        .await
339        .map(|addrs| addrs.map(|socket_addr| socket_addr.ip()))
340    }
341
342    /// Create a (lazy-initialized) resolver that is not shared across threads.
343    pub fn thread_resolver() -> Self {
344        Self {
345            use_shared: false,
346            ..Default::default()
347        }
348    }
349
350    /// Build (or fetch the already-built) configured resolver using the currently configured
351    /// nameserver group.
352    ///
353    /// When `use_shared` is set and shared state is available for `C` (see
354    /// [`SharedResolverState`]), every call consults the shared resolver's own cache directly
355    /// (`shared.state`) rather than copying the result into this instance's local cache: this
356    /// ensures a [`Self::set_name_servers`] reset made through any instance is visible on this
357    /// instance's very next lookup, instead of this instance being stuck with whatever it cached
358    /// the first time it resolved anything. Otherwise this instance's own local cache
359    /// (`self.state`) is used and (re)built independently.
360    fn build_configured_resolver(&self) -> Result<Resolver<C>, ResolveError> {
361        match self.use_shared.then(C::shared_resolver).flatten() {
362            Some(shared) => shared
363                .state
364                .load()
365                .get_or_try_init(|| {
366                    configure_and_build_resolver::<C>(
367                        shared.name_servers.load_full().as_ref().clone(),
368                    )
369                })
370                .cloned(),
371            None => self
372                .state
373                .load()
374                .get_or_try_init(|| {
375                    configure_and_build_resolver::<C>(
376                        self.name_servers.load_full().as_ref().clone(),
377                    )
378                })
379                .cloned(),
380        }
381    }
382
383    fn new_resolver_system(use_shared: bool) -> Result<Resolver<C>, ResolveError> {
384        // using a closure here is slightly gross, but this makes sure that if the
385        // lazy-init returns an error it can be handled by the client
386        match use_shared.then(C::shared_resolver).flatten() {
387            Some(shared) => Ok(shared
388                .system_resolver
389                .get_or_try_init(new_resolver_system::<C>)?
390                .clone()),
391            None => new_resolver_system::<C>(),
392        }
393    }
394
395    fn new_static_fallback(use_shared: bool) -> StaticResolver {
396        match use_shared.then(C::shared_resolver).flatten() {
397            Some(shared) => shared
398                .static_base
399                .get_or_init(new_default_static_fallback)
400                .clone(),
401            _ => new_default_static_fallback(),
402        }
403    }
404
405    /// Swap the primary internal resolver to the system resolver rather than the
406    /// configured custom resolver.
407    pub fn use_system_resolver(&self) {
408        self.use_system.store(true, Relaxed);
409
410        if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
411            shared.use_system_resolver();
412        }
413    }
414
415    /// Swap the primary internal resolver to the configured custom resolver rather than the
416    /// system resolver.
417    pub fn use_configured_resolver(&self) {
418        self.use_system.store(false, Relaxed);
419
420        if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
421            shared.use_configured_resolver();
422        }
423    }
424
425    /// Clear entries from the static table that would return entries during the pre-resolve stage.
426    /// This means that all lookups will attempt to use the network resolver again before the static
427    /// table is consulted.
428    ///
429    /// Entries elevated to pre-resolve from fallback (added from default or using
430    /// [`Self::set_fallback_addrs`]) will have their cache timeout cleared. Entries added directly
431    /// to pre-resolve (using [`Self::set_static_preresolve`]) will be removed.
432    pub fn clear_preresolve(&self) {
433        debug!("clearing pre-resolve table");
434        if let Some(static_base) = self.static_base.get() {
435            static_base.clear_preresolve()
436        }
437    }
438
439    /// Get the current map of hostnames to addresses used in the fallback static lookup stage if one
440    /// exists.
441    pub fn get_static_fallbacks(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
442        Some(self.static_base.get()?.get_fallback_addrs())
443    }
444
445    /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup.
446    pub fn set_fallback_addrs(&self, addrs: HashMap<String, Vec<IpAddr>>) {
447        debug!("setting fallback entries for {:?}", addrs.keys());
448        self.static_base
449            .get_or_init(|| Self::new_static_fallback(self.use_shared))
450            .set_fallback(addrs);
451    }
452
453    /// Get the current map of hostnames to addresses used in the preresolve static lookup stage
454    /// if one exists.
455    pub fn get_static_preresolve(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
456        Some(self.static_base.get()?.get_preresolve_addrs())
457    }
458
459    /// Set (or overwrite) the map of addresses used in the preresolve static hostname lookup.
460    pub fn set_static_preresolve(&self, addrs: HashMap<String, Vec<IpAddr>>) {
461        debug!("setting pre-resolve entries for {:?}", addrs.keys());
462        self.static_base
463            .get_or_init(|| Self::new_static_fallback(self.use_shared))
464            .set_preresolve(addrs);
465    }
466
467    /// Get the full default set of known nameserver configs (Cloudflare and Quad9, DoT and DoH,
468    /// IPv4 and IPv6). Unlike [`Self::get_name_servers`], this is independent of any override
469    /// applied via [`Self::set_name_servers`].
470    pub fn default_name_servers(&self) -> Vec<NameServerConfig> {
471        default_nameserver_group()
472    }
473
474    /// Get the nameserver group currently configured for this resolver, reflecting any change
475    /// made via [`Self::set_name_servers`].
476    pub fn get_name_servers(&self) -> Vec<NameServerConfig> {
477        self.name_servers.load_full().as_ref().clone()
478    }
479
480    /// Set (or overwrite) the nameserver group used by this resolver. Since the underlying
481    /// resolver is immutable once built, this invalidates the internal (cached, lazily-built)
482    /// resolver: the next lookup made with this resolver rebuilds it using the new nameservers.
483    ///
484    /// If this resolver uses the shared underlying resolver, the shared nameserver group and
485    /// cached resolver are reset as well, so that other instances backed by the shared resolver
486    /// pick up the change the next time they need to (re)build it.
487    pub fn set_name_servers(&self, name_servers: Vec<NameServerConfig>) {
488        debug!("setting nameserver group to {name_servers:?}");
489        self.name_servers.store(Arc::new(name_servers));
490        self.state.store(Arc::new(OnceCell::new()));
491
492        if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
493            shared.name_servers.store(self.name_servers.load_full());
494            shared.state.store(Arc::new(OnceCell::new()));
495        }
496    }
497}
498
499/// Successfully resolved addresses are cached for a minimum of 30 minutes Individual lookup
500/// timeouts are set to `DEFAULT_QUERY_TIMEOUT` (5 seconds) Retries after lookup failure are
501/// disabled (`attempts = 0`) Lookup order is set to (default) A then AAAA Number or parallel lookup
502/// is set to (default) 2 Nameserver selection uses the (default) EWMA statistics / performance
503/// based strategy
504fn default_options() -> ResolverOpts {
505    let mut opts = ResolverOpts::default();
506    // Always cache successful responses for queries received by this resolver for 30 min minimum.
507    opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
508    opts.timeout = DEFAULT_QUERY_TIMEOUT;
509    opts.attempts = 0;
510
511    opts
512}
513
514fn configure_and_build_resolver<C: ConnectionProvider + Default>(
515    name_servers: Vec<NameServerConfig>,
516) -> Result<Resolver<C>, ResolveError> {
517    let options = default_options();
518    info!("building new configured resolver");
519    debug!("configuring resolver with {options:?}, {name_servers:?}");
520
521    let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
522    let mut resolver_builder = Resolver::<C>::builder_with_config(config, C::default());
523
524    resolver_builder = resolver_builder.with_options(options);
525
526    Ok(resolver_builder.build()?)
527}
528
529fn filter_ipv4(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
530    nameservers
531        .into_iter()
532        .filter(|ns| ns.ip.is_ipv4())
533        .collect()
534}
535
536#[allow(unused)]
537fn filter_ipv6(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
538    nameservers
539        .into_iter()
540        .filter(|ns| ns.ip.is_ipv6())
541        .collect()
542}
543
544fn default_nameserver_group() -> Vec<NameServerConfig> {
545    QUAD9
546        .tls()
547        .chain(QUAD9.https())
548        .chain(CLOUDFLARE.tls())
549        .chain(CLOUDFLARE.https())
550        .collect()
551}
552
553fn default_nameserver_group_ipv4_only() -> Vec<NameServerConfig> {
554    filter_ipv4(default_nameserver_group())
555}
556
557#[allow(unused)]
558fn default_nameserver_group_ipv6_only() -> Vec<NameServerConfig> {
559    filter_ipv6(default_nameserver_group())
560}
561
562/// Create a new resolver with the default configuration, which reads from the system DNS config
563/// (i.e. `/etc/resolv.conf` in unix). The options are overridden to look up for both IPv4 and IPv6
564/// addresses to work with "happy eyeballs" algorithm.
565fn new_resolver_system<C: ConnectionProvider + Default>() -> Result<Resolver<C>, ResolveError> {
566    let mut resolver_builder = Resolver::<C>::builder(C::default())?;
567
568    let options = default_options();
569    info!("building new fallback system resolver");
570    debug!("fallback system resolver with {options:?}");
571
572    resolver_builder = resolver_builder.with_options(options);
573
574    Ok(resolver_builder.build()?)
575}
576
577fn new_default_static_fallback() -> StaticResolver {
578    StaticResolver::new().with_fallback(constants::default_static_addrs())
579}
580
581#[cfg(test)]
582mod test;