From 1400db6156a7aa17946f540ab428b94d0708b194 Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 6 Nov 2025 12:37:27 -0700 Subject: [PATCH 1/5] DNS Reliability Fixes (#6175) --- Cargo.lock | 1 + common/client-core/src/error.rs | 2 +- .../client-libs/gateway-client/src/error.rs | 2 +- common/http-api-client/Cargo.toml | 4 +- common/http-api-client/src/dns.rs | 148 +++++++++++++++--- common/http-api-client/src/lib.rs | 2 +- 6 files changed, 132 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d5dd1e0db..56354ba68f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6057,6 +6057,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tracing", + "tracing-subscriber", "url", "wasmtimer", ] diff --git a/common/client-core/src/error.rs b/common/client-core/src/error.rs index dc6e4fc1b2..eea24910d0 100644 --- a/common/client-core/src/error.rs +++ b/common/client-core/src/error.rs @@ -45,7 +45,7 @@ pub enum ClientCoreError { #[cfg(not(target_arch = "wasm32"))] #[error("resolution failed: {0}")] - ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError), + ResolutionFailed(#[from] nym_http_api_client::ResolveError), #[error("no gateways on network")] NoGatewaysOnNetwork, diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index 8766f9f5a6..eaea37c586 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -54,7 +54,7 @@ pub enum GatewayClientError { #[cfg(not(target_arch = "wasm32"))] #[error("resolution failed: {0}")] - ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError), + ResolutionFailed(#[from] nym_http_api_client::ResolveError), #[error("No shared key was provided or obtained")] NoSharedKeyAvailable, diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index 35da8e0e11..411f9ad846 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -32,6 +32,7 @@ thiserror = { workspace = true } tracing = { workspace = true } itertools = { workspace = true } inventory = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros", "time"] } # used for decoding text responses (they were already implicitly included) bytes = { workspace = true } @@ -52,5 +53,4 @@ workspace = true features = ["tokio"] [dev-dependencies] -tokio = { workspace = true, features = ["rt", "macros"] } - +tracing-subscriber.workspace = true diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index d3fa9dde35..016f4383f7 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -32,17 +32,18 @@ use crate::ClientBuilder; use std::{ net::SocketAddr, sync::{Arc, LazyLock}, + time::Duration, }; use hickory_resolver::{ - ResolveError, TokioResolver, + TokioResolver, config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, lookup_ip::{LookupIp, LookupIpIntoIter}, name_server::TokioConnectionProvider, }; use once_cell::sync::OnceCell; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use tracing::warn; +use tracing::{info, warn}; impl ClientBuilder { /// Override the DNS resolver implementation used by the underlying http client. @@ -72,11 +73,13 @@ static SHARED_RESOLVER: LazyLock = LazyLock::new(|| { }); #[derive(Debug, thiserror::Error)] -#[error("hickory-dns resolver error: {hickory_error}")] +#[allow(missing_docs)] /// Error occurring while resolving a hostname into an IP address. -pub struct HickoryDnsError { - #[from] - hickory_error: ResolveError, +pub enum ResolveError { + #[error("hickory-dns resolver error: {0}")] + HickoryDnsError(#[from] hickory_resolver::ResolveError), + #[error("high level lookup timed out")] + Timeout, } /// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. @@ -87,7 +90,7 @@ pub struct HickoryDnsError { /// The default initialization uses a shared underlying `AsyncResolver`. If a thread local resolver /// is required use `thread_resolver()` to build a resolver with an independently instantiated /// internal `AsyncResolver`. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct HickoryDnsResolver { // Since we might not have been called in the context of a // Tokio Runtime in initialization, so we must delay the actual @@ -95,6 +98,20 @@ pub struct HickoryDnsResolver { state: Arc>, fallback: Option>>, dont_use_shared: bool, + /// Overall timeout for dns lookup associated with any individual host resolution. For example, + /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached. + overall_dns_timeout: Duration, +} + +impl Default for HickoryDnsResolver { + fn default() -> Self { + Self { + state: Default::default(), + fallback: Default::default(), + dont_use_shared: Default::default(), + overall_dns_timeout: Duration::from_secs(10), + } + } } impl Resolve for HickoryDnsResolver { @@ -102,6 +119,7 @@ impl Resolve for HickoryDnsResolver { let resolver = self.state.clone(); let maybe_fallback = self.fallback.clone(); let independent = self.dont_use_shared; + let overall_dns_timeout = self.overall_dns_timeout; Box::pin(async move { let resolver = resolver.get_or_try_init(|| { // using a closure here is slightly gross, but this makes sure that if the @@ -113,10 +131,14 @@ impl Resolve for HickoryDnsResolver { } })?; + let resolve_fut = + tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolver.lookup_ip(name.as_str()).await { - Ok(res) => res, - Err(e) => { + let lookup = match resolve_fut.await { + Ok(Ok(res)) => res, + Err(_) => return Err(ResolveError::Timeout.into()), + Ok(Err(e)) => { if let Some(ref fallback) = maybe_fallback { // on failure use the fall back system configured DNS resolver if !e.is_no_records_found() { @@ -162,13 +184,15 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { + pub async fn resolve_str(&self, name: &str) -> Result { let resolver = self.state.get_or_try_init(|| self.new_resolver())?; + let resolve_fut = tokio::time::timeout(self.overall_dns_timeout, resolver.lookup_ip(name)); // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolver.lookup_ip(name).await { - Ok(res) => res, - Err(e) => { + let lookup = match resolve_fut.await { + Ok(Ok(res)) => res, + Err(_) => return Err(ResolveError::Timeout), + Ok(Err(e)) => { if let Some(ref fallback) = self.fallback { // on failure use the fall back system configured DNS resolver if !e.is_no_records_found() { @@ -194,7 +218,7 @@ impl HickoryDnsResolver { } } - fn new_resolver(&self) -> Result { + fn new_resolver(&self) -> Result { if self.dont_use_shared { new_resolver() } else { @@ -202,7 +226,7 @@ impl HickoryDnsResolver { } } - fn new_resolver_system(&self) -> Result { + fn new_resolver_system(&self) -> Result { if self.dont_use_shared || SHARED_RESOLVER.fallback.is_none() { new_resolver_system() } else { @@ -216,7 +240,7 @@ impl HickoryDnsResolver { } /// Enable fallback to the system default resolver if the primary (DoX) resolver fails - pub fn enable_system_fallback(&mut self) -> Result<(), HickoryDnsError> { + pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> { self.fallback = Some(Default::default()); let _ = self .fallback @@ -235,18 +259,34 @@ impl HickoryDnsResolver { /// Create a new resolver with a custom DoT based configuration. The options are overridden to look /// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm. -fn new_resolver() -> Result { +/// +/// Timeout Defaults to 5 seconds +/// Number of retries after lookup failure before giving up Defaults to 2 +/// +/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup. +/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs. +fn new_resolver() -> Result { + info!("building new configured resolver"); + let mut name_servers = NameServerConfigGroup::quad9_tls(); name_servers.merge(NameServerConfigGroup::quad9_https()); name_servers.merge(NameServerConfigGroup::cloudflare_tls()); name_servers.merge(NameServerConfigGroup::cloudflare_https()); + configure_and_build_resolver(name_servers) +} + +fn configure_and_build_resolver( + name_servers: NameServerConfigGroup, +) -> Result { let config = ResolverConfig::from_parts(None, Vec::new(), name_servers); let mut resolver_builder = TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; resolver_builder.options_mut().server_ordering_strategy = ServerOrderingStrategy::RoundRobin; + // Cache successful responses for queries received by this resolver for 30 min minimum. + resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800)); Ok(resolver_builder.build()) } @@ -254,7 +294,7 @@ fn new_resolver() -> Result { /// Create a new resolver with the default configuration, which reads from the system DNS config /// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6 /// addresses to work with "happy eyeballs" algorithm. -fn new_resolver_system() -> Result { +fn new_resolver_system() -> Result { let mut resolver_builder = TokioResolver::builder_tokio()?; resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; @@ -266,8 +306,9 @@ mod test { use super::*; #[tokio::test] - async fn reqwest_hickory_doh() { - let resolver = HickoryDnsResolver::default(); + async fn reqwest_with_custom_dns() { + let var_name = HickoryDnsResolver::default(); + let resolver = var_name; let client = reqwest::ClientBuilder::new() .dns_resolver(resolver.into()) .build() @@ -286,7 +327,7 @@ mod test { } #[tokio::test] - async fn dns_lookup() -> Result<(), HickoryDnsError> { + async fn dns_lookup() -> Result<(), ResolveError> { let resolver = HickoryDnsResolver::default(); let domain = "ifconfig.me"; @@ -297,3 +338,66 @@ mod test { Ok(()) } } + +#[cfg(test)] +mod failure_test { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + /// IP addresses guaranteed to fail attempts to resolve + /// + /// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6) + const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)), + ]; + + // Create a resolver that behaves the same as the custom configured router, except for the fact + // that it is guaranteed to fail. + fn build_broken_resolver() -> Result { + info!("building new faulty resolver"); + + let mut broken_ns_group = NameServerConfigGroup::from_ips_tls( + GUARANTEED_BROKEN_IPS_1, + 853, + "cloudflare-dns.com".to_string(), + true, + ); + let broken_ns_https = NameServerConfigGroup::from_ips_https( + GUARANTEED_BROKEN_IPS_1, + 443, + "cloudflare-dns.com".to_string(), + true, + ); + broken_ns_group.merge(broken_ns_https); + + configure_and_build_resolver(broken_ns_group) + } + + #[tokio::test] + async fn dns_lookup_failures() -> Result<(), ResolveError> { + let time_start = std::time::Instant::now(); + + let r = OnceCell::new(); + r.set(build_broken_resolver().expect("failed to build resolver")) + .expect("broken resolver init error"); + + // create a new resolver that won't mess with the shared resolver used by other tests + let resolver = HickoryDnsResolver { + dont_use_shared: true, + state: Arc::new(r), + ..Default::default() + }; + build_broken_resolver()?; + let domain = "ifconfig.me"; + let result = resolver.resolve_str(domain).await; + assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout))); + + let duration = time_start.elapsed(); + assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1)); + + Ok(()) + } +} diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 220d151ba7..f2c4e09742 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -179,7 +179,7 @@ mod dns; mod path; #[cfg(not(target_arch = "wasm32"))] -pub use dns::{HickoryDnsError, HickoryDnsResolver}; +pub use dns::{HickoryDnsResolver, ResolveError}; // helper for generating user agent based on binary information #[cfg(not(target_arch = "wasm32"))] From 806f807f02a3f0fa00920b6804fcbb5737288a7d Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 6 Nov 2025 16:46:39 -0700 Subject: [PATCH 2/5] Implement Static DNS fallback (#6178) --- common/http-api-client/Cargo.toml | 1 - common/http-api-client/src/dns.rs | 275 ++++++++++++------ common/http-api-client/src/dns/constants.rs | 73 +++++ .../src/dns/static_resolver.rs | 89 ++++++ 4 files changed, 356 insertions(+), 82 deletions(-) create mode 100644 common/http-api-client/src/dns/constants.rs create mode 100644 common/http-api-client/src/dns/static_resolver.rs diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index 411f9ad846..e748e37888 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -33,7 +33,6 @@ tracing = { workspace = true } itertools = { workspace = true } inventory = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "time"] } - # used for decoding text responses (they were already implicitly included) bytes = { workspace = true } encoding_rs = { workspace = true } diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 016f4383f7..158a867f4e 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -30,7 +30,9 @@ use crate::ClientBuilder; use std::{ - net::SocketAddr, + collections::HashMap, + net::{IpAddr, SocketAddr}, + str::FromStr, sync::{Arc, LazyLock}, time::Duration, }; @@ -38,12 +40,16 @@ use std::{ use hickory_resolver::{ TokioResolver, config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, - lookup_ip::{LookupIp, LookupIpIntoIter}, + lookup_ip::LookupIpIntoIter, name_server::TokioConnectionProvider, }; use once_cell::sync::OnceCell; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use tracing::{info, warn}; +use tracing::*; + +mod constants; +mod static_resolver; +pub use static_resolver::*; impl ClientBuilder { /// Override the DNS resolver implementation used by the underlying http client. @@ -60,10 +66,6 @@ impl ClientBuilder { } } -struct SocketAddrs { - iter: LookupIpIntoIter, -} - // n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated. // this is fine, as the OS can deallocate the terminated program faster than we can free memory // but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional. @@ -76,10 +78,14 @@ static SHARED_RESOLVER: LazyLock = LazyLock::new(|| { #[allow(missing_docs)] /// Error occurring while resolving a hostname into an IP address. pub enum ResolveError { + #[error("invalid name: {0}")] + InvalidNameError(String), #[error("hickory-dns resolver error: {0}")] - HickoryDnsError(#[from] hickory_resolver::ResolveError), + ResolveError(#[from] hickory_resolver::ResolveError), #[error("high level lookup timed out")] Timeout, + #[error("hostname not found in static lookup table")] + StaticLookupMiss, } /// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. @@ -97,6 +103,7 @@ pub struct HickoryDnsResolver { // construction of the resolver. state: Arc>, fallback: Option>>, + static_base: Option>>, dont_use_shared: bool, /// Overall timeout for dns lookup associated with any individual host resolution. For example, /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached. @@ -108,6 +115,7 @@ impl Default for HickoryDnsResolver { Self { state: Default::default(), fallback: Default::default(), + static_base: Default::default(), dont_use_shared: Default::default(), overall_dns_timeout: Duration::from_secs(10), } @@ -118,60 +126,86 @@ impl Resolve for HickoryDnsResolver { fn resolve(&self, name: Name) -> Resolving { let resolver = self.state.clone(); let maybe_fallback = self.fallback.clone(); + let maybe_static = self.static_base.clone(); let independent = self.dont_use_shared; let overall_dns_timeout = self.overall_dns_timeout; Box::pin(async move { - let resolver = resolver.get_or_try_init(|| { - // using a closure here is slightly gross, but this makes sure that if the - // lazy-init returns an error it can be handled by the client - if independent { - new_resolver() - } else { - Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone()) - } - })?; + resolve( + name, + resolver, + maybe_fallback, + maybe_static, + independent, + overall_dns_timeout, + ) + .await + .map_err(|e| Box::new(e) as Box) + }) + } +} - let resolve_fut = - tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); - - // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolve_fut.await { - Ok(Ok(res)) => res, - Err(_) => return Err(ResolveError::Timeout.into()), - Ok(Err(e)) => { - if let Some(ref fallback) = maybe_fallback { - // on failure use the fall back system configured DNS resolver - if !e.is_no_records_found() { - warn!("primary DNS failed w/ error {e}: using system fallback"); - } - let resolver = fallback.get_or_try_init(|| { - // using a closure here is slightly gross, but this makes sure that if the - // lazy-init returns an error it can be handled by the client - if independent { - new_resolver_system() - } else { - Ok(SHARED_RESOLVER - .fallback - .as_ref() - .ok_or(e)? // if the shared resolver has no fallback return the original error - .get_or_try_init(new_resolver_system)? - .clone()) - } - })?; - - resolver.lookup_ip(name.as_str()).await? - } else { - return Err(e.into()); - } - } - }; +async fn resolve( + name: Name, + resolver: Arc>, + maybe_fallback: Option>>, + maybe_static: Option>>, + independent: bool, + overall_dns_timeout: Duration, +) -> Result { + let resolver = resolver.get_or_try_init(|| HickoryDnsResolver::new_resolver(independent))?; + // Attempt a lookup using the primary resolver + let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + let primary_err = match resolve_fut.await { + Err(_) => ResolveError::Timeout, + Ok(Ok(lookup)) => { let addrs: Addrs = Box::new(SocketAddrs { iter: lookup.into_iter(), }); - Ok(addrs) - }) + return Ok(addrs); + } + Ok(Err(e)) => { + // on failure use the fall back system configured DNS resolver + if !e.is_no_records_found() { + warn!("primary DNS failed w/ error: {e}"); + } + e.into() + } + }; + + // If the primary resolver encountered an error, attempt a lookup using the fallback + // resolver if one is configured. + if let Some(ref fallback) = maybe_fallback { + let resolver = + fallback.get_or_try_init(|| HickoryDnsResolver::new_resolver_system(independent))?; + + let resolve_fut = + tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + if let Ok(Ok(lookup)) = resolve_fut.await { + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + return Ok(addrs); + } } + + // If no record has been found and a static map of fallback addresses is configured + // check the table for our entry + if let Some(ref static_resolver) = maybe_static { + debug!("checking static"); + let resolver = + static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent)); + + if let Ok(addrs) = resolver.resolve(name).await { + return Ok(addrs); + } + } + + Err(primary_err) +} + +struct SocketAddrs { + iter: LookupIpIntoIter, } impl Iterator for SocketAddrs { @@ -184,30 +218,18 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { - let resolver = self.state.get_or_try_init(|| self.new_resolver())?; - let resolve_fut = tokio::time::timeout(self.overall_dns_timeout, resolver.lookup_ip(name)); - - // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolve_fut.await { - Ok(Ok(res)) => res, - Err(_) => return Err(ResolveError::Timeout), - Ok(Err(e)) => { - if let Some(ref fallback) = self.fallback { - // on failure use the fall back system configured DNS resolver - if !e.is_no_records_found() { - warn!("primary DNS failed w/ error {e}: using system fallback"); - } - - let resolver = fallback.get_or_try_init(|| self.new_resolver_system())?; - resolver.lookup_ip(name).await? - } else { - return Err(e.into()); - } - } - }; - - Ok(lookup) + pub async fn resolve_str(&self, name: &str) -> Result { + let n = + Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?; + resolve( + n, + self.state.clone(), + self.fallback.clone(), + self.static_base.clone(), + self.dont_use_shared, + self.overall_dns_timeout, + ) + .await } /// Create a (lazy-initialized) resolver that is not shared across threads. @@ -218,16 +240,20 @@ impl HickoryDnsResolver { } } - fn new_resolver(&self) -> Result { - if self.dont_use_shared { + fn new_resolver(dont_use_shared: bool) -> Result { + // using a closure here is slightly gross, but this makes sure that if the + // lazy-init returns an error it can be handled by the client + if dont_use_shared { new_resolver() } else { Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone()) } } - fn new_resolver_system(&self) -> Result { - if self.dont_use_shared || SHARED_RESOLVER.fallback.is_none() { + fn new_resolver_system(dont_use_shared: bool) -> Result { + // using a closure here is slightly gross, but this makes sure that if the + // lazy-init returns an error it can be handled by the client + if dont_use_shared || SHARED_RESOLVER.fallback.is_none() { new_resolver_system() } else { Ok(SHARED_RESOLVER @@ -239,6 +265,16 @@ impl HickoryDnsResolver { } } + fn new_static_fallback(dont_use_shared: bool) -> StaticResolver { + if !dont_use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base { + shared_resolver + .get_or_init(new_default_static_fallback) + .clone() + } else { + new_default_static_fallback() + } + } + /// Enable fallback to the system default resolver if the primary (DoX) resolver fails pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> { self.fallback = Some(Default::default()); @@ -255,6 +291,20 @@ impl HickoryDnsResolver { pub fn disable_system_fallback(&mut self) { self.fallback = None; } + + /// Get the current map of hostname to address in use by the fallback static lookup if one + /// exists. + pub fn get_static_fallbacks(&self) -> Option>> { + Some(self.static_base.as_ref()?.get()?.get_addrs()) + } + + /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup + pub fn set_static_fallbacks(&mut self, addrs: HashMap>) { + let cell = OnceCell::new(); + cell.set(StaticResolver::new(addrs)) + .expect("infallible assign"); + self.static_base = Some(Arc::new(cell)); + } } /// Create a new resolver with a custom DoT based configuration. The options are overridden to look @@ -301,9 +351,15 @@ fn new_resolver_system() -> Result { Ok(resolver_builder.build()) } +fn new_default_static_fallback() -> StaticResolver { + StaticResolver::new(constants::default_static_addrs()) +} + #[cfg(test)] mod test { use super::*; + use itertools::Itertools; + use std::collections::HashMap; #[tokio::test] async fn reqwest_with_custom_dns() { @@ -337,6 +393,31 @@ mod test { Ok(()) } + + #[tokio::test] + async fn static_resolver_as_fallback() -> Result<(), ResolveError> { + let example_domain = "non-existent.nymvpn.com"; + let mut resolver = HickoryDnsResolver { + ..Default::default() + }; + + let result = resolver.resolve_str(example_domain).await; + assert!(result.is_err()); // should be NXDomain + + resolver.static_base = Some(Default::default()); + + let mut addr_map = HashMap::new(); + let example_ip4: IpAddr = "10.10.10.10".parse().unwrap(); + let example_ip6: IpAddr = "dead::beef".parse().unwrap(); + addr_map.insert(example_domain.to_string(), vec![example_ip4, example_ip6]); + + resolver.set_static_fallbacks(addr_map); + + let mut addrs = resolver.resolve_str(example_domain).await?; + assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); + assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + Ok(()) + } } #[cfg(test)] @@ -388,6 +469,7 @@ mod failure_test { let resolver = HickoryDnsResolver { dont_use_shared: true, state: Arc::new(r), + overall_dns_timeout: Duration::from_secs(5), ..Default::default() }; build_broken_resolver()?; @@ -400,4 +482,35 @@ mod failure_test { Ok(()) } + + #[tokio::test] + async fn fallback_to_static() -> Result<(), ResolveError> { + let r = OnceCell::new(); + r.set(build_broken_resolver().expect("failed to build resolver")) + .expect("broken resolver init error"); + + // create a new resolver that won't mess with the shared resolver used by other tests + let resolver = HickoryDnsResolver { + dont_use_shared: true, + state: Arc::new(r), + static_base: Some(Default::default()), + overall_dns_timeout: Duration::from_secs(5), + ..Default::default() + }; + build_broken_resolver()?; + + // successful lookup using fallback to static resolver + let domain = "nymvpn.com"; + let _ = resolver + .resolve_str(domain) + .await + .expect("failed to resolve address in static lookup"); + + // unsuccessful lookup - primary times out, and not in + let domain = "non-existent.nymtech.net"; + let result = resolver.resolve_str(domain).await; + assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout))); + + Ok(()) + } } diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs new file mode 100644 index 0000000000..0672188f9b --- /dev/null +++ b/common/http-api-client/src/dns/constants.rs @@ -0,0 +1,73 @@ +#![allow(missing_docs)] + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +pub const NYM_API_DOMAIN: &str = "validator.nymtech.net"; +pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))]; + +pub const NYM_VPN_API_DOMAIN: &str = "nymvpn.com"; +pub const NYM_VPN_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 21))]; + +pub const NYM_FRONTDOOR_VERCEL_DOMAIN: &str = "nym-frontdoor.vercel.app"; +pub const NYM_FRONTDOOR_VERCEL_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), + IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)), +]; + +pub const NYM_FRONTDOOR_FASTLY_DOMAIN: &str = "nym-frontdoor.global.ssl.fastly.net"; +pub const NYM_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + +pub const NYMVPN_FRONTDOOR_FASTLY_DOMAIN: &str = "nymvpn-frontdoor.global.ssl.fastly.net"; +pub const NYMVPN_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + +pub const VERCEL_APP_DOMAIN: &str = "vercel.app"; +pub const VERCEL_APP_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), + IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)), +]; + +pub const VERCEL_COM_DOMAIN: &str = "vercel.com"; +pub const VERCEL_COM_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(198, 169, 2, 129)), + IpAddr::V4(Ipv4Addr::new(198, 169, 1, 193)), +]; + +pub const NYM_COM_DOMAIN: &str = "nym.com"; +pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))]; + +pub const NYM_STATS_API_DOMAIN: &str = "nym-statistics-api.nymtech.cc"; +pub const NYM_STATS_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(91, 92, 153, 96))]; + +pub fn default_static_addrs() -> HashMap> { + let mut m = HashMap::new(); + m.insert(NYM_API_DOMAIN.to_string(), NYM_API_IPS.to_vec()); + m.insert(NYM_VPN_API_DOMAIN.to_string(), NYM_VPN_API_IPS.to_vec()); + m.insert( + NYM_FRONTDOOR_VERCEL_DOMAIN.to_string(), + NYM_FRONTDOOR_VERCEL_IPS.to_vec(), + ); + m.insert( + NYM_FRONTDOOR_FASTLY_DOMAIN.to_string(), + NYM_FRONTDOOR_FASTLY_IPS.to_vec(), + ); + m.insert( + NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), + NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), + ); + m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); + m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); + m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec()); + m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec()); + m +} diff --git a/common/http-api-client/src/dns/static_resolver.rs b/common/http-api-client/src/dns/static_resolver.rs new file mode 100644 index 0000000000..030ae4419d --- /dev/null +++ b/common/http-api-client/src/dns/static_resolver.rs @@ -0,0 +1,89 @@ +use crate::dns::ResolveError; + +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex}, +}; + +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use tracing::*; + +#[derive(Debug, Default, Clone)] +pub struct StaticResolver { + static_addr_map: Arc>>>, +} + +impl StaticResolver { + pub fn new(static_entries: HashMap>) -> StaticResolver { + debug!("building static resolver"); + Self { + static_addr_map: Arc::new(Mutex::new(static_entries)), + } + } + + pub fn get_addrs(&self) -> HashMap> { + self.static_addr_map.lock().unwrap().clone() + } +} + +impl Resolve for StaticResolver { + fn resolve(&self, name: Name) -> Resolving { + debug!("looking up {name:?} in static resolver"); + let addr_map = self.static_addr_map.clone(); + Box::pin(async move { + let addr_map = addr_map.lock().unwrap(); + let lookup = match addr_map.get(name.as_str()) { + None => return Err(ResolveError::StaticLookupMiss.into()), + Some(addrs) => addrs, + }; + let addrs: Addrs = Box::new( + lookup + .clone() + .into_iter() + .map(|ip_addr| SocketAddr::new(ip_addr, 0)), + ); + + Ok(addrs) + }) + } +} + +#[cfg(test)] +mod test { + use itertools::Itertools; + + use super::*; + use std::error::Error as StdError; + use std::str::FromStr; + + #[tokio::test] + async fn lookup_using_static_resolver() -> Result<(), Box> { + let example_domain = String::from("static.nymvpn.com"); + + // lookup for domain for which there is no entry + let resolver = StaticResolver::new(HashMap::new()); + + let url = reqwest::dns::Name::from_str(&example_domain).unwrap(); + let result = resolver.resolve(url).await; + assert!(result.is_err()); + match result { + Ok(_) => panic!("lookup with empty map should fail"), + Err(e) => assert_eq!(e.to_string(), ResolveError::StaticLookupMiss.to_string()), + } + + // Successful lookup + let mut addr_map = HashMap::new(); + let example_ip4: IpAddr = "10.10.10.10".parse().unwrap(); + let example_ip6: IpAddr = "dead::beef".parse().unwrap(); + addr_map.insert(example_domain.clone(), vec![example_ip4, example_ip6]); + + let url = reqwest::dns::Name::from_str(&example_domain).unwrap(); + let resolver = StaticResolver::new(addr_map); + let mut addrs = resolver.resolve(url).await?; + assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); + assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + + Ok(()) + } +} From 2f752a6c421ea52b729830c9e7e368652fad9441 Mon Sep 17 00:00:00 2001 From: jmwample Date: Thu, 6 Nov 2025 18:37:50 -0700 Subject: [PATCH 3/5] fix things related to interface changes --- common/client-core/src/init/websockets.rs | 1 - .../gateway-client/src/client/websockets.rs | 1 - common/http-api-client/src/dns.rs | 10 +++++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/common/client-core/src/init/websockets.rs b/common/client-core/src/init/websockets.rs index 6d09a325b0..ba407623a6 100644 --- a/common/client-core/src/init/websockets.rs +++ b/common/client-core/src/init/websockets.rs @@ -30,7 +30,6 @@ pub(crate) async fn connect_async( resolver .resolve_str(domain) .await? - .into_iter() .map(|a| SocketAddr::new(a, port)) .collect() } diff --git a/common/client-libs/gateway-client/src/client/websockets.rs b/common/client-libs/gateway-client/src/client/websockets.rs index 9b04c9e028..2336549d5e 100644 --- a/common/client-libs/gateway-client/src/client/websockets.rs +++ b/common/client-libs/gateway-client/src/client/websockets.rs @@ -39,7 +39,6 @@ pub(crate) async fn connect_async( resolver .resolve_str(domain) .await? - .into_iter() .map(|a| SocketAddr::new(a, port)) .collect() } diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 158a867f4e..216fef032b 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -218,7 +218,10 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { + pub async fn resolve_str( + &self, + name: &str, + ) -> Result + use<>, ResolveError> { let n = Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?; resolve( @@ -230,6 +233,7 @@ impl HickoryDnsResolver { self.overall_dns_timeout, ) .await + .map(|addrs| addrs.map(|socket_addr| socket_addr.ip())) } /// Create a (lazy-initialized) resolver that is not shared across threads. @@ -414,8 +418,8 @@ mod test { resolver.set_static_fallbacks(addr_map); let mut addrs = resolver.resolve_str(example_domain).await?; - assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); - assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + assert!(addrs.contains(&example_ip4)); + assert!(addrs.contains(&example_ip6)); Ok(()) } } From 5acce42c642666076739cbf060245bae3e34f066 Mon Sep 17 00:00:00 2001 From: jmwample Date: Tue, 11 Nov 2025 09:14:26 -0700 Subject: [PATCH 4/5] add some staic hosts and switch server strategy --- common/http-api-client/src/dns.rs | 1 - common/http-api-client/src/dns/constants.rs | 25 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 216fef032b..d211c3e221 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -338,7 +338,6 @@ fn configure_and_build_resolver( TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - resolver_builder.options_mut().server_ordering_strategy = ServerOrderingStrategy::RoundRobin; // Cache successful responses for queries received by this resolver for 30 min minimum. resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800)); diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs index 0672188f9b..d879d653a4 100644 --- a/common/http-api-client/src/dns/constants.rs +++ b/common/http-api-client/src/dns/constants.rs @@ -31,6 +31,14 @@ pub const NYMVPN_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), ]; +pub const YELP_FASTLY_DOMAIN: &str = "yelp.global.ssl.fastly.net"; +pub const YELP_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + pub const VERCEL_APP_DOMAIN: &str = "vercel.app"; pub const VERCEL_APP_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), @@ -49,6 +57,18 @@ pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))]; pub const NYM_STATS_API_DOMAIN: &str = "nym-statistics-api.nymtech.cc"; pub const NYM_STATS_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(91, 92, 153, 96))]; +pub const NYM_RPC_DOMAIN: &str = "rpc.nymtech.net"; +pub const NYM_RPC_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(194, 182, 169, 49)), + IpAddr::V4(Ipv4Addr::new(91, 92, 200, 116)), + IpAddr::V4(Ipv6Addr::new( + 0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483, + )), + IpAddr::V4(Ipv6Addr::new( + 0x2a04, 0xc46, 0xe00, 0x6f28, 0x4b3, 0x68ff, 0xfe00, 0x460, + )), +]; + pub fn default_static_addrs() -> HashMap> { let mut m = HashMap::new(); m.insert(NYM_API_DOMAIN.to_string(), NYM_API_IPS.to_vec()); @@ -65,9 +85,14 @@ pub fn default_static_addrs() -> HashMap> { NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), ); + m.insert( + YELP_FRONTDOOR_FASTLY_DOMAIN.to_string(), + YELP_FRONTDOOR_FASTLY_IPS.to_vec(), + ); m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec()); m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec()); + m.insert(NYM_RPC_DOMAIN.to_string(), NYM_RPC_IPS.to_vec()); m } From 10951d4cd33efbec98dd2e4700085f18bc73c450 Mon Sep 17 00:00:00 2001 From: jmwample Date: Tue, 11 Nov 2025 10:40:25 -0700 Subject: [PATCH 5/5] clippy, fmt, minor fix --- common/http-api-client/src/dns.rs | 2 +- common/http-api-client/src/dns/constants.rs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index d211c3e221..8687cf7f27 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -39,7 +39,7 @@ use std::{ use hickory_resolver::{ TokioResolver, - config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, + config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig}, lookup_ip::LookupIpIntoIter, name_server::TokioConnectionProvider, }; diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs index d879d653a4..b25dc43a1c 100644 --- a/common/http-api-client/src/dns/constants.rs +++ b/common/http-api-client/src/dns/constants.rs @@ -1,7 +1,7 @@ #![allow(missing_docs)] use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; pub const NYM_API_DOMAIN: &str = "validator.nymtech.net"; pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))]; @@ -61,10 +61,10 @@ pub const NYM_RPC_DOMAIN: &str = "rpc.nymtech.net"; pub const NYM_RPC_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(194, 182, 169, 49)), IpAddr::V4(Ipv4Addr::new(91, 92, 200, 116)), - IpAddr::V4(Ipv6Addr::new( + IpAddr::V6(Ipv6Addr::new( 0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483, )), - IpAddr::V4(Ipv6Addr::new( + IpAddr::V6(Ipv6Addr::new( 0x2a04, 0xc46, 0xe00, 0x6f28, 0x4b3, 0x68ff, 0xfe00, 0x460, )), ]; @@ -85,10 +85,7 @@ pub fn default_static_addrs() -> HashMap> { NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), ); - m.insert( - YELP_FRONTDOOR_FASTLY_DOMAIN.to_string(), - YELP_FRONTDOOR_FASTLY_IPS.to_vec(), - ); + m.insert(YELP_FASTLY_DOMAIN.to_string(), YELP_FASTLY_IPS.to_vec()); m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec());