diff --git a/Cargo.lock b/Cargo.lock index d75b5e0a96..06daa94d75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6250,14 +6250,15 @@ dependencies = [ name = "nym-gateway-directory" version = "0.0.0" dependencies = [ - "futures", "itertools 0.14.0", "nym-client-core", "nym-common", "nym-crypto 0.4.0", "nym-http-api-client", + "nym-network-defaults 0.1.0", "nym-offline-monitor", "nym-sphinx", + "nym-sphinx-addressing", "nym-topology", "nym-validator-client 0.1.0", "nym-vpn-api-client", @@ -6266,6 +6267,7 @@ dependencies = [ "strum", "thiserror 2.0.17", "tokio", + "tokio-util", "tracing", "url", ] diff --git a/nym-gateway-directory/Cargo.toml b/nym-gateway-directory/Cargo.toml index 440221235c..7884d64815 100644 --- a/nym-gateway-directory/Cargo.toml +++ b/nym-gateway-directory/Cargo.toml @@ -3,33 +3,41 @@ name = "nym-gateway-directory" authors.workspace = true repository.workspace = true homepage.workspace = true -documentation.workspace = true -edition.workspace = true license.workspace = true +edition = "2024" + +[lints] +workspace = true [dependencies] -futures.workspace = true itertools.workspace = true +# nym-client-core.workspace = true nym-client-core = { path = "../common/client-core" } +# nym-common.workspace = true nym-common = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "5ab01ba06057449614dd6a8450d04358c5ec33d1" } +# nym-http-api-client.workspace = true nym-http-api-client = { path = "../common/http-api-client" } +# nym-offline-monitor.workspace = true nym-offline-monitor = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "5ab01ba06057449614dd6a8450d04358c5ec33d1" } -nym-vpn-api-client = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "5ab01ba06057449614dd6a8450d04358c5ec33d1" } +# nym-sdk.workspace = true - removing due to circular dep, replaced with below nym-sphinx = { path = "../common/nymsphinx" } # replaces the types pulled in from nym-sdk in nym-vpn-client repo +# nym-topology.workspace = true nym-topology = { path = "../common/topology" } # replaces the types pulled in from nym-sdk in nym-vpn-client repo +# nym-validator-client.workspace = true nym-validator-client = { path = "../common/client-libs/validator-client" } -nym-crypto = { path = "../common/crypto", features = [ - "rand", - "asymmetric", - "stream_cipher", - "aes", - "hashing", -] } # TODO probably dont need all these features, cut down +# nym-vpn-api-client = { workspace = true } +nym-vpn-api-client = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "5ab01ba06057449614dd6a8450d04358c5ec33d1" } +nym-crypto = { path = "../common/crypto" } +nym-sphinx-addressing = { path = "../common/nymsphinx/addressing" } + +nym-network-defaults = { path = "../common/network-defaults" } + rand.workspace = true serde.workspace = true strum.workspace = true thiserror.workspace = true tokio.workspace = true +tokio-util.workspace = true tracing.workspace = true url.workspace = true diff --git a/nym-gateway-directory/src/caching_client.rs b/nym-gateway-directory/src/caching_client.rs deleted file mode 100644 index e57df88e9d..0000000000 --- a/nym-gateway-directory/src/caching_client.rs +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use std::{ - collections::HashMap, - net::IpAddr, - sync::Arc, - time::{Duration, Instant}, -}; - -use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; -use nym_offline_monitor::ConnectivityHandle; -use nym_sphinx::addressing::clients::Recipient; -use nym_sphinx::addressing::nodes::NodeIdentity; -use strum::IntoEnumIterator; -use tokio::sync::Mutex; - -use crate::{ - error::Result, Config, Country, Error, Gateway, GatewayClient, GatewayList, GatewayType, -}; - -#[derive(Clone)] -pub struct CachingGatewayClient { - inner: Arc>, -} - -impl CachingGatewayClient { - pub fn new( - gateway_client: GatewayClient, - connectivity_handle: Option, - ) -> Self { - Self { - inner: Arc::new(Mutex::new(CachingGatewayClientInner { - gateway_client, - connectivity_handle, - cached_gateways: Default::default(), - cached_countries: Default::default(), - })), - } - } - - pub async fn new_from_existing(existing_client: &CachingGatewayClient) -> Self { - let inner = existing_client.inner.lock().await; - Self { - inner: Arc::new(Mutex::new(CachingGatewayClientInner { - gateway_client: inner.gateway_client.clone(), - connectivity_handle: inner.connectivity_handle.clone(), - cached_gateways: inner.cached_gateways.clone(), - cached_countries: inner.cached_countries.clone(), - })), - } - } - - pub async fn update_client(&self, new_client: GatewayClient) { - self.inner.lock().await.gateway_client = new_client; - } - - pub async fn set_connectivity_handle(&self, connectivity_handle: ConnectivityHandle) { - self.inner.lock().await.connectivity_handle = Some(connectivity_handle); - } - - pub async fn get_config(&self) -> Config { - self.inner.lock().await.gateway_client.get_config() - } - - pub async fn refresh_all(&self) { - self.inner.lock().await.refresh_all().await - } - - pub async fn force_refresh_all(&self) { - self.inner.lock().await.force_refresh_all().await - } - - pub async fn lookup_gateways(&self, gw_type: GatewayType) -> Result { - self.inner.lock().await.lookup_gateways(gw_type).await - } - - pub async fn lookup_countries(&self, gw_type: GatewayType) -> Result> { - self.inner.lock().await.lookup_countries(gw_type).await - } - - pub async fn lookup_gateway_ip(&self, gateway_identity: &str) -> Result { - self.inner - .lock() - .await - .lookup_gateway_ip(gateway_identity) - .await - } -} - -/// A caching client that wraps around the `GatewayClient` and caches the results of -/// `lookup_gateways` and `lookup_countries` calls. -struct CachingGatewayClientInner { - // The underlying client that actually does the work - gateway_client: GatewayClient, - - // The connectivity handle to check if we are online - connectivity_handle: Option, - - // The cached gateways and their last updated time - cached_gateways: HashMap, - - // The cached countries and their last updated time - cached_countries: HashMap, Instant)>, -} - -enum LookupResult { - Gateways(Result), - Countries(Result>), -} - -impl CachingGatewayClientInner { - /// The maximum age of the cache before it is considered stale. - const MAX_CACHE_AGE: Duration = Duration::from_secs(5 * 60); - - async fn check_offline(&self) -> bool { - if let Some(connectivity_handle) = &self.connectivity_handle { - if connectivity_handle.connectivity().await.is_offline() { - return true; - } - } - false - } - - pub async fn refresh_all(&mut self) { - tracing::info!("Refreshing all gateways and countries"); - self.refresh( - self.get_stale_gateway_list_types(), - self.get_stale_country_list_types(), - ) - .await; - } - - pub async fn force_refresh_all(&mut self) { - tracing::info!("Forcing refresh of all gateways and countries"); - self.refresh(GatewayType::iter().collect(), GatewayType::iter().collect()) - .await; - } - - fn get_stale_gateway_list_types(&self) -> Vec { - let mut stale_gw_types = Vec::new(); - for gw_type in GatewayType::iter() { - if !self.is_gateways_current(&gw_type) { - stale_gw_types.push(gw_type.clone()); - } - } - stale_gw_types - } - - fn get_stale_country_list_types(&self) -> Vec { - let mut stale_gw_types = Vec::new(); - for gw_type in GatewayType::iter() { - if !self.is_countries_current(&gw_type) { - stale_gw_types.push(gw_type.clone()); - } - } - stale_gw_types - } - - async fn refresh(&mut self, gw_list_types: Vec, country_types: Vec) { - if self.check_offline().await { - tracing::warn!("Not refreshing gateways and countries because we are not connected"); - return; - } - - tracing::info!( - "Refreshing gateway lists: {gw_list_types:?}, country lists: {country_types:?}" - ); - let mut tasks = FuturesUnordered::new(); - - for gw_type in country_types { - let client = self.gateway_client.clone(); - tasks.push( - async move { - let res = client.lookup_countries(gw_type.clone()).await; - (gw_type, LookupResult::Countries(res)) - } - .boxed(), - ); - } - for gw_type in gw_list_types { - let client = self.gateway_client.clone(); - tasks.push( - async move { - let res = client.lookup_gateways(gw_type.clone()).await; - (gw_type, LookupResult::Gateways(res)) - } - .boxed(), - ); - } - - while let Some((gw_type, res)) = tasks.next().await { - match res { - LookupResult::Gateways(r) => match r { - Ok(ref refreshed_gateways) => { - tracing::info!("Refreshed gateways for {gw_type:?}"); - self.cached_gateways.insert( - gw_type.clone(), - (refreshed_gateways.clone(), Instant::now()), - ); - } - Err(err) => { - tracing::warn!("Failed to refresh gateways for {gw_type:?}: {err}"); - } - }, - LookupResult::Countries(r) => match r { - Ok(ref refreshed_countries) => { - tracing::info!("Refreshed countries for {gw_type:?}"); - self.cached_countries.insert( - gw_type.clone(), - (refreshed_countries.clone(), Instant::now()), - ); - } - Err(err) => { - tracing::warn!("Failed to refresh countries for {gw_type:?}: {err}"); - } - }, - } - } - } - - fn is_countries_current(&self, gw_type: &GatewayType) -> bool { - if let Some((_, last_updated)) = self.cached_countries.get(gw_type) { - last_updated.elapsed() < Self::MAX_CACHE_AGE - } else { - false - } - } - - fn is_gateways_current(&self, gw_type: &GatewayType) -> bool { - if let Some((_, last_updated)) = self.cached_gateways.get(gw_type) { - last_updated.elapsed() < Self::MAX_CACHE_AGE - } else { - false - } - } - - async fn refresh_countries(&mut self, gw_type: GatewayType) -> Result> { - if let Some((countries, last_updated)) = self.cached_countries.get(&gw_type) { - if last_updated.elapsed() < Self::MAX_CACHE_AGE { - return Ok(countries.clone()); - } - } - self.force_refresh_countries(gw_type).await - } - - async fn force_refresh_countries(&mut self, gw_type: GatewayType) -> Result> { - if self.check_offline().await { - tracing::warn!("Not refreshing countries because we are not connected"); - return Err(Error::Offline); - } - let refreshed_countries = self - .gateway_client - .lookup_countries(gw_type.clone()) - .await?; - self.cached_countries.insert( - gw_type.clone(), - (refreshed_countries.clone(), Instant::now()), - ); - Ok(refreshed_countries) - } - - async fn refresh_gateways(&mut self, gw_type: GatewayType) -> Result { - if let Some((gw_list, last_updated)) = self.cached_gateways.get(&gw_type) { - if last_updated.elapsed() < Self::MAX_CACHE_AGE { - return Ok(gw_list.clone()); - } - } - self.force_refresh_gateways(gw_type).await - } - - async fn force_refresh_gateways(&mut self, gw_type: GatewayType) -> Result { - if self.check_offline().await { - tracing::warn!("Not refreshing countries because we are not connected"); - return Err(Error::Offline); - } - let refreshed_gateways = self.gateway_client.lookup_gateways(gw_type.clone()).await?; - self.cached_gateways.insert( - gw_type.clone(), - (refreshed_gateways.clone(), Instant::now()), - ); - Ok(refreshed_gateways) - } - - async fn lookup_gateways(&mut self, gw_type: GatewayType) -> Result { - let refresh_result = self.refresh_gateways(gw_type.clone()).await; - - // Regardless of if we managed to refresh the cache, we return the cached gateways if they - // exist. They should be the most recent one we can muster - if let Some((gateways, _)) = self.cached_gateways.get(&gw_type) { - Ok(gateways.clone()) - } else { - refresh_result - } - } - - async fn lookup_countries(&mut self, gw_type: GatewayType) -> Result> { - let refresh_result = self.refresh_countries(gw_type.clone()).await; - - // Regardless of if we managed to refresh the cache, we return the cached countries if they - // exist. They should be the most recent one we can muster - if let Some((countries, _)) = self.cached_countries.get(&gw_type) { - Ok(countries.clone()) - } else { - refresh_result - } - } - - async fn lookup_gateway_ip(&mut self, gateway_identity: &str) -> Result { - // If we have a populated list of gateways, we should always be able to find the IP there. - if let Ok(identity) = NodeIdentity::from_base58_string(gateway_identity) { - for (_, (gateways, _)) in self.cached_gateways.iter() { - if let Some(ip) = gateways - .node_with_identity(&identity) - .and_then(Gateway::lookup_ip) - { - return Ok(ip); - } - } - } else { - tracing::warn!("Failed to parse gateway identity: {gateway_identity}"); - } - - // Fallback - tracing::warn!("Using fallback to lookup gateway IP"); - self.gateway_client - .lookup_gateway_ip(gateway_identity) - .await - } -} diff --git a/nym-gateway-directory/src/entries/auth_addresses.rs b/nym-gateway-directory/src/entries/auth_addresses.rs index 17b6e2ad66..8f8fde3c0e 100644 --- a/nym-gateway-directory/src/entries/auth_addresses.rs +++ b/nym-gateway-directory/src/entries/auth_addresses.rs @@ -1,15 +1,14 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::fmt::Display; - +// use nym_sdk::mixnet::Recipient; use nym_sphinx::addressing::clients::Recipient; +use nym_sphinx::addressing::nodes::NodeIdentity; -use crate::{error::Result, Error}; +use crate::{Error, error::Result}; -// optional, until we remove the wireguard feature flag #[derive(Debug, Copy, Clone)] -pub struct AuthAddress(pub Option); +pub struct AuthAddress(Recipient); impl AuthAddress { pub(crate) fn try_from_base58_string(address: &str) -> Result { @@ -19,39 +18,24 @@ impl AuthAddress { source, } })?; - Ok(AuthAddress(Some(recipient))) + Ok(AuthAddress(recipient)) } } -#[derive(Debug, Copy, Clone)] -pub struct AuthAddresses { - entry_addr: AuthAddress, - exit_addr: AuthAddress, -} - -impl AuthAddresses { - pub fn new(entry_addr: AuthAddress, exit_addr: AuthAddress) -> Self { - AuthAddresses { - entry_addr, - exit_addr, - } - } - - pub fn entry(&self) -> AuthAddress { - self.entry_addr - } - - pub fn exit(&self) -> AuthAddress { - self.exit_addr - } -} - -impl Display for AuthAddresses { +impl std::fmt::Display for AuthAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "entry: {:?} exit: {:?}", - self.entry_addr.0, self.exit_addr.0 - ) + write!(f, "{}", self.0) + } +} + +impl From for AuthAddress { + fn from(recipient: Recipient) -> Self { + Self(recipient) + } +} + +impl From for Recipient { + fn from(auth_address: AuthAddress) -> Self { + auth_address.0 } } diff --git a/nym-gateway-directory/src/entries/entry_point.rs b/nym-gateway-directory/src/entries/entry_point.rs index 803e7c2e7d..df8adb61d7 100644 --- a/nym-gateway-directory/src/entries/entry_point.rs +++ b/nym-gateway-directory/src/entries/entry_point.rs @@ -1,15 +1,18 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::fmt::{Display, Formatter}; - +use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::addressing::nodes::NodeIdentity; - +use std::fmt::{Display, Formatter}; +// use nym_sdk::mixnet::NodeIdentity; use serde::{Deserialize, Serialize}; use tracing::debug; -use super::gateway::{Gateway, GatewayList}; -use crate::{error::Result, Error}; +use crate::{ + Error, ScoreValue, + entries::gateway::{COUNTRY_WITH_REGION_SELECTOR, Gateway, GatewayFilter, GatewayList}, + error::Result, +}; // The entry point is always a gateway identity, or some other entry that can be resolved to a // gateway identity. @@ -17,8 +20,10 @@ use crate::{error::Result, Error}; pub enum EntryPoint { // An explicit entry gateway identity. Gateway { identity: NodeIdentity }, - // Select a random entry gateway in a specific location. - Location { location: String }, + // Select a random entry gateway in a specific country. + Country { two_letter_iso_country_code: String }, + // Select a random entry gateway in a specific region/state. + Region { region: String }, // Select an entry gateway at random. Random, } @@ -27,7 +32,10 @@ impl Display for EntryPoint { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { EntryPoint::Gateway { identity } => write!(f, "Gateway: {identity}"), - EntryPoint::Location { location } => write!(f, "Location: {location}"), + EntryPoint::Country { + two_letter_iso_country_code, + } => write!(f, "Country: {two_letter_iso_country_code}"), + EntryPoint::Region { region } => write!(f, "Region/state: {region}"), EntryPoint::Random => write!(f, "Random"), } } @@ -44,14 +52,14 @@ impl EntryPoint { Ok(EntryPoint::Gateway { identity }) } - pub fn is_location(&self) -> bool { - matches!(self, EntryPoint::Location { .. }) - } - - pub async fn lookup_gateway(&self, gateways: &GatewayList) -> Result { + pub fn lookup_gateway( + &self, + gateways: &GatewayList, + min_score: Option, + ) -> Result { match &self { EntryPoint::Gateway { identity } => { - debug!("Selecting gateway by identity: {}", identity); + debug!("Selecting gateway by identity: {identity}"); gateways .gateway_with_identity(identity) .ok_or_else(|| Error::NoMatchingGateway { @@ -59,21 +67,62 @@ impl EntryPoint { }) .cloned() } - EntryPoint::Location { location } => { - debug!("Selecting gateway by location: {}", location); - gateways - .random_gateway_located_at(location.to_string()) - .ok_or_else(|| Error::NoMatchingEntryGatewayForLocation { - requested_location: location.clone(), + EntryPoint::Country { + two_letter_iso_country_code, + } => { + debug!("Selecting gateway by country: {two_letter_iso_country_code}"); + + let filters = Self::build_filters( + vec![GatewayFilter::Country(two_letter_iso_country_code.clone())], + min_score, + ); + + gateways.choose_random(&filters).ok_or_else(|| { + Error::NoMatchingEntryGatewayForLocation { + requested_location: two_letter_iso_country_code.clone(), available_countries: gateways.all_iso_codes(), - }) + } + }) + } + EntryPoint::Region { region } => { + debug!("Selecting gateway by region/state: {region}"); + + // Currently only supported in the US + let filters = Self::build_filters( + vec![ + GatewayFilter::Country(COUNTRY_WITH_REGION_SELECTOR.to_string()), + GatewayFilter::Region(region.to_string()), + ], + min_score, + ); + + gateways.choose_random(&filters).ok_or_else(|| { + Error::NoMatchingEntryGatewayForLocation { + requested_location: region.clone(), + available_countries: gateways.all_iso_codes(), + } + }) } EntryPoint::Random => { debug!("Selecting a random gateway"); + + let filters = Self::build_filters(vec![], min_score); + gateways - .random_gateway() + .choose_random(&filters) .ok_or_else(|| Error::FailedToSelectGatewayRandomly) } } } + + #[inline] + fn build_filters( + mut base_filters: Vec, + min_score: Option, + ) -> Vec { + if let Some(min_score) = min_score { + base_filters.push(GatewayFilter::MinScore(min_score)); + } + base_filters + } } diff --git a/nym-gateway-directory/src/entries/exit_point.rs b/nym-gateway-directory/src/entries/exit_point.rs index 2075fc974d..4a5518f8eb 100644 --- a/nym-gateway-directory/src/entries/exit_point.rs +++ b/nym-gateway-directory/src/entries/exit_point.rs @@ -1,14 +1,17 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::fmt::{Display, Formatter}; - use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::addressing::nodes::NodeIdentity; +use std::fmt::{Display, Formatter}; +// use nym_sdk::mixnet::{NodeIdentity, Recipient}; use serde::{Deserialize, Serialize}; -use super::gateway::{Gateway, GatewayList}; -use crate::{error::Result, Error, IpPacketRouterAddress}; +use crate::{ + Error, IpPacketRouterAddress, ScoreValue, + entries::gateway::{COUNTRY_WITH_REGION_SELECTOR, Gateway, GatewayFilter, GatewayList}, + error::Result, +}; // The exit point is a nym-address, but if the exit ip-packet-router is running embedded on a // gateway, we can refer to it by the gateway identity. @@ -22,8 +25,11 @@ pub enum ExitPoint { // embedded on a gateway. Gateway { identity: NodeIdentity }, - // NOTE: Consider using a crate with strongly typed country codes instead of strings - Location { location: String }, + // Select a random entry gateway in a specific country. + Country { two_letter_iso_country_code: String }, + + // Select a random entry gateway in a specific region/state. + Region { region: String }, // Select an exit gateway at random. Random, @@ -34,18 +40,22 @@ impl Display for ExitPoint { match self { ExitPoint::Address { address } => write!(f, "Address: {address}"), ExitPoint::Gateway { identity } => write!(f, "Gateway: {identity}"), - ExitPoint::Location { location } => write!(f, "Location: {location}"), + ExitPoint::Country { + two_letter_iso_country_code, + } => write!(f, "Country: {two_letter_iso_country_code}"), + ExitPoint::Region { region } => write!(f, "Region/state: {region}"), ExitPoint::Random => write!(f, "Random"), } } } impl ExitPoint { - pub fn is_location(&self) -> bool { - matches!(self, ExitPoint::Location { .. }) - } - - pub fn lookup_gateway(&self, gateways: &GatewayList) -> Result { + pub fn lookup_gateway( + &self, + gateways: &GatewayList, + min_score: Option, + residential_exit: bool, + ) -> Result { match &self { ExitPoint::Address { address } => { tracing::debug!("Selecting gateway by address: {address}"); @@ -73,21 +83,69 @@ impl ExitPoint { }) .cloned() } - ExitPoint::Location { location } => { - tracing::debug!("Selecting gateway by location: {location}"); - gateways - .random_gateway_located_at(location.to_string()) - .ok_or_else(|| Error::NoMatchingExitGatewayForLocation { - requested_location: location.clone(), + ExitPoint::Country { + two_letter_iso_country_code, + } => { + tracing::debug!("Selecting gateway by country: {two_letter_iso_country_code}"); + + let filters = Self::build_filters( + vec![GatewayFilter::Country(two_letter_iso_country_code.clone())], + min_score, + residential_exit, + ); + + gateways.choose_random(&filters).ok_or_else(|| { + Error::NoMatchingExitGatewayForLocation { + requested_location: two_letter_iso_country_code.clone(), available_countries: gateways.all_iso_codes(), - }) + } + }) + } + ExitPoint::Region { region } => { + tracing::debug!("Selecting gateway by region/state: {region}"); + + let filters = Self::build_filters( + vec![ + // Currently only supported in the US + GatewayFilter::Country(COUNTRY_WITH_REGION_SELECTOR.to_string()), + GatewayFilter::Region(region.to_string()), + ], + min_score, + residential_exit, + ); + + gateways.choose_random(&filters).ok_or_else(|| { + Error::NoMatchingExitGatewayForLocation { + requested_location: region.clone(), + available_countries: gateways.all_iso_codes(), + } + }) } ExitPoint::Random => { tracing::debug!("Selecting a random exit gateway"); + + let filters = Self::build_filters(vec![], min_score, residential_exit); + gateways - .random_gateway() + .choose_random(&filters) .ok_or_else(|| Error::FailedToSelectGatewayRandomly) } } } + + #[inline] + fn build_filters( + mut base_filters: Vec, + min_score: Option, + residential_exit: bool, + ) -> Vec { + if let Some(min_score) = min_score { + base_filters.push(GatewayFilter::MinScore(min_score)); + } + if residential_exit { + base_filters.push(GatewayFilter::Residential); + base_filters.push(GatewayFilter::Exit); + } + base_filters + } } diff --git a/nym-gateway-directory/src/entries/gateway.rs b/nym-gateway-directory/src/entries/gateway.rs index 570ec7fc85..e84d767985 100644 --- a/nym-gateway-directory/src/entries/gateway.rs +++ b/nym-gateway-directory/src/entries/gateway.rs @@ -3,19 +3,31 @@ use itertools::Itertools; use nym_sphinx::addressing::nodes::NodeIdentity; - use nym_topology::{NodeId, RoutingNode}; -use nym_vpn_api_client::types::{NaiveFloat, Percent, ScoreThresholds}; +use nym_validator_client::models::{KeyRotationId, NymNodeDescription}; +use nym_vpn_api_client::{ + response::{BridgeInformation, BridgeParameters}, + types::{Percent, ScoreThresholds}, +}; use rand::seq::IteratorRandom; -use std::{fmt, net::IpAddr}; +use std::{ + fmt::{self, Display}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; use tracing::error; -use crate::{error::Result, AuthAddress, Country, Error, IpPacketRouterAddress}; - -use super::score::{Score, HIGH_SCORE_THRESHOLD, LOW_SCORE_THRESHOLD, MEDIUM_SCORE_THRESHOLD}; +use crate::{ + AuthAddress, Country, Error, IpPacketRouterAddress, + entries::score::{HIGH_SCORE_THRESHOLD, LOW_SCORE_THRESHOLD, MEDIUM_SCORE_THRESHOLD, Score}, + error::Result, + helpers, +}; pub type NymNode = Gateway; +pub const COUNTRY_WITH_REGION_SELECTOR: &str = "US"; + #[derive(Clone)] pub struct Gateway { pub identity: NodeIdentity, @@ -23,15 +35,15 @@ pub struct Gateway { pub location: Option, pub ipr_address: Option, pub authenticator_address: Option, + pub bridge_params: Option, pub last_probe: Option, pub ips: Vec, pub host: Option, pub clients_ws_port: Option, pub clients_wss_port: Option, pub mixnet_performance: Option, - pub wg_performance: Option, - pub wg_score: Option, pub mixnet_score: Option, + pub wg_performance: Option, pub version: Option, } @@ -47,11 +59,87 @@ impl fmt::Debug for Gateway { .field("clients_ws_port", &self.clients_ws_port) .field("clients_wss_port", &self.clients_wss_port) .field("mixnet_performance", &self.mixnet_performance) + .field("mixnet_score", &self.mixnet_score) + .field("wg_performance", &self.wg_performance) + .field("version", &self.version) .finish() } } impl Gateway { + pub fn try_from_node_description( + node_description: NymNodeDescription, + current_key_rotation: KeyRotationId, + ) -> Result { + let identity = node_description.description.host_information.keys.ed25519; + let location = node_description + .description + .auxiliary_details + .location + .map(|l| Location { + two_letter_iso_country_code: l.alpha2.to_string(), + ..Default::default() + }); + let ipr_address = node_description + .description + .ip_packet_router + .as_ref() + .and_then(|ipr| { + IpPacketRouterAddress::try_from_base58_string(&ipr.address) + .inspect_err(|err| error!("Failed to parse IPR address: {err}")) + .ok() + }); + let authenticator_address = node_description + .description + .authenticator + .as_ref() + .and_then(|a| { + AuthAddress::try_from_base58_string(&a.address) + .inspect_err(|err| error!("Failed to parse authenticator address: {err}")) + .ok() + }); + let version = Some(node_description.version().to_string()); + let role = if node_description.description.declared_role.entry { + nym_validator_client::nym_nodes::NodeRole::EntryGateway + } else if node_description.description.declared_role.exit_ipr + || node_description.description.declared_role.exit_nr + { + nym_validator_client::nym_nodes::NodeRole::ExitGateway + } else { + nym_validator_client::nym_nodes::NodeRole::Inactive + }; + + let gateway = RoutingNode::try_from(&node_description.to_skimmed_node( + current_key_rotation, + role, + Default::default(), + )) + .map_err(|_| Error::MalformedGateway)?; + + let host = gateway.ws_entry_address(false); + let entry_info = &gateway.entry; + let clients_ws_port = entry_info.as_ref().map(|g| g.clients_ws_port); + let clients_wss_port = entry_info.as_ref().and_then(|g| g.clients_wss_port); + let ips = node_description.description.host_information.ip_address; + Ok(Gateway { + identity, + moniker: String::new(), + location, + ipr_address, + authenticator_address, + bridge_params: None, + last_probe: None, + ips, + host, + clients_ws_port, + clients_wss_port, + mixnet_performance: None, + mixnet_score: None, + wg_performance: None, + version, + }) + } + pub fn identity(&self) -> NodeIdentity { self.identity } @@ -62,15 +150,37 @@ impl Gateway { .map(|l| l.two_letter_iso_country_code.as_str()) } - pub fn is_two_letter_iso_country_code(&self, code: &str) -> bool { - self.two_letter_iso_country_code() == Some(code) + pub fn is_in_country(&self, two_letter_iso_country_code: &str) -> bool { + self.location + .as_ref() + .map(|loc| loc.two_letter_iso_country_code == two_letter_iso_country_code) + .unwrap_or(false) } - pub fn has_ipr_address(&self) -> bool { + pub fn region(&self) -> Option<&str> { + self.location.as_ref().map(|l| l.region.as_str()) + } + + pub fn is_in_region(&self, region: &str) -> bool { + self.location + .as_ref() + .map(|loc| loc.region == region) + .unwrap_or(false) + } + + pub fn is_residential_asn(&self) -> bool { + self.location + .as_ref() + .and_then(|loc| loc.asn.as_ref()) + .map(|asn| asn.kind == AsnKind::Residential) + .unwrap_or(false) + } + + pub fn is_exit_node(&self) -> bool { self.ipr_address.is_some() } - pub fn has_authenticator_address(&self) -> bool { + pub fn is_vpn_node(&self) -> bool { self.authenticator_address.is_some() } @@ -82,6 +192,10 @@ impl Gateway { self.ips.first().copied() } + pub fn split_ips(&self) -> (Vec, Vec) { + helpers::split_ips(self.ips.clone()) + } + pub fn clients_address_no_tls(&self) -> Option { match (&self.host, &self.clients_ws_port) { (Some(host), Some(port)) => Some(format!("ws://{host}:{port}")), @@ -96,18 +210,68 @@ impl Gateway { } } - pub fn update_to_new_thresholds( - &mut self, - mix_thresholds: Option, - wg_thresholds: Option, - ) { + pub fn update_to_new_thresholds(&mut self, mix_thresholds: Option) { if let (Some(mix_thresholds), Some(score)) = (mix_thresholds, self.mixnet_score.as_mut()) { score.update_to_new_thresholds(mix_thresholds); } - if let (Some(wg_thresholds), Some(score)) = (wg_thresholds, self.wg_score.as_mut()) { - score.update_to_new_thresholds(wg_thresholds); + } + + pub fn meets_score(&self, gw_type: Option, min_score: ScoreValue) -> bool { + match gw_type { + Some(GatewayType::MixnetEntry) | Some(GatewayType::MixnetExit) => self + .mixnet_performance + .is_some_and(|p| p.round_to_integer() >= min_score.threshold()), + Some(GatewayType::Wg) => self + .wg_performance + .as_ref() + .is_some_and(|p| p.score >= min_score), + None => false, } } + + /// Tests whether the gateway matches a specific filter. + pub fn matches_filter(&self, gw_type: Option, filter: &GatewayFilter) -> bool { + match filter { + GatewayFilter::MinScore(score) => self.meets_score(gw_type, *score), + GatewayFilter::Country(code) => self.is_in_country(code), + GatewayFilter::Region(region) => self.is_in_region(region), + GatewayFilter::Residential => self.is_residential_asn(), + GatewayFilter::Exit => self.is_exit_node(), + GatewayFilter::Vpn => self.is_vpn_node(), + } + } + + /// Tests whether the gateway matches all of the filters. + pub fn matches_all_filters( + &self, + gw_type: Option, + filters: &[GatewayFilter], + ) -> bool { + filters + .iter() + .all(|filter| self.matches_filter(gw_type, filter)) + } + + pub fn get_bridge_params(&self) -> Option { + if let Some(all_params) = &self.bridge_params { + all_params.transports.first().cloned() + } else { + None + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AsnKind { + Residential, + Other, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Asn { + pub asn: String, + pub name: String, + pub kind: AsnKind, } #[derive(Debug, Default, Clone, PartialEq)] @@ -115,6 +279,79 @@ pub struct Location { pub two_letter_iso_country_code: String, pub latitude: f64, pub longitude: f64, + + pub city: String, + pub region: String, + + pub asn: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScoreValue { + Offline, + Low, + Medium, + High, +} + +impl ScoreValue { + fn priority(&self) -> u8 { + match self { + ScoreValue::Offline => 0, + ScoreValue::Low => 1, + ScoreValue::Medium => 2, + ScoreValue::High => 3, + } + } + + pub fn threshold(&self) -> u8 { + match self { + ScoreValue::Offline => 0, + ScoreValue::Low => LOW_SCORE_THRESHOLD, + ScoreValue::Medium => MEDIUM_SCORE_THRESHOLD, + ScoreValue::High => HIGH_SCORE_THRESHOLD, + } + } +} + +impl PartialOrd for ScoreValue { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.priority().cmp(&other.priority())) + } +} + +impl FromStr for ScoreValue { + type Err = crate::Error; + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "offline" => Ok(ScoreValue::Offline), + "low" => Ok(ScoreValue::Low), + "medium" => Ok(ScoreValue::Medium), + "high" => Ok(ScoreValue::High), + _ => Err(crate::Error::InvalidScoreValue(s.to_string())), + } + } +} + +impl Display for ScoreValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ScoreValue::Offline => "Offline", + ScoreValue::Low => "Low", + ScoreValue::Medium => "Medium", + ScoreValue::High => "High", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Performance { + pub last_updated_utc: String, + pub score: ScoreValue, + pub load: ScoreValue, + pub uptime_percentage_last_24_hours: f32, } #[derive(Debug, Clone, PartialEq)] @@ -154,12 +391,56 @@ pub struct WgProbeResults { pub ping_ips_performance: f32, } +impl From for AsnKind { + fn from(value: nym_vpn_api_client::response::AsnKind) -> Self { + match value { + nym_vpn_api_client::response::AsnKind::Residential => AsnKind::Residential, + nym_vpn_api_client::response::AsnKind::Other => AsnKind::Other, + } + } +} + +impl From for Asn { + fn from(location: nym_vpn_api_client::response::Asn) -> Self { + Asn { + asn: location.asn, + name: location.name, + kind: location.kind.into(), + } + } +} + impl From for Location { fn from(location: nym_vpn_api_client::response::Location) -> Self { Location { two_letter_iso_country_code: location.two_letter_iso_country_code, latitude: location.latitude, longitude: location.longitude, + city: location.city, + region: location.region, + asn: location.asn.map(Into::into), + } + } +} + +impl From for ScoreValue { + fn from(value: nym_vpn_api_client::response::ScoreValue) -> Self { + match value { + nym_vpn_api_client::response::ScoreValue::Offline => ScoreValue::Offline, + nym_vpn_api_client::response::ScoreValue::Low => ScoreValue::Low, + nym_vpn_api_client::response::ScoreValue::Medium => ScoreValue::Medium, + nym_vpn_api_client::response::ScoreValue::High => ScoreValue::High, + } + } +} + +impl From for Performance { + fn from(value: nym_vpn_api_client::response::DVpnGatewayPerformance) -> Self { + Performance { + last_updated_utc: value.last_updated_utc, + score: value.score.into(), + load: value.load.into(), + uptime_percentage_last_24_hours: value.uptime_percentage_last_24_hours, } } } @@ -258,13 +539,6 @@ impl TryFrom for Gateway { .cloned() .map(|ip| ip.to_string()); let host = hostname.or(first_ip_address); - let wg_performance = gateway.last_probe.as_ref().and_then(|probe| { - probe - .outcome - .wg - .as_ref() - .and_then(|p| Percent::naive_try_from_f64(p.ping_hosts_performance as f64).ok()) - }); Ok(Gateway { identity, @@ -272,6 +546,7 @@ impl TryFrom for Gateway { location: Some(gateway.location.into()), ipr_address, authenticator_address, + bridge_params: gateway.bridges, last_probe: gateway.last_probe.map(Probe::from), ips: gateway.ip_addresses, host, @@ -279,96 +554,24 @@ impl TryFrom for Gateway { clients_wss_port: gateway.entry.wss_port, mixnet_performance: Some(gateway.performance), mixnet_score: Some(Score::from(gateway.performance)), - wg_performance, - wg_score: wg_performance.map(Score::from), + wg_performance: gateway.performance_v2.map(Performance::from), version: gateway.build_information.map(|info| info.build_version), }) } } -impl TryFrom for Gateway { - type Error = Error; - - fn try_from( - node_description: nym_validator_client::models::NymNodeDescription, - ) -> Result { - let identity = node_description.description.host_information.keys.ed25519; - let location = node_description - .description - .auxiliary_details - .location - .map(|l| Location { - two_letter_iso_country_code: l.alpha2.to_string(), - ..Default::default() - }); - let ipr_address = node_description - .description - .ip_packet_router - .as_ref() - .and_then(|ipr| { - IpPacketRouterAddress::try_from_base58_string(&ipr.address) - .inspect_err(|err| error!("Failed to parse IPR address: {err}")) - .ok() - }); - let authenticator_address = node_description - .description - .authenticator - .as_ref() - .and_then(|a| { - AuthAddress::try_from_base58_string(&a.address) - .inspect_err(|err| error!("Failed to parse authenticator address: {err}")) - .ok() - }); - let version = Some(node_description.version().to_string()); - let role = if node_description.description.declared_role.entry { - nym_validator_client::nym_nodes::NodeRole::EntryGateway - } else if node_description.description.declared_role.exit_ipr - || node_description.description.declared_role.exit_nr - { - nym_validator_client::nym_nodes::NodeRole::ExitGateway - } else { - nym_validator_client::nym_nodes::NodeRole::Inactive - }; - - let gateway = - RoutingNode::try_from(&node_description.to_skimmed_node(role, Default::default())) - .map_err(|_| Error::MalformedGateway)?; - - let host = gateway.ws_entry_address(false); - let entry_info = &gateway.entry; - let clients_ws_port = entry_info.as_ref().map(|g| g.clients_ws_port); - let clients_wss_port = entry_info.as_ref().and_then(|g| g.clients_wss_port); - let ips = node_description.description.host_information.ip_address; - Ok(Gateway { - identity, - moniker: String::new(), - location, - ipr_address, - authenticator_address, - last_probe: None, - ips, - host, - clients_ws_port, - clients_wss_port, - mixnet_performance: None, - wg_performance: None, - wg_score: None, - mixnet_score: None, - version, - }) - } -} - pub type NymNodeList = GatewayList; #[derive(Debug, Clone)] pub struct GatewayList { + /// If None, then the list contains mixed types. + gw_type: Option, gateways: Vec, } impl GatewayList { - pub fn new(gateways: Vec) -> Self { - GatewayList { gateways } + pub fn new(gw_type: Option, gateways: Vec) -> Self { + GatewayList { gw_type, gateways } } // Returns a list of all locations of the gateways, including duplicates @@ -393,7 +596,16 @@ impl GatewayList { .collect() } + pub fn filter(&self, filters: &[GatewayFilter]) -> Vec { + self.gateways + .iter() + .filter(|gateway| gateway.matches_all_filters(self.gw_type, filters)) + .cloned() + .collect() + } + pub fn node_with_identity(&self, identity: &NodeIdentity) -> Option<&NymNode> { + // Not using self.filter() here as find() will stop at the first match self.gateways .iter() .find(|node| &node.identity() == identity) @@ -403,25 +615,10 @@ impl GatewayList { self.node_with_identity(identity) } - pub fn gateways_located_at(&self, code: String) -> impl Iterator { - self.gateways.iter().filter(move |gateway| { - gateway - .two_letter_iso_country_code() - .is_some_and(|gw_code| gw_code == code) - }) - } - - pub fn random_gateway(&self) -> Option { - self.gateways - .iter() + pub fn choose_random(&self, filters: &[GatewayFilter]) -> Option { + self.filter(filters) + .into_iter() .choose(&mut rand::thread_rng()) - .cloned() - } - - pub fn random_gateway_located_at(&self, code: String) -> Option { - self.gateways_located_at(code) - .choose(&mut rand::thread_rng()) - .cloned() } pub fn remove_gateway(&mut self, entry_gateway: &Gateway) { @@ -429,6 +626,10 @@ impl GatewayList { .retain(|gateway| gateway.identity() != entry_gateway.identity()); } + pub fn gw_type(&self) -> Option { + self.gw_type + } + pub fn len(&self) -> usize { self.gateways.len() } @@ -438,25 +639,11 @@ impl GatewayList { } pub fn into_exit_gateways(self) -> GatewayList { - let gw = self - .gateways - .into_iter() - .filter(Gateway::has_ipr_address) - .collect(); - Self::new(gw) + Self::new(self.gw_type, self.filter(&[GatewayFilter::Exit])) } pub fn into_vpn_gateways(self) -> GatewayList { - let gw = self - .gateways - .into_iter() - .filter(Gateway::has_authenticator_address) - .collect(); - Self::new(gw) - } - - pub fn into_countries(self) -> Vec { - self.all_countries() + Self::new(self.gw_type, self.filter(&[GatewayFilter::Vpn])) } pub fn into_inner(self) -> Vec { @@ -498,7 +685,7 @@ impl nym_client_core::init::helpers::ConnectableGateway for Gateway { } } -#[derive(Debug, Clone, Hash, PartialEq, Eq, strum::EnumIter)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, strum::EnumIter)] pub enum GatewayType { MixnetEntry, MixnetExit, @@ -534,3 +721,199 @@ impl From for nym_vpn_api_client::types::GatewayType { } } } + +#[derive(Debug, Clone, PartialEq)] +pub enum GatewayFilter { + MinScore(ScoreValue), // Mixnet or Wg score + Country(String), // Two-letter ISO country code + Region(String), // Region name + Residential, // Has a residential ASN + Exit, // Has an IPR address + Vpn, // Has an authenticator address +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayFilters { + pub gw_type: GatewayType, + pub filters: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + // Create a list of Gateways with different properties set for testing + fn sample_gateway_list(gw_type: GatewayType) -> GatewayList { + let asn = Asn { + asn: "AS12345".to_string(), + name: "Test ASN".to_string(), + kind: AsnKind::Residential, + }; + let addr = "MNrmKzuKjNdbEhfPUzVNfjw63oBQNSayqoQKGL4JjAV.6fDcSN6faGpvA3pd3riCwjpzXc7RQfWmGMa82UVoEwKE@d5adfJNtcdZW2XwK85JAAU8nXAs9JCPYn2RNvDLZn4e"; + let ipr = IpPacketRouterAddress::try_from_base58_string(addr).unwrap(); + let aa = AuthAddress::try_from_base58_string(addr).unwrap(); + let variables = [ + ("US", "CA", None, Some(ipr), Some(aa)), // Gateway 1 + ("US", "NY", Some(asn.clone()), None, None), // Gateway 2 + ("DE", "BE", None, None, Some(aa)), // Gateway 3 + ("FR", "Aquitaine", Some(asn.clone()), None, Some(aa)), // Gateway 4 + ("US", "TX", Some(asn.clone()), Some(ipr), None), // Gateway 5 + ("GB", "Hampshire", None, None, None), // Gateway 6 + ]; + + let mut instance = 0; + let gateways: Vec = variables + .into_iter() + .map(|(country, region, asn, ipr, aa)| { + instance += 1; + Gateway { + identity: NodeIdentity::from_base58_string( + "7CWjY3QFoA9dgE535u9bQiXCfzgMZvSpJu842GA1Wn42", + ) + .unwrap(), + moniker: format!("Gateway {instance}"), + location: Some(Location { + two_letter_iso_country_code: country.to_string(), + region: region.to_string(), + asn, + ..Default::default() + }), + ipr_address: ipr, + authenticator_address: aa, + bridge_params: None, + last_probe: None, + ips: Vec::new(), + host: None, + clients_ws_port: None, + clients_wss_port: None, + mixnet_performance: Some(Percent::from_percentage_value(75).unwrap()), + mixnet_score: None, + wg_performance: Some(Performance { + last_updated_utc: "2024-01-01T00:00:00Z".to_string(), + score: ScoreValue::High, + load: ScoreValue::Low, + uptime_percentage_last_24_hours: 0.75, + }), + version: None, + } + }) + .collect(); + GatewayList::new(Some(gw_type), gateways) + } + + #[test] + fn test_gateway_filter_score() { + let wg_list = sample_gateway_list(GatewayType::Wg); + let mixnet_entry_list = sample_gateway_list(GatewayType::MixnetEntry); + let mixnet_exit_list = sample_gateway_list(GatewayType::MixnetExit); + + let gws = wg_list.filter(&[GatewayFilter::MinScore(ScoreValue::High)]); + assert_eq!(gws.len(), 6); + let gws = wg_list.filter(&[GatewayFilter::MinScore(ScoreValue::Medium)]); + assert_eq!(gws.len(), 6); + let gws = wg_list.filter(&[GatewayFilter::MinScore(ScoreValue::Low)]); + assert_eq!(gws.len(), 6); + + let gws = mixnet_entry_list.filter(&[GatewayFilter::MinScore(ScoreValue::High)]); + assert_eq!(gws.len(), 0); + let gws = mixnet_entry_list.filter(&[GatewayFilter::MinScore(ScoreValue::Medium)]); + assert_eq!(gws.len(), 6); + let gws = mixnet_entry_list.filter(&[GatewayFilter::MinScore(ScoreValue::Low)]); + assert_eq!(gws.len(), 6); + + let gws = mixnet_exit_list.filter(&[GatewayFilter::MinScore(ScoreValue::High)]); + assert_eq!(gws.len(), 0); + let gws = mixnet_exit_list.filter(&[GatewayFilter::MinScore(ScoreValue::Medium)]); + assert_eq!(gws.len(), 6); + let gws = mixnet_exit_list.filter(&[GatewayFilter::MinScore(ScoreValue::Low)]); + assert_eq!(gws.len(), 6); + } + + #[test] + fn test_gateway_filter_exit_nodes() { + let gateway_list = sample_gateway_list(GatewayType::MixnetEntry); + let exit_gws = gateway_list.filter(&[GatewayFilter::Exit]); + assert_eq!(exit_gws.len(), 2); + assert_eq!(exit_gws[0].moniker, "Gateway 1"); + assert_eq!(exit_gws[1].moniker, "Gateway 5"); + } + + #[test] + fn test_gateway_filter_vpn_nodes() { + let gateway_list = sample_gateway_list(GatewayType::MixnetExit); + let vpn_gws = gateway_list.filter(&[GatewayFilter::Vpn]); + assert_eq!(vpn_gws.len(), 3); + assert_eq!(vpn_gws[0].moniker, "Gateway 1"); + assert_eq!(vpn_gws[1].moniker, "Gateway 3"); + assert_eq!(vpn_gws[2].moniker, "Gateway 4"); + } + + #[test] + fn test_gateway_filter_residential() { + let gateway_list = sample_gateway_list(GatewayType::Wg); + let residential_gws = gateway_list.filter(&[GatewayFilter::Residential]); + assert_eq!(residential_gws.len(), 3); + assert_eq!(residential_gws[0].moniker, "Gateway 2"); + assert_eq!(residential_gws[1].moniker, "Gateway 4"); + assert_eq!(residential_gws[2].moniker, "Gateway 5"); + } + + #[test] + fn test_gateway_random_country() { + let gateway_list = sample_gateway_list(GatewayType::MixnetEntry); + + assert!( + gateway_list + .choose_random(&[GatewayFilter::Country("US".into())]) + .unwrap() + .is_in_country("US") + ); + + assert!( + gateway_list + .choose_random(&[GatewayFilter::Country("DE".into())]) + .unwrap() + .is_in_country("DE") + ); + + assert!( + gateway_list + .choose_random(&[GatewayFilter::Country("BE".into())]) + .is_none() + ); + } + + #[test] + fn test_gateway_random_region() { + let gateway_list = sample_gateway_list(GatewayType::MixnetExit); + + assert!( + gateway_list + .choose_random(&[ + GatewayFilter::Country("US".into()), + GatewayFilter::Region("CA".into()) + ]) + .unwrap() + .is_in_region("CA") + ); + + assert!( + gateway_list + .choose_random(&[ + GatewayFilter::Country("GB".into()), + GatewayFilter::Region("Hampshire".into()) + ]) + .unwrap() + .is_in_region("Hampshire") + ); + + assert!( + gateway_list + .choose_random(&[ + GatewayFilter::Country("DE".into()), + GatewayFilter::Region("XZ".into()) + ]) + .is_none() + ); + } +} diff --git a/nym-gateway-directory/src/entries/ipr_addresses.rs b/nym-gateway-directory/src/entries/ipr_addresses.rs index 752ba74a54..37c596f5c0 100644 --- a/nym-gateway-directory/src/entries/ipr_addresses.rs +++ b/nym-gateway-directory/src/entries/ipr_addresses.rs @@ -1,11 +1,11 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only - use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::addressing::nodes::NodeIdentity; +// use nym_sdk::mixnet::{NodeIdentity, Recipient}; use nym_validator_client::models::NymNodeData; -use crate::{error::Result, Error}; +use crate::{Error, error::Result}; #[derive(Debug, Copy, Clone)] pub struct IpPacketRouterAddress(Recipient); diff --git a/nym-gateway-directory/src/entries/score.rs b/nym-gateway-directory/src/entries/score.rs index 45c298f16c..490be6c8cb 100644 --- a/nym-gateway-directory/src/entries/score.rs +++ b/nym-gateway-directory/src/entries/score.rs @@ -7,7 +7,7 @@ pub(crate) const HIGH_SCORE_THRESHOLD: u8 = 80; pub(crate) const MEDIUM_SCORE_THRESHOLD: u8 = 60; pub(crate) const LOW_SCORE_THRESHOLD: u8 = 0; -#[derive(Clone)] +#[derive(Debug, Clone)] pub enum Score { High(u8), Medium(u8), diff --git a/nym-gateway-directory/src/error.rs b/nym-gateway-directory/src/error.rs index a796d1539a..f7aca83102 100644 --- a/nym-gateway-directory/src/error.rs +++ b/nym-gateway-directory/src/error.rs @@ -1,6 +1,9 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use nym_http_api_client::HttpClientError; +use nym_validator_client::nym_api::error::NymAPIError; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("identity not formatted correctly: {identity}")] @@ -19,7 +22,7 @@ pub enum Error { ValidatorClientError(#[from] nym_validator_client::ValidatorClientError), #[error(transparent)] - VpnApiClientError(#[from] nym_vpn_api_client::VpnApiClientError), + VpnApiClientError(#[from] nym_vpn_api_client::error::VpnApiClientError), #[error("failed to resolve gateway hostname: {hostname}")] FailedToDnsResolveGateway { @@ -30,6 +33,9 @@ pub enum Error { #[error("resolved hostname {0} but no IP address found")] ResolvedHostnameButNoIp(String), + #[error("timed out while attempting to resolve hostname: {hostname}")] + HostnameResolutionTimeout { hostname: String }, + #[error("failed to lookup described gateways")] FailedToLookupDescribedGateways(#[source] nym_validator_client::ValidatorClientError), @@ -87,6 +93,30 @@ pub enum Error { #[error("no connectivity")] Offline, + + #[error("HTTP client error: {0}")] + HttpClient(#[from] Box), + + #[error("Nym API error: {source}")] + NymApi { source: Box }, + + #[error("operation cancelled")] + Cancelled, + + #[error("invalid score value: {0}. Valid values are: offline, low, medium, high")] + InvalidScoreValue(String), +} + +impl Error { + /// Returns true when no gateways matching the search criteria could be found, except when the gateway is constrained to identity + pub fn is_unmatched_non_specific_gateway(&self) -> bool { + matches!( + self, + Error::NoMatchingEntryGatewayForLocation { .. } + | Error::NoMatchingExitGatewayForLocation { .. } + | Error::FailedToSelectGatewayRandomly + ) + } } // Result type based on our error type diff --git a/nym-gateway-directory/src/gateway_cache.rs b/nym-gateway-directory/src/gateway_cache.rs new file mode 100644 index 0000000000..41e2fca5d1 --- /dev/null +++ b/nym-gateway-directory/src/gateway_cache.rs @@ -0,0 +1,312 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + collections::HashMap, + net::IpAddr, + time::{Duration, Instant}, +}; + +use nym_offline_monitor::ConnectivityHandle; +// use nym_sdk::mixnet::NodeIdentity; +use nym_sphinx::addressing::clients::Recipient; +use nym_sphinx::addressing::nodes::NodeIdentity; +use strum::IntoEnumIterator; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::{ + Error, Gateway, GatewayClient, GatewayFilters, GatewayList, GatewayType, error::Result, +}; + +/// The maximum age of the cache before it is considered stale. +const MAX_CACHE_AGE: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone)] +pub struct GatewayCacheHandle { + tx: tokio::sync::mpsc::UnboundedSender, +} + +impl GatewayCacheHandle { + fn new(tx: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { tx } + } + + /// Refresh all gateways and countries without blocking until the operation is complete. + pub async fn refresh_all(&self) -> Result<()> { + self.tx + .send(Command::RefreshAll) + .map_err(|_| Error::Cancelled) + } + + /// Lookup gateways waiting for any pending fetch request or initiating one if needed. + pub async fn lookup_gateways(&self, gw_type: GatewayType) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.tx + .send(Command::LookupGateways(gw_type, tx)) + .map_err(|_| Error::Cancelled)?; + rx.await.map_err(|_| Error::Cancelled)? + } + + pub async fn lookup_filtered_gateways(&self, filters: GatewayFilters) -> Result> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.tx + .send(Command::LookupFilteredGateways(filters, tx)) + .map_err(|_| Error::Cancelled)?; + rx.await.map_err(|_| Error::Cancelled)? + } + + /// Lookup gateway IP address waiting for any pending fetch request or initiating one if needed. + pub async fn lookup_gateway_ip(&self, gateway_identity: String) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.tx + .send(Command::LookupGatewayIp(gateway_identity, tx)) + .map_err(|_| Error::Cancelled)?; + rx.await.map_err(|_| Error::Cancelled)? + } + + pub fn replace_gateway_client(&mut self, gateway_client: GatewayClient) -> Result<()> { + self.tx + .send(Command::ReplaceGatewayClient(Box::new(gateway_client))) + .map_err(|_| Error::Cancelled) + } +} + +enum Command { + RefreshAll, + LookupGateways( + GatewayType, + tokio::sync::oneshot::Sender>, + ), + LookupFilteredGateways( + GatewayFilters, + tokio::sync::oneshot::Sender>>, + ), + LookupGatewayIp( + String, // gateway_identity + tokio::sync::oneshot::Sender>, + ), + ReplaceGatewayClient(Box), +} + +pub struct GatewayCache { + // The channel for receiving commands + command_rx: tokio::sync::mpsc::UnboundedReceiver, + + // The underlying client that actually does the work + gateway_client: GatewayClient, + + // The cached gateways and their last updated time + cached_gateways: HashMap, + + // The connectivity handle to check if we are online + connectivity_handle: ConnectivityHandle, + + /// Whether the initial refresh has been performed + is_performed_initial_refresh: bool, + + // Shutdown token + shutdown_token: CancellationToken, +} + +impl GatewayCache { + pub fn spawn( + gateway_client: GatewayClient, + connectivity_handle: ConnectivityHandle, + shutdown_token: CancellationToken, + ) -> (GatewayCacheHandle, JoinHandle<()>) { + let (command_tx, command_rx) = tokio::sync::mpsc::unbounded_channel(); + + let inner = Self { + gateway_client, + connectivity_handle, + command_rx, + cached_gateways: HashMap::default(), + is_performed_initial_refresh: false, + shutdown_token, + }; + let join_handle = tokio::spawn(inner.run()); + (GatewayCacheHandle::new(command_tx), join_handle) + } + + async fn run(mut self) { + if self.connectivity_handle.connectivity().await.is_online() { + self.perform_initial_fetch_once().await; + } + + loop { + tokio::select! { + Some(cmd) = self.command_rx.recv() => { + match cmd { + Command::RefreshAll => { + self.refresh_all().await; + } + Command::LookupGateways(gw_type, tx) => { + tx.send(self.lookup_gateways(gw_type).await).ok(); + } + Command::LookupFilteredGateways(filters, tx) => { + let gw_vec = self.lookup_filtered_gateways(filters).await; + tx.send(gw_vec).ok(); + } + Command::LookupGatewayIp(gateway_identity, tx) => { + tx.send(self.lookup_gateway_ip(&gateway_identity).await).ok(); + } + Command::ReplaceGatewayClient(gateway_client) => { + self.replace_gateway_client(*gateway_client) + } + } + } + Some(status) = self.connectivity_handle.next() => { + if status.is_online() { + self.perform_initial_fetch_once().await; + } + } + _ = self.shutdown_token.cancelled() => { + break; + } + } + } + } + + async fn perform_initial_fetch_once(&mut self) { + if !self.is_performed_initial_refresh { + tracing::info!("Performing initial refresh"); + self.is_performed_initial_refresh = true; + self.refresh_all().await; + } + } + + fn replace_gateway_client(&mut self, gateway_client: GatewayClient) { + let old_config = self.gateway_client.get_config(); + let new_config = gateway_client.get_config(); + + self.gateway_client = gateway_client; + + // Invalidate cache immediately if gateway performance or thresholds change + if new_config.min_gateway_performance != old_config.min_gateway_performance + || new_config.mix_score_thresholds != old_config.mix_score_thresholds + || new_config.wg_score_thresholds != old_config.wg_score_thresholds + { + self.cached_gateways.clear(); + } + } + + async fn refresh_all(&mut self) { + let gw_types = self.get_stale_gateway_list_types(); + + if !gw_types.is_empty() { + tracing::info!("Refreshing gateways: {:?}", gw_types,); + self.refresh(gw_types).await; + } + } + + fn get_stale_gateway_list_types(&self) -> Vec { + GatewayType::iter() + .filter(|gw_type| !self.is_gateways_current(gw_type)) + .collect() + } + + async fn refresh(&mut self, gw_list_types: Vec) { + if self.connectivity_handle.connectivity().await.is_offline() { + tracing::debug!("Not refreshing gateways because we are not connected"); + return; + } + + tracing::info!("Refreshing gateway lists: {gw_list_types:?}"); + + let mut tasks = tokio::task::JoinSet::new(); + + for gw_type in gw_list_types { + let client = self.gateway_client.clone(); + tasks.spawn(async move { + let res = client.lookup_gateways(gw_type).await; + (gw_type, res) + }); + } + + while let Some(res) = tasks.join_next().await { + match res { + Ok((gw_type, r)) => match r { + Ok(refreshed_gateways) => { + tracing::info!("Refreshed gateways for {gw_type:?}"); + self.cached_gateways + .insert(gw_type, (refreshed_gateways, Instant::now())); + } + Err(err) => { + tracing::warn!("Failed to refresh gateways for {gw_type:?}: {err}"); + } + }, + Err(err) => { + tracing::error!("Failed to join on refresh task: {err}"); + } + } + } + } + + fn is_gateways_current(&self, gw_type: &GatewayType) -> bool { + self.cached_gateways + .get(gw_type) + .as_ref() + .map(|(_, last_updated)| last_updated.elapsed() < MAX_CACHE_AGE) + .unwrap_or_default() + } + + async fn refresh_gateways(&mut self, gw_type: GatewayType) -> Result { + if let Some((gw_list, last_updated)) = self.cached_gateways.get(&gw_type) + && last_updated.elapsed() < MAX_CACHE_AGE + { + Ok(gw_list.clone()) + } else { + if self.connectivity_handle.connectivity().await.is_offline() { + tracing::warn!("Not refreshing countries because we are not connected"); + return Err(Error::Offline); + } + + let refreshed_gateways = self.gateway_client.lookup_gateways(gw_type).await?; + + self.cached_gateways + .insert(gw_type, (refreshed_gateways.clone(), Instant::now())); + + Ok(refreshed_gateways) + } + } + + async fn lookup_gateways(&mut self, gw_type: GatewayType) -> Result { + let refresh_result = self.refresh_gateways(gw_type).await; + + // Regardless of if we managed to refresh the cache, we return the cached gateways if they + // exist. They should be the most recent one we can muster + if let Some((gateways, _)) = self.cached_gateways.get(&gw_type) { + Ok(gateways.clone()) + } else { + refresh_result + } + } + + async fn lookup_filtered_gateways(&mut self, filters: GatewayFilters) -> Result> { + let gw_list = self.lookup_gateways(filters.gw_type).await?; + Ok(gw_list.filter(&filters.filters)) + } + + async fn lookup_gateway_ip(&mut self, gateway_identity: &str) -> Result { + // If we have a populated list of gateways, we should always be able to find the IP there. + if let Ok(identity) = NodeIdentity::from_base58_string(gateway_identity) { + for (_, (gateways, _)) in self.cached_gateways.iter() { + if let Some(ip) = gateways + .node_with_identity(&identity) + .and_then(Gateway::lookup_ip) + { + return Ok(ip); + } + } + } else { + tracing::warn!("Failed to parse gateway identity: {gateway_identity}"); + } + + // Fallback + tracing::warn!("Using fallback to lookup gateway IP"); + self.gateway_client + .lookup_gateway_ip(gateway_identity) + .await + } +} diff --git a/nym-gateway-directory/src/gateway_client.rs b/nym-gateway-directory/src/gateway_client.rs index d06bca4ffb..cdbc8eb5d5 100644 --- a/nym-gateway-directory/src/gateway_client.rs +++ b/nym-gateway-directory/src/gateway_client.rs @@ -7,19 +7,18 @@ use std::{ }; use nym_http_api_client::UserAgent; -use nym_validator_client::{models::NymNodeDescription, nym_nodes::SkimmedNode, NymApiClient}; +use nym_validator_client::{ + models::NymNodeDescription, nym_api::NymApiClientExt, nym_nodes::SkimmedNodesWithMetadata, +}; use nym_vpn_api_client::types::{GatewayMinPerformance, Percent, ScoreThresholds}; use rand::{prelude::SliceRandom, thread_rng}; use tracing::{debug, error, warn}; use url::Url; use crate::{ - entries::{ - country::Country, - gateway::{Gateway, GatewayList, GatewayType, NymNodeList}, - }, - error::Result, Error, NymNode, + entries::gateway::{Gateway, GatewayList, GatewayType, NymNodeList}, + error::Result, }; #[derive(Clone, Debug)] @@ -109,7 +108,7 @@ impl ResolvedConfig { #[derive(Clone)] pub struct GatewayClient { - api_client: NymApiClient, + api_client: nym_http_api_client::Client, nym_vpn_api_client: Option, nyxd_url: Url, min_gateway_performance: Option, @@ -127,7 +126,11 @@ impl GatewayClient { user_agent: UserAgent, static_nym_api_ip_addresses: Option<&[SocketAddr]>, ) -> Result { - let api_client = NymApiClient::new_with_user_agent(config.api_url, user_agent.clone()); + let api_client = nym_http_api_client::Client::builder(config.api_url.clone()) + .map_err(|e| Error::FailedToLookupDescribedGateways(e.into()))? + .with_user_agent(user_agent.clone()) + .build() + .map_err(|e| Error::FailedToLookupDescribedGateways(e.into()))?; let nym_vpn_api_client = config .nym_vpn_api_url .map(|url| { @@ -149,6 +152,42 @@ impl GatewayClient { }) } + pub fn from_network_with_resolver_overrides( + config: Config, + network_details: &nym_network_defaults::NymNetworkDetails, + user_agent: UserAgent, + static_nym_api_ip_addresses: Option<&[SocketAddr]>, + ) -> Result { + // Use the new unified HTTP client with domain fronting for the main API client + let api_client = nym_http_api_client::ClientBuilder::from_network(network_details) + .map_err(Box::new)? + .with_user_agent(user_agent.clone()) + .build() + .map_err(Box::new)?; + + // Use domain fronting with resolver overrides for VPN API client + let nym_vpn_api_client = if config.nym_vpn_api_url.is_some() { + Some( + nym_vpn_api_client::VpnApiClient::from_network_with_resolver_overrides( + network_details, + user_agent.clone(), + static_nym_api_ip_addresses, + )?, + ) + } else { + None + }; + + Ok(GatewayClient { + api_client, + nym_vpn_api_client, + nyxd_url: config.nyxd_url, + min_gateway_performance: config.min_gateway_performance, + mix_score_thresholds: config.mix_score_thresholds, + wg_score_thresholds: config.wg_score_thresholds, + }) + } + /// Return the config of this instance. pub fn get_config(&self) -> Config { Config { @@ -181,23 +220,29 @@ impl GatewayClient { self.api_client .get_all_described_nodes() .await - .map_err(Error::FailedToLookupDescribedGateways) + .map_err(|e| Error::NymApi { + source: Box::new(e), + }) } - async fn lookup_skimmed_gateways(&self) -> Result> { + async fn lookup_skimmed_gateways(&self) -> Result { debug!("Fetching skimmed entry assigned nodes from nym-api..."); self.api_client - .get_all_basic_entry_assigned_nodes() + .get_all_basic_entry_assigned_nodes_with_metadata() .await - .map_err(Error::FailedToLookupSkimmedGateways) + .map_err(|e| Error::NymApi { + source: Box::new(e), + }) } - async fn lookup_skimmed_nodes(&self) -> Result> { + async fn lookup_skimmed_nodes(&self) -> Result { debug!("Fetching skimmed entry assigned nodes from nym-api..."); self.api_client - .get_all_basic_nodes() + .get_all_basic_nodes_with_metadata() .await - .map_err(Error::FailedToLookupSkimmedNodes) + .map_err(|e| Error::NymApi { + source: Box::new(e), + }) } pub async fn lookup_gateway_ip_from_nym_api(&self, gateway_identity: &str) -> Result { @@ -205,7 +250,10 @@ impl GatewayClient { let mut ips = self .api_client .get_all_described_nodes() - .await? + .await + .map_err(|e| Error::NymApi { + source: Box::new(e), + })? .iter() .find_map(|node| { if node @@ -248,38 +296,42 @@ impl GatewayClient { } pub async fn lookup_all_gateways_from_nym_api(&self) -> Result { + let skimmed_gateways = self.lookup_skimmed_gateways().await?; + let key_rotation_id = skimmed_gateways.metadata.rotation_id; + let mut gateways = self .lookup_described_nodes() .await? .into_iter() .filter(|node| node.description.declared_role.entry) .filter_map(|gw| { - Gateway::try_from(gw) + Gateway::try_from_node_description(gw, key_rotation_id) .inspect_err(|err| error!("Failed to parse gateway: {err}")) .ok() }) .collect::>(); - let skimmed_gateways = self.lookup_skimmed_gateways().await?; - append_performance(&mut gateways, skimmed_gateways); + append_performance(&mut gateways, skimmed_gateways.nodes); filter_on_mixnet_min_performance(&mut gateways, &self.min_gateway_performance); - Ok(GatewayList::new(gateways)) + Ok(GatewayList::new(None, gateways)) } pub async fn lookup_all_nymnodes(&self) -> Result { + let skimmed_nodes = self.lookup_skimmed_nodes().await?; + let key_rotation_id = skimmed_nodes.metadata.rotation_id; + let mut nodes = self .lookup_described_nodes() .await? .into_iter() .filter_map(|gw| { - NymNode::try_from(gw) + NymNode::try_from_node_description(gw, key_rotation_id) .inspect_err(|err| error!("Failed to parse node: {err}")) .ok() }) .collect::>(); - let skimmed_nodes = self.lookup_skimmed_nodes().await?; - append_performance(&mut nodes, skimmed_nodes); + append_performance(&mut nodes, skimmed_nodes.nodes); filter_on_mixnet_min_performance(&mut nodes, &self.min_gateway_performance); - Ok(GatewayList::new(nodes)) + Ok(GatewayList::new(None, nodes)) } pub async fn lookup_gateways_from_nym_api(&self, gw_type: GatewayType) -> Result { @@ -345,15 +397,12 @@ impl GatewayClient { .inspect_err(|err| error!("Failed to parse gateway: {err}")) .ok() .map(|mut gw| { - gw.update_to_new_thresholds( - self.mix_score_thresholds, - self.wg_score_thresholds, - ); + gw.update_to_new_thresholds(self.mix_score_thresholds); gw }) }) .collect(); - Ok(GatewayList::new(gateways)) + Ok(GatewayList::new(None, gateways)) } else { warn!("OPERATING IN FALLBACK MODE WITHOUT NYM-VPN-API!"); self.lookup_all_gateways_from_nym_api().await @@ -372,37 +421,17 @@ impl GatewayClient { .inspect_err(|err| error!("Failed to parse gateway: {err}")) .ok() .map(|mut gw| { - gw.update_to_new_thresholds( - self.mix_score_thresholds, - self.wg_score_thresholds, - ); + gw.update_to_new_thresholds(self.mix_score_thresholds); gw }) }) .collect(); - Ok(GatewayList::new(gateways)) + Ok(GatewayList::new(Some(gw_type), gateways)) } else { warn!("OPERATING IN FALLBACK MODE WITHOUT NYM-VPN-API!"); self.lookup_gateways_from_nym_api(gw_type).await } } - - pub async fn lookup_countries(&self, gw_type: GatewayType) -> Result> { - if let Some(nym_vpn_api_client) = &self.nym_vpn_api_client { - debug!("Fetching entry countries from nym-vpn-api..."); - Ok(nym_vpn_api_client - .get_gateway_countries_by_type(gw_type.into(), self.min_gateway_performance) - .await? - .into_iter() - .map(Country::from) - .collect()) - } else { - warn!("OPERATING IN FALLBACK MODE WITHOUT NYM-VPN-API!"); - self.lookup_gateways_from_nym_api(gw_type) - .await - .map(GatewayList::into_countries) - } - } } // Append the performance to the gateways. This is a temporary hack until the nymvpn.com endpoints @@ -431,78 +460,15 @@ fn filter_on_mixnet_min_performance( gateways: &mut Vec, min_gateway_performance: &Option, ) { - if let Some(min_performance) = min_gateway_performance { - if let Some(mixnet_min_performance) = min_performance.mixnet_min_performance { - tracing::debug!( - "Filtering gateways based on mixnet_min_performance: {:?}", - min_performance - ); - gateways.retain(|gateway| { - gateway.mixnet_performance.unwrap_or_default() >= mixnet_min_performance - }); - } - } -} - -#[cfg(test)] -mod test { - use nym_http_api_client::UserAgent; - - use super::*; - - fn user_agent() -> UserAgent { - UserAgent { - application: "test".to_string(), - version: "0.0.1".to_string(), - platform: "test".to_string(), - git_commit: "test".to_string(), - } - } - - fn new_mainnet() -> Config { - let mainnet_network_defaults = nym_sdk::NymNetworkDetails::default(); - let default_nyxd_url = mainnet_network_defaults - .endpoints - .first() - .expect("rust sdk mainnet default incorrectly configured") - .nyxd_url(); - let default_api_url = mainnet_network_defaults - .endpoints - .first() - .expect("rust sdk mainnet default incorrectly configured") - .api_url() - .expect("rust sdk mainnet default api_url not parseable"); - - let default_nym_vpn_api_url = mainnet_network_defaults - .nym_vpn_api_url() - .expect("rust sdk mainnet default nym-vpn-api url not parseable"); - - Config { - nyxd_url: default_nyxd_url, - api_url: default_api_url, - nym_vpn_api_url: Some(default_nym_vpn_api_url), - min_gateway_performance: None, - mix_score_thresholds: None, - wg_score_thresholds: None, - } - } - - #[tokio::test] - async fn lookup_described_gateways() { - let config = new_mainnet(); - let client = GatewayClient::new(config, user_agent()).unwrap(); - let gateways = client.lookup_described_nodes().await.unwrap(); - assert!(!gateways.is_empty()); - } - - #[tokio::test] - async fn lookup_gateways_in_nym_vpn_api() { - let config = new_mainnet(); - let client = GatewayClient::new(config, user_agent()).unwrap(); - let gateways = client - .lookup_gateways(GatewayType::MixnetExit) - .await - .unwrap(); - assert!(!gateways.is_empty()); + if let Some(min_performance) = min_gateway_performance + && let Some(mixnet_min_performance) = min_performance.mixnet_min_performance + { + tracing::debug!( + "Filtering gateways based on mixnet_min_performance: {:?}", + min_performance + ); + gateways.retain(|gateway| { + gateway.mixnet_performance.unwrap_or_default() >= mixnet_min_performance + }); } } diff --git a/nym-gateway-directory/src/helpers.rs b/nym-gateway-directory/src/helpers.rs index b869a2c394..07f1e07fba 100644 --- a/nym-gateway-directory/src/helpers.rs +++ b/nym-gateway-directory/src/helpers.rs @@ -1,23 +1,42 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::net::{IpAddr, SocketAddr}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; use nym_common::trace_err_chain; use nym_http_api_client::HickoryDnsResolver; -use crate::{error::Result, gateway_client::ResolvedConfig, Config, Error}; +use crate::{Config, Error, error::Result, gateway_client::ResolvedConfig}; + +// be generous with the resolution timeout +const HOSTNAME_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(10); async fn try_resolve_hostname(hostname: &str) -> Result> { tracing::debug!("Trying to resolve hostname: {hostname}"); let resolver = HickoryDnsResolver::default(); - let addrs = resolver.resolve_str(hostname).await.map_err(|err| { - trace_err_chain!(err, "Failed to resolve gateway hostname"); - Error::FailedToDnsResolveGateway { - hostname: hostname.to_string(), - source: err, - } - })?; + + let addrs = + match tokio::time::timeout(HOSTNAME_RESOLUTION_TIMEOUT, resolver.resolve_str(hostname)) + .await + { + Ok(Ok(addrs)) => addrs, + Ok(Err(err)) => { + trace_err_chain!(err, "Failed to resolve hostname"); + return Err(Error::FailedToDnsResolveGateway { + hostname: hostname.to_string(), + source: err, + }); + } + Err(_timeout) => { + return Err(Error::HostnameResolutionTimeout { + hostname: hostname.to_string(), + }); + } + }; + tracing::debug!("Resolved to: {addrs:?}"); let ips = addrs.iter().collect::>(); @@ -62,3 +81,14 @@ pub async fn resolve_config(config: &Config) -> Result { nym_vpn_api_socket_addrs, }) } + +pub fn split_ips(ips: Vec) -> (Vec, Vec) { + ips.into_iter() + .fold((vec![], vec![]), |(mut v4, mut v6), ip| { + match ip { + IpAddr::V4(ipv4_addr) => v4.push(ipv4_addr), + IpAddr::V6(ipv6_addr) => v6.push(ipv6_addr), + } + (v4, v6) + }) +} diff --git a/nym-gateway-directory/src/lib.rs b/nym-gateway-directory/src/lib.rs index 0744b55ead..94e5d19c05 100644 --- a/nym-gateway-directory/src/lib.rs +++ b/nym-gateway-directory/src/lib.rs @@ -1,30 +1,30 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -mod caching_client; mod entries; mod error; +pub mod gateway_cache; mod gateway_client; mod helpers; - use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::addressing::nodes::NodeIdentity; -pub use nym_vpn_api_client::types::{GatewayMinPerformance, Percent}; +pub use nym_vpn_api_client::types::{GatewayMinPerformance, NaiveFloat, Percent}; pub use crate::{ - caching_client::CachingGatewayClient, entries::{ - auth_addresses::{AuthAddress, AuthAddresses}, + auth_addresses::AuthAddress, country::Country, entry_point::EntryPoint, exit_point::ExitPoint, gateway::{ - Entry, Exit, Gateway, GatewayList, GatewayType, Location, NymNode, Probe, ProbeOutcome, + Asn, AsnKind, Entry, Exit, Gateway, GatewayFilter, GatewayFilters, GatewayList, + GatewayType, Location, NymNode, Performance, Probe, ProbeOutcome, ScoreValue, }, ipr_addresses::IpPacketRouterAddress, score::Score, }, error::Error, + gateway_cache::{GatewayCache, GatewayCacheHandle}, gateway_client::{Config, GatewayClient, ResolvedConfig}, - helpers::resolve_config, + helpers::{resolve_config, split_ips}, };