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
74mod 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: Option<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: Some(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 maybe_static: Option<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 if let Some(ref static_resolver) = maybe_static {
270 let resolver = static_resolver
271 .get_or_init(|| HickoryDnsResolver::<C>::new_static_fallback(independent));
272
273 if let Some(addrs) = resolver.pre_resolve(name.as_str()) {
274 let addrs: Addrs =
275 Box::new(addrs.into_iter().map(|ip_addr| SocketAddr::new(ip_addr, 0)));
276 return Ok(addrs);
277 }
278 }
279
280 // Attempt a lookup using the primary resolver
281 let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
282 let primary_err = match resolve_fut.await {
283 Err(_) => ResolveError::Timeout,
284 Ok(Ok(lookup)) => {
285 // Shuffle so that successive connection attempts cycle through all
286 // returned IPs rather than always hitting the same first address.
287 let mut ips = Vec::from_iter(lookup.iter());
288 fastrand::shuffle(&mut ips);
289 let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
290 return Ok(addrs);
291 }
292 Ok(Err(e)) => {
293 // on failure use the fall back system configured DNS resolver
294 if !e.is_no_records_found() {
295 warn!("primary DNS failed w/ error: {e}");
296 }
297 e.into()
298 }
299 };
300
301 // If no record has been found and a static map of fallback addresses is configured
302 // check the table for our entry
303 if let Some(ref static_resolver) = maybe_static {
304 debug!("checking static");
305 let resolver = static_resolver
306 .get_or_init(|| HickoryDnsResolver::<C>::new_static_fallback(independent));
307
308 if let Ok(addrs) = resolver.resolve(name).await {
309 return Ok(addrs);
310 }
311 }
312
313 Err(primary_err)
314}
315
316impl<C: SharedResolverState> HickoryDnsResolver<C> {
317 /// Returns an instance of the process-wide shared resolver for this connection provider, if
318 /// one exists (see [`SharedResolverState`]). Falls back to an independent instance (as if
319 /// built via [`Self::default`]) for connection providers without shared state.
320 pub fn shared() -> Self {
321 C::shared_resolver().cloned().unwrap_or_default()
322 }
323
324 /// Attempt to resolve a domain name to a set of [`IpAddr`]s
325 pub async fn resolve_str(
326 &self,
327 name: &str,
328 ) -> Result<impl Iterator<Item = IpAddr> + use<C>, ResolveError> {
329 let n =
330 Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?;
331 let use_system = self.use_system.load(std::sync::atomic::Ordering::Relaxed);
332 let resolver = if use_system {
333 self.system_resolver
334 .get_or_try_init(|| Self::new_resolver_system(self.use_shared))?
335 .clone()
336 } else {
337 self.build_configured_resolver()?
338 };
339
340 resolve(
341 n,
342 resolver,
343 self.static_base.clone(),
344 self.use_shared,
345 self.overall_dns_timeout,
346 )
347 .await
348 .map(|addrs| addrs.map(|socket_addr| socket_addr.ip()))
349 }
350
351 /// Create a (lazy-initialized) resolver that is not shared across threads.
352 pub fn thread_resolver() -> Self {
353 Self {
354 use_shared: false,
355 ..Default::default()
356 }
357 }
358
359 /// Build (or fetch the already-built) configured resolver using the currently configured
360 /// nameserver group.
361 ///
362 /// When `use_shared` is set and shared state is available for `C` (see
363 /// [`SharedResolverState`]), every call consults the shared resolver's own cache directly
364 /// (`shared.state`) rather than copying the result into this instance's local cache: this
365 /// ensures a [`Self::set_name_servers`] reset made through any instance is visible on this
366 /// instance's very next lookup, instead of this instance being stuck with whatever it cached
367 /// the first time it resolved anything. Otherwise this instance's own local cache
368 /// (`self.state`) is used and (re)built independently.
369 fn build_configured_resolver(&self) -> Result<Resolver<C>, ResolveError> {
370 match self.use_shared.then(C::shared_resolver).flatten() {
371 Some(shared) => shared
372 .state
373 .load()
374 .get_or_try_init(|| {
375 configure_and_build_resolver::<C>(
376 shared.name_servers.load_full().as_ref().clone(),
377 )
378 })
379 .cloned(),
380 None => self
381 .state
382 .load()
383 .get_or_try_init(|| {
384 configure_and_build_resolver::<C>(
385 self.name_servers.load_full().as_ref().clone(),
386 )
387 })
388 .cloned(),
389 }
390 }
391
392 fn new_resolver_system(use_shared: bool) -> Result<Resolver<C>, ResolveError> {
393 // using a closure here is slightly gross, but this makes sure that if the
394 // lazy-init returns an error it can be handled by the client
395 match use_shared.then(C::shared_resolver).flatten() {
396 Some(shared) => Ok(shared
397 .system_resolver
398 .get_or_try_init(new_resolver_system::<C>)?
399 .clone()),
400 None => new_resolver_system::<C>(),
401 }
402 }
403
404 fn new_static_fallback(use_shared: bool) -> StaticResolver {
405 match use_shared.then(C::shared_resolver).flatten() {
406 Some(shared) if shared.static_base.is_some() => shared
407 .static_base
408 .as_ref()
409 .unwrap()
410 .get_or_init(new_default_static_fallback)
411 .clone(),
412 _ => new_default_static_fallback(),
413 }
414 }
415
416 /// Swap the primary internal resolver to the system resolver rather than the
417 /// configured custom resolver.
418 pub fn use_system_resolver(&self) {
419 self.use_system.store(true, Relaxed);
420
421 if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
422 shared.use_system_resolver();
423 }
424 }
425
426 /// Swap the primary internal resolver to the configured custom resolver rather than the
427 /// system resolver.
428 pub fn use_configured_resolver(&self) {
429 self.use_system.store(false, Relaxed);
430
431 if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
432 shared.use_configured_resolver();
433 }
434 }
435
436 /// Clear entries from the static table that would return entries during the pre-resolve stage.
437 /// This means that all lookups will attempt to use the network resolver again before the static
438 /// table is consulted.
439 ///
440 /// Entries elevated to pre-resolve from fallback (added from default or using
441 /// [`Self::set_fallback_addrs`]) will have their cache timeout cleared. Entries added directly
442 /// to pre-resolve (using [`Self::set_static_preresolve`]) will be removed.
443 pub fn clear_preresolve(&self) {
444 debug!("clearing pre-resolve table");
445 if let Some(cell) = &self.static_base
446 && let Some(static_base) = cell.get()
447 {
448 static_base.clear_preresolve()
449 }
450 }
451
452 /// Get the current map of hostnames to addresses used in the fallback static lookup stage if one
453 /// exists.
454 pub fn get_static_fallbacks(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
455 Some(self.static_base.as_ref()?.get()?.get_fallback_addrs())
456 }
457
458 /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup.
459 pub fn set_fallback_addrs(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
460 debug!("setting fallback entries for {:?}", addrs.keys());
461 if self.static_base.is_none() {
462 let cell = OnceCell::new();
463 self.static_base = Some(Arc::new(cell));
464 }
465 self.static_base
466 .as_ref()
467 .unwrap()
468 .get_or_init(|| Self::new_static_fallback(self.use_shared))
469 .set_fallback(addrs);
470 }
471
472 /// Get the current map of hostnames to addresses used in the preresolve static lookup stage
473 /// if one exists.
474 pub fn get_static_preresolve(&self) -> Option<HashMap<String, Vec<IpAddr>>> {
475 Some(self.static_base.as_ref()?.get()?.get_preresolve_addrs())
476 }
477
478 /// Set (or overwrite) the map of addresses used in the preresolve static hostname lookup.
479 pub fn set_static_preresolve(&mut self, addrs: HashMap<String, Vec<IpAddr>>) {
480 debug!("setting pre-resolve entries for {:?}", addrs.keys());
481 if self.static_base.is_none() {
482 let cell = OnceCell::new();
483 self.static_base = Some(Arc::new(cell));
484 }
485 self.static_base
486 .as_ref()
487 .unwrap()
488 .get_or_init(|| Self::new_static_fallback(self.use_shared))
489 .set_preresolve(addrs);
490 }
491
492 /// Get the full default set of known nameserver configs (Cloudflare and Quad9, DoT and DoH,
493 /// IPv4 and IPv6). Unlike [`Self::get_name_servers`], this is independent of any override
494 /// applied via [`Self::set_name_servers`].
495 pub fn default_name_servers(&self) -> Vec<NameServerConfig> {
496 default_nameserver_group()
497 }
498
499 /// Get the nameserver group currently configured for this resolver, reflecting any change
500 /// made via [`Self::set_name_servers`].
501 pub fn get_name_servers(&self) -> Vec<NameServerConfig> {
502 self.name_servers.load_full().as_ref().clone()
503 }
504
505 /// Set (or overwrite) the nameserver group used by this resolver. Since the underlying
506 /// resolver is immutable once built, this invalidates the internal (cached, lazily-built)
507 /// resolver: the next lookup made with this resolver rebuilds it using the new nameservers.
508 ///
509 /// If this resolver uses the shared underlying resolver, the shared nameserver group and
510 /// cached resolver are reset as well, so that other instances backed by the shared resolver
511 /// pick up the change the next time they need to (re)build it.
512 pub fn set_name_servers(&self, name_servers: Vec<NameServerConfig>) {
513 debug!("setting nameserver group to {name_servers:?}");
514 self.name_servers.store(Arc::new(name_servers));
515 self.state.store(Arc::new(OnceCell::new()));
516
517 if let Some(shared) = self.use_shared.then(C::shared_resolver).flatten() {
518 shared.name_servers.store(self.name_servers.load_full());
519 shared.state.store(Arc::new(OnceCell::new()));
520 }
521 }
522}
523
524/// Successfully resolved addresses are cached for a minimum of 30 minutes Individual lookup
525/// timeouts are set to `DEFAULT_QUERY_TIMEOUT` (5 seconds) Retries after lookup failure are
526/// disabled (`attempts = 0`) Lookup order is set to (default) A then AAAA Number or parallel lookup
527/// is set to (default) 2 Nameserver selection uses the (default) EWMA statistics / performance
528/// based strategy
529fn default_options() -> ResolverOpts {
530 let mut opts = ResolverOpts::default();
531 // Always cache successful responses for queries received by this resolver for 30 min minimum.
532 opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
533 opts.timeout = DEFAULT_QUERY_TIMEOUT;
534 opts.attempts = 0;
535
536 opts
537}
538
539fn configure_and_build_resolver<C: ConnectionProvider + Default>(
540 name_servers: Vec<NameServerConfig>,
541) -> Result<Resolver<C>, ResolveError> {
542 let options = default_options();
543 info!("building new configured resolver");
544 debug!("configuring resolver with {options:?}, {name_servers:?}");
545
546 let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
547 let mut resolver_builder = Resolver::<C>::builder_with_config(config, C::default());
548
549 resolver_builder = resolver_builder.with_options(options);
550
551 Ok(resolver_builder.build()?)
552}
553
554fn filter_ipv4(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
555 nameservers
556 .into_iter()
557 .filter(|ns| ns.ip.is_ipv4())
558 .collect()
559}
560
561#[allow(unused)]
562fn filter_ipv6(nameservers: impl IntoIterator<Item = NameServerConfig>) -> Vec<NameServerConfig> {
563 nameservers
564 .into_iter()
565 .filter(|ns| ns.ip.is_ipv6())
566 .collect()
567}
568
569fn default_nameserver_group() -> Vec<NameServerConfig> {
570 QUAD9
571 .tls()
572 .chain(QUAD9.https())
573 .chain(CLOUDFLARE.tls())
574 .chain(CLOUDFLARE.https())
575 .collect()
576}
577
578fn default_nameserver_group_ipv4_only() -> Vec<NameServerConfig> {
579 filter_ipv4(default_nameserver_group())
580}
581
582#[allow(unused)]
583fn default_nameserver_group_ipv6_only() -> Vec<NameServerConfig> {
584 filter_ipv6(default_nameserver_group())
585}
586
587/// Create a new resolver with the default configuration, which reads from the system DNS config
588/// (i.e. `/etc/resolv.conf` in unix). The options are overridden to look up for both IPv4 and IPv6
589/// addresses to work with "happy eyeballs" algorithm.
590fn new_resolver_system<C: ConnectionProvider + Default>() -> Result<Resolver<C>, ResolveError> {
591 let mut resolver_builder = Resolver::<C>::builder(C::default())?;
592
593 let options = default_options();
594 info!("building new fallback system resolver");
595 debug!("fallback system resolver with {options:?}");
596
597 resolver_builder = resolver_builder.with_options(options);
598
599 Ok(resolver_builder.build()?)
600}
601
602fn new_default_static_fallback() -> StaticResolver {
603 StaticResolver::new().with_fallback(constants::default_static_addrs())
604}
605
606#[cfg(test)]
607mod test;