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;