almost sorted new version gw dir

This commit is contained in:
mfahampshire
2025-10-16 17:55:34 +01:00
parent 2b4a11e273
commit 8fd9cee189
14 changed files with 1193 additions and 701 deletions
Generated
+3 -1
View File
@@ -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",
]
+19 -11
View File
@@ -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
-330
View File
@@ -1,330 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Mutex<CachingGatewayClientInner>>,
}
impl CachingGatewayClient {
pub fn new(
gateway_client: GatewayClient,
connectivity_handle: Option<ConnectivityHandle>,
) -> 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<GatewayList> {
self.inner.lock().await.lookup_gateways(gw_type).await
}
pub async fn lookup_countries(&self, gw_type: GatewayType) -> Result<Vec<Country>> {
self.inner.lock().await.lookup_countries(gw_type).await
}
pub async fn lookup_gateway_ip(&self, gateway_identity: &str) -> Result<IpAddr> {
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<ConnectivityHandle>,
// The cached gateways and their last updated time
cached_gateways: HashMap<GatewayType, (GatewayList, Instant)>,
// The cached countries and their last updated time
cached_countries: HashMap<GatewayType, (Vec<Country>, Instant)>,
}
enum LookupResult {
Gateways(Result<GatewayList>),
Countries(Result<Vec<Country>>),
}
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<GatewayType> {
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<GatewayType> {
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<GatewayType>, country_types: Vec<GatewayType>) {
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<Vec<Country>> {
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<Vec<Country>> {
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<GatewayList> {
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<GatewayList> {
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<GatewayList> {
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<Vec<Country>> {
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<IpAddr> {
// 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
}
}
@@ -1,15 +1,14 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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<Recipient>);
pub struct AuthAddress(Recipient);
impl AuthAddress {
pub(crate) fn try_from_base58_string(address: &str) -> Result<Self> {
@@ -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<Recipient> for AuthAddress {
fn from(recipient: Recipient) -> Self {
Self(recipient)
}
}
impl From<AuthAddress> for Recipient {
fn from(auth_address: AuthAddress) -> Self {
auth_address.0
}
}
@@ -1,15 +1,18 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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<Gateway> {
pub fn lookup_gateway(
&self,
gateways: &GatewayList,
min_score: Option<ScoreValue>,
) -> Result<Gateway> {
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<GatewayFilter>,
min_score: Option<ScoreValue>,
) -> Vec<GatewayFilter> {
if let Some(min_score) = min_score {
base_filters.push(GatewayFilter::MinScore(min_score));
}
base_filters
}
}
+78 -20
View File
@@ -1,14 +1,17 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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<Gateway> {
pub fn lookup_gateway(
&self,
gateways: &GatewayList,
min_score: Option<ScoreValue>,
residential_exit: bool,
) -> Result<Gateway> {
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<GatewayFilter>,
min_score: Option<ScoreValue>,
residential_exit: bool,
) -> Vec<GatewayFilter> {
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
}
}
+521 -138
View File
@@ -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<Location>,
pub ipr_address: Option<IpPacketRouterAddress>,
pub authenticator_address: Option<AuthAddress>,
pub bridge_params: Option<BridgeInformation>,
pub last_probe: Option<Probe>,
pub ips: Vec<IpAddr>,
pub host: Option<String>,
pub clients_ws_port: Option<u16>,
pub clients_wss_port: Option<u16>,
pub mixnet_performance: Option<Percent>,
pub wg_performance: Option<Percent>,
pub wg_score: Option<Score>,
pub mixnet_score: Option<Score>,
pub wg_performance: Option<Performance>,
pub version: Option<String>,
}
@@ -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<Self> {
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<Ipv4Addr>, Vec<Ipv6Addr>) {
helpers::split_ips(self.ips.clone())
}
pub fn clients_address_no_tls(&self) -> Option<String> {
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<ScoreThresholds>,
wg_thresholds: Option<ScoreThresholds>,
) {
pub fn update_to_new_thresholds(&mut self, mix_thresholds: Option<ScoreThresholds>) {
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<GatewayType>, 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<GatewayType>, 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<GatewayType>,
filters: &[GatewayFilter],
) -> bool {
filters
.iter()
.all(|filter| self.matches_filter(gw_type, filter))
}
pub fn get_bridge_params(&self) -> Option<BridgeParameters> {
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<Asn>,
}
#[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<std::cmp::Ordering> {
Some(self.priority().cmp(&other.priority()))
}
}
impl FromStr for ScoreValue {
type Err = crate::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
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<nym_vpn_api_client::response::AsnKind> 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<nym_vpn_api_client::response::Asn> 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<nym_vpn_api_client::response::Location> 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<nym_vpn_api_client::response::ScoreValue> 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<nym_vpn_api_client::response::DVpnGatewayPerformance> 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<nym_vpn_api_client::response::NymDirectoryGateway> 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<nym_vpn_api_client::response::NymDirectoryGateway> 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<nym_vpn_api_client::response::NymDirectoryGateway> 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<nym_validator_client::models::NymNodeDescription> for Gateway {
type Error = Error;
fn try_from(
node_description: nym_validator_client::models::NymNodeDescription,
) -> Result<Self> {
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<GatewayType>,
gateways: Vec<Gateway>,
}
impl GatewayList {
pub fn new(gateways: Vec<Gateway>) -> Self {
GatewayList { gateways }
pub fn new(gw_type: Option<GatewayType>, gateways: Vec<Gateway>) -> 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<Gateway> {
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<Item = &Gateway> {
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<Gateway> {
self.gateways
.iter()
pub fn choose_random(&self, filters: &[GatewayFilter]) -> Option<Gateway> {
self.filter(filters)
.into_iter()
.choose(&mut rand::thread_rng())
.cloned()
}
pub fn random_gateway_located_at(&self, code: String) -> Option<Gateway> {
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<GatewayType> {
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<Country> {
self.all_countries()
Self::new(self.gw_type, self.filter(&[GatewayFilter::Vpn]))
}
pub fn into_inner(self) -> Vec<Gateway> {
@@ -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<GatewayType> 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<GatewayFilter>,
}
#[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<Gateway> = 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()
);
}
}
@@ -1,11 +1,11 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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);
+1 -1
View File
@@ -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),
+31 -1
View File
@@ -1,6 +1,9 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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<HttpClientError>),
#[error("Nym API error: {source}")]
NymApi { source: Box<NymAPIError> },
#[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
+312
View File
@@ -0,0 +1,312 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Command>,
}
impl GatewayCacheHandle {
fn new(tx: tokio::sync::mpsc::UnboundedSender<Command>) -> 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<GatewayList> {
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<Vec<Gateway>> {
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<IpAddr> {
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<Result<GatewayList>>,
),
LookupFilteredGateways(
GatewayFilters,
tokio::sync::oneshot::Sender<Result<Vec<Gateway>>>,
),
LookupGatewayIp(
String, // gateway_identity
tokio::sync::oneshot::Sender<Result<IpAddr>>,
),
ReplaceGatewayClient(Box<GatewayClient>),
}
pub struct GatewayCache {
// The channel for receiving commands
command_rx: tokio::sync::mpsc::UnboundedReceiver<Command>,
// The underlying client that actually does the work
gateway_client: GatewayClient,
// The cached gateways and their last updated time
cached_gateways: HashMap<GatewayType, (GatewayList, Instant)>,
// 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> {
GatewayType::iter()
.filter(|gw_type| !self.is_gateways_current(gw_type))
.collect()
}
async fn refresh(&mut self, gw_list_types: Vec<GatewayType>) {
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<GatewayList> {
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<GatewayList> {
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<Vec<Gateway>> {
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<IpAddr> {
// 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
}
}
+90 -124
View File
@@ -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<nym_vpn_api_client::VpnApiClient>,
nyxd_url: Url,
min_gateway_performance: Option<GatewayMinPerformance>,
@@ -127,7 +126,11 @@ impl GatewayClient {
user_agent: UserAgent,
static_nym_api_ip_addresses: Option<&[SocketAddr]>,
) -> Result<Self> {
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<Self> {
// 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<Vec<SkimmedNode>> {
async fn lookup_skimmed_gateways(&self) -> Result<SkimmedNodesWithMetadata> {
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<Vec<SkimmedNode>> {
async fn lookup_skimmed_nodes(&self) -> Result<SkimmedNodesWithMetadata> {
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<IpAddr> {
@@ -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<GatewayList> {
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::<Vec<_>>();
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<NymNodeList> {
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::<Vec<_>>();
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<GatewayList> {
@@ -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<Vec<Country>> {
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<Gateway>,
min_gateway_performance: &Option<GatewayMinPerformance>,
) {
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
});
}
}
+39 -9
View File
@@ -1,23 +1,42 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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<Vec<IpAddr>> {
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::<Vec<_>>();
@@ -62,3 +81,14 @@ pub async fn resolve_config(config: &Config) -> Result<ResolvedConfig> {
nym_vpn_api_socket_addrs,
})
}
pub fn split_ips(ips: Vec<IpAddr>) -> (Vec<Ipv4Addr>, Vec<Ipv6Addr>) {
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)
})
}
+7 -7
View File
@@ -1,30 +1,30 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// 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},
};