diff --git a/Cargo.lock b/Cargo.lock index e9d1b051cc..a0ce53c78e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6058,6 +6058,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-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/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..e748e37888 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -32,7 +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 } encoding_rs = { workspace = true } @@ -52,5 +52,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..8687cf7f27 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -30,19 +30,26 @@ use crate::ClientBuilder; use std::{ - net::SocketAddr, + collections::HashMap, + net::{IpAddr, SocketAddr}, + str::FromStr, sync::{Arc, LazyLock}, + time::Duration, }; use hickory_resolver::{ - ResolveError, TokioResolver, - config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, - lookup_ip::{LookupIp, LookupIpIntoIter}, + TokioResolver, + config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig}, + lookup_ip::LookupIpIntoIter, name_server::TokioConnectionProvider, }; use once_cell::sync::OnceCell; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use tracing::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. @@ -59,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. @@ -72,11 +75,17 @@ 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("invalid name: {0}")] + InvalidNameError(String), + #[error("hickory-dns resolver error: {0}")] + 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. @@ -87,69 +96,116 @@ 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 // 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. + overall_dns_timeout: Duration, +} + +impl Default for HickoryDnsResolver { + fn default() -> Self { + Self { + state: Default::default(), + fallback: Default::default(), + static_base: Default::default(), + dont_use_shared: Default::default(), + overall_dns_timeout: Duration::from_secs(10), + } + } } 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) + }) + } +} - // 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) => { - 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 { @@ -162,28 +218,22 @@ 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())?; - - // 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) => { - 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 + use<>, ResolveError> { + 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 + .map(|addrs| addrs.map(|socket_addr| socket_addr.ip())) } /// Create a (lazy-initialized) resolver that is not shared across threads. @@ -194,16 +244,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 @@ -215,8 +269,18 @@ 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<(), HickoryDnsError> { + pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> { self.fallback = Some(Default::default()); let _ = self .fallback @@ -231,22 +295,51 @@ 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 /// 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,20 +347,27 @@ 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; 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_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 +386,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"; @@ -296,4 +396,124 @@ 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(&example_ip4)); + assert!(addrs.contains(&example_ip6)); + 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), + overall_dns_timeout: Duration::from_secs(5), + ..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(()) + } + + #[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..b25dc43a1c --- /dev/null +++ b/common/http-api-client/src/dns/constants.rs @@ -0,0 +1,95 @@ +#![allow(missing_docs)] + +use std::collections::HashMap; +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))]; + +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 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)), + 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 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::V6(Ipv6Addr::new( + 0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483, + )), + IpAddr::V6(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()); + 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(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()); + 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 +} 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(()) + } +} 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"))]