temp commit: got gateway dir dependency working, moving on to vpn-api-client
This commit is contained in:
Generated
-13042
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,7 @@ members = [
|
||||
"nym-credential-proxy/nym-credential-proxy",
|
||||
"nym-credential-proxy/nym-credential-proxy-requests",
|
||||
"nym-data-observatory",
|
||||
"nym-gateway-directory",
|
||||
"nym-ip-packet-client",
|
||||
"nym-network-monitor",
|
||||
"nym-node",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "nym-gateway-directory"
|
||||
# version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
nym-client-core = { path = "../common/client-core" }
|
||||
nym-common = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "c7e38aae038748f7d577a8b1ae02931fff97e868" } # nym-vpn-core 1.11.0
|
||||
nym-http-api-client = { path = "../common/http-api-client" }
|
||||
nym-offline-monitor = { git = "https://github.com/nymtech/nym-vpn-client/", rev = "c7e38aae038748f7d577a8b1ae02931fff97e868" }
|
||||
nym-sdk = { path = "../sdk/rust/nym-sdk" }
|
||||
nym-topology = { path = "../common/topology" }
|
||||
nym-validator-client = { path = "../common/client-libs/validator-client" }
|
||||
# nym-vpn-api-client = { path = "../nym-vpn-api-client" } # pull in remote and see if that works? I wonder if I can sack this off
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
@@ -0,0 +1,329 @@
|
||||
// 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::{FutureExt, StreamExt, stream::FuturesUnordered};
|
||||
use nym_offline_monitor::ConnectivityHandle;
|
||||
pub use nym_sdk::mixnet::NodeIdentity;
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
Config, Country, Error, Gateway, GatewayClient, GatewayList, GatewayType, error::Result,
|
||||
};
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
pub use nym_sdk::mixnet::Recipient;
|
||||
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
// optional, until we remove the wireguard feature flag
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct AuthAddress(pub Option<Recipient>);
|
||||
|
||||
impl AuthAddress {
|
||||
pub(crate) fn try_from_base58_string(address: &str) -> Result<Self> {
|
||||
let recipient = Recipient::try_from_base58_string(address).map_err(|source| {
|
||||
Error::RecipientFormattingError {
|
||||
address: address.to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
Ok(AuthAddress(Some(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 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"entry: {:?} exit: {:?}",
|
||||
self.entry_addr.0, self.exit_addr.0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use crate::Location;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Country {
|
||||
iso_code: String,
|
||||
}
|
||||
|
||||
impl Country {
|
||||
pub fn iso_code(&self) -> &str {
|
||||
&self.iso_code
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::NymDirectoryCountry> for Country {
|
||||
fn from(country: nym_vpn_api_client::response::NymDirectoryCountry) -> Self {
|
||||
Self {
|
||||
iso_code: country.iso_code().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Location> for Country {
|
||||
fn from(location: Location) -> Self {
|
||||
Self {
|
||||
iso_code: location.two_letter_iso_country_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
pub use nym_sdk::mixnet::NodeIdentity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
use super::gateway::{Gateway, GatewayList};
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
// The entry point is always a gateway identity, or some other entry that can be resolved to a
|
||||
// gateway identity.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub enum EntryPoint {
|
||||
// An explicit entry gateway identity.
|
||||
Gateway { identity: NodeIdentity },
|
||||
// Select a random entry gateway in a specific location.
|
||||
Location { location: String },
|
||||
// Select an entry gateway at random.
|
||||
Random,
|
||||
}
|
||||
|
||||
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::Random => write!(f, "Random"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryPoint {
|
||||
pub fn from_base58_string(base58: &str) -> Result<Self> {
|
||||
let identity = NodeIdentity::from_base58_string(base58).map_err(|source| {
|
||||
Error::NodeIdentityFormattingError {
|
||||
identity: base58.to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
Ok(EntryPoint::Gateway { identity })
|
||||
}
|
||||
|
||||
pub fn is_location(&self) -> bool {
|
||||
matches!(self, EntryPoint::Location { .. })
|
||||
}
|
||||
|
||||
pub async fn lookup_gateway(&self, gateways: &GatewayList) -> Result<Gateway> {
|
||||
match &self {
|
||||
EntryPoint::Gateway { identity } => {
|
||||
debug!("Selecting gateway by identity: {}", identity);
|
||||
gateways
|
||||
.gateway_with_identity(identity)
|
||||
.ok_or_else(|| Error::NoMatchingGateway {
|
||||
requested_identity: identity.to_string(),
|
||||
})
|
||||
.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(),
|
||||
available_countries: gateways.all_iso_codes(),
|
||||
})
|
||||
}
|
||||
EntryPoint::Random => {
|
||||
debug!("Selecting a random gateway");
|
||||
gateways
|
||||
.random_gateway()
|
||||
.ok_or_else(|| Error::FailedToSelectGatewayRandomly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
pub use nym_sdk::mixnet::{NodeIdentity, Recipient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::gateway::{Gateway, GatewayList};
|
||||
use crate::{Error, IpPacketRouterAddress, 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub enum ExitPoint {
|
||||
// An explicit exit address. This is useful when the exit ip-packet-router is running as a
|
||||
// standalone entity (private).
|
||||
Address { address: Box<Recipient> },
|
||||
|
||||
// An explicit exit gateway identity. This is useful when the exit ip-packet-router is running
|
||||
// embedded on a gateway.
|
||||
Gateway { identity: NodeIdentity },
|
||||
|
||||
// NOTE: Consider using a crate with strongly typed country codes instead of strings
|
||||
Location { location: String },
|
||||
|
||||
// Select an exit gateway at random.
|
||||
Random,
|
||||
}
|
||||
|
||||
impl Display for ExitPoint {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExitPoint::Address { address } => write!(f, "Address: {address}"),
|
||||
ExitPoint::Gateway { identity } => write!(f, "Gateway: {identity}"),
|
||||
ExitPoint::Location { location } => write!(f, "Location: {location}"),
|
||||
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> {
|
||||
match &self {
|
||||
ExitPoint::Address { address } => {
|
||||
tracing::debug!("Selecting gateway by address: {address}");
|
||||
// There is no validation done when a ip packet router is specified by address
|
||||
// since it might be private and not available in any directory.
|
||||
let ipr_address = IpPacketRouterAddress::from(**address);
|
||||
let gateway_address = ipr_address.gateway();
|
||||
|
||||
// Now fetch the gateway that the IPR is connected to, and override its IPR address
|
||||
let mut gateway = gateways
|
||||
.gateway_with_identity(&gateway_address)
|
||||
.ok_or_else(|| Error::NoMatchingGateway {
|
||||
requested_identity: gateway_address.to_string(),
|
||||
})
|
||||
.cloned()?;
|
||||
gateway.ipr_address = Some(ipr_address);
|
||||
Ok(gateway)
|
||||
}
|
||||
ExitPoint::Gateway { identity } => {
|
||||
tracing::debug!("Selecting gateway by identity: {identity}");
|
||||
gateways
|
||||
.gateway_with_identity(identity)
|
||||
.ok_or_else(|| Error::NoMatchingGateway {
|
||||
requested_identity: identity.to_string(),
|
||||
})
|
||||
.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(),
|
||||
available_countries: gateways.all_iso_codes(),
|
||||
})
|
||||
}
|
||||
ExitPoint::Random => {
|
||||
tracing::debug!("Selecting a random exit gateway");
|
||||
gateways
|
||||
.random_gateway()
|
||||
.ok_or_else(|| Error::FailedToSelectGatewayRandomly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use itertools::Itertools;
|
||||
pub use nym_sdk::mixnet::NodeIdentity;
|
||||
use nym_topology::{NodeId, RoutingNode};
|
||||
use nym_vpn_api_client::types::{NaiveFloat, Percent, ScoreThresholds};
|
||||
use rand::seq::IteratorRandom;
|
||||
use std::{fmt, net::IpAddr};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{AuthAddress, Country, Error, IpPacketRouterAddress, error::Result};
|
||||
|
||||
use super::score::{HIGH_SCORE_THRESHOLD, LOW_SCORE_THRESHOLD, MEDIUM_SCORE_THRESHOLD, Score};
|
||||
|
||||
pub type NymNode = Gateway;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Gateway {
|
||||
pub identity: NodeIdentity,
|
||||
pub moniker: String,
|
||||
pub location: Option<Location>,
|
||||
pub ipr_address: Option<IpPacketRouterAddress>,
|
||||
pub authenticator_address: Option<AuthAddress>,
|
||||
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 version: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Gateway {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Gateway")
|
||||
.field("identity", &self.identity.to_base58_string())
|
||||
.field("location", &self.location)
|
||||
.field("ipr_address", &self.ipr_address)
|
||||
.field("authenticator_address", &self.authenticator_address)
|
||||
.field("last_probe", &self.last_probe)
|
||||
.field("host", &self.host)
|
||||
.field("clients_ws_port", &self.clients_ws_port)
|
||||
.field("clients_wss_port", &self.clients_wss_port)
|
||||
.field("mixnet_performance", &self.mixnet_performance)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
pub fn identity(&self) -> NodeIdentity {
|
||||
self.identity
|
||||
}
|
||||
|
||||
pub fn two_letter_iso_country_code(&self) -> Option<&str> {
|
||||
self.location
|
||||
.as_ref()
|
||||
.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 has_ipr_address(&self) -> bool {
|
||||
self.ipr_address.is_some()
|
||||
}
|
||||
|
||||
pub fn has_authenticator_address(&self) -> bool {
|
||||
self.authenticator_address.is_some()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> Option<&String> {
|
||||
self.host.as_ref()
|
||||
}
|
||||
|
||||
pub fn lookup_ip(&self) -> Option<IpAddr> {
|
||||
self.ips.first().copied()
|
||||
}
|
||||
|
||||
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}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clients_address_tls(&self) -> Option<String> {
|
||||
match (&self.host, &self.clients_wss_port) {
|
||||
(Some(host), Some(port)) => Some(format!("wss://{host}:{port}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_to_new_thresholds(
|
||||
&mut self,
|
||||
mix_thresholds: Option<ScoreThresholds>,
|
||||
wg_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Location {
|
||||
pub two_letter_iso_country_code: String,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Probe {
|
||||
pub last_updated_utc: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProbeOutcome {
|
||||
pub as_entry: Entry,
|
||||
pub as_exit: Option<Exit>,
|
||||
pub wg: Option<WgProbeResults>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub can_connect: bool,
|
||||
pub can_route: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Exit {
|
||||
pub can_connect: bool,
|
||||
pub can_route_ip_v4: bool,
|
||||
pub can_route_ip_external_v4: bool,
|
||||
pub can_route_ip_v6: bool,
|
||||
pub can_route_ip_external_v6: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WgProbeResults {
|
||||
pub can_register: bool,
|
||||
pub can_handshake: bool,
|
||||
pub can_resolve_dns: bool,
|
||||
pub ping_hosts_performance: f32,
|
||||
pub ping_ips_performance: f32,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::Probe> for Probe {
|
||||
fn from(probe: nym_vpn_api_client::response::Probe) -> Self {
|
||||
Probe {
|
||||
last_updated_utc: probe.last_updated_utc,
|
||||
outcome: ProbeOutcome::from(probe.outcome),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Percent> for Score {
|
||||
fn from(percent: Percent) -> Self {
|
||||
let rounded_percent = percent.round_to_integer();
|
||||
if rounded_percent >= HIGH_SCORE_THRESHOLD {
|
||||
Score::High(rounded_percent)
|
||||
} else if rounded_percent >= MEDIUM_SCORE_THRESHOLD {
|
||||
Score::Medium(rounded_percent)
|
||||
} else if rounded_percent > LOW_SCORE_THRESHOLD {
|
||||
Score::Low(rounded_percent)
|
||||
} else {
|
||||
Score::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::ProbeOutcome> for ProbeOutcome {
|
||||
fn from(outcome: nym_vpn_api_client::response::ProbeOutcome) -> Self {
|
||||
ProbeOutcome {
|
||||
as_entry: Entry::from(outcome.as_entry),
|
||||
as_exit: outcome.as_exit.map(Exit::from),
|
||||
wg: outcome.wg.map(WgProbeResults::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::Entry> for Entry {
|
||||
fn from(entry: nym_vpn_api_client::response::Entry) -> Self {
|
||||
Entry {
|
||||
can_connect: entry.can_connect,
|
||||
can_route: entry.can_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::Exit> for Exit {
|
||||
fn from(exit: nym_vpn_api_client::response::Exit) -> Self {
|
||||
Exit {
|
||||
can_connect: exit.can_connect,
|
||||
can_route_ip_v4: exit.can_route_ip_v4,
|
||||
can_route_ip_external_v4: exit.can_route_ip_external_v4,
|
||||
can_route_ip_v6: exit.can_route_ip_v6,
|
||||
can_route_ip_external_v6: exit.can_route_ip_external_v6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::response::WgProbeResults> for WgProbeResults {
|
||||
fn from(results: nym_vpn_api_client::response::WgProbeResults) -> Self {
|
||||
WgProbeResults {
|
||||
can_register: results.can_register,
|
||||
can_handshake: results.can_handshake,
|
||||
can_resolve_dns: results.can_resolve_dns,
|
||||
ping_hosts_performance: results.ping_hosts_performance,
|
||||
ping_ips_performance: results.ping_ips_performance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<nym_vpn_api_client::response::NymDirectoryGateway> for Gateway {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(gateway: nym_vpn_api_client::response::NymDirectoryGateway) -> Result<Self> {
|
||||
let identity =
|
||||
NodeIdentity::from_base58_string(&gateway.identity_key).map_err(|source| {
|
||||
Error::NodeIdentityFormattingError {
|
||||
identity: gateway.identity_key,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
|
||||
let ipr_address = gateway
|
||||
.ip_packet_router
|
||||
.and_then(|ipr| IpPacketRouterAddress::try_from_base58_string(&ipr.address).ok());
|
||||
|
||||
let authenticator_address = gateway
|
||||
.authenticator
|
||||
.and_then(|auth| AuthAddress::try_from_base58_string(&auth.address).ok());
|
||||
|
||||
let hostname = gateway.entry.hostname;
|
||||
let first_ip_address = gateway
|
||||
.ip_addresses
|
||||
.first()
|
||||
.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,
|
||||
moniker: gateway.name,
|
||||
location: Some(gateway.location.into()),
|
||||
ipr_address,
|
||||
authenticator_address,
|
||||
last_probe: gateway.last_probe.map(Probe::from),
|
||||
ips: gateway.ip_addresses,
|
||||
host,
|
||||
clients_ws_port: Some(gateway.entry.ws_port),
|
||||
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),
|
||||
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 {
|
||||
gateways: Vec<Gateway>,
|
||||
}
|
||||
|
||||
impl GatewayList {
|
||||
pub fn new(gateways: Vec<Gateway>) -> Self {
|
||||
GatewayList { gateways }
|
||||
}
|
||||
|
||||
// Returns a list of all locations of the gateways, including duplicates
|
||||
fn all_locations(&self) -> impl Iterator<Item = &Location> {
|
||||
self.gateways
|
||||
.iter()
|
||||
.filter_map(|gateway| gateway.location.as_ref())
|
||||
}
|
||||
|
||||
pub fn all_countries(&self) -> Vec<Country> {
|
||||
self.all_locations()
|
||||
.cloned()
|
||||
.map(Country::from)
|
||||
.unique()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all_iso_codes(&self) -> Vec<String> {
|
||||
self.all_countries()
|
||||
.into_iter()
|
||||
.map(|country| country.iso_code().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn node_with_identity(&self, identity: &NodeIdentity) -> Option<&NymNode> {
|
||||
self.gateways
|
||||
.iter()
|
||||
.find(|node| &node.identity() == identity)
|
||||
}
|
||||
|
||||
pub fn gateway_with_identity(&self, identity: &NodeIdentity) -> Option<&Gateway> {
|
||||
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()
|
||||
.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) {
|
||||
self.gateways
|
||||
.retain(|gateway| gateway.identity() != entry_gateway.identity());
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.gateways.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gateways.is_empty()
|
||||
}
|
||||
|
||||
pub fn into_exit_gateways(self) -> GatewayList {
|
||||
let gw = self
|
||||
.gateways
|
||||
.into_iter()
|
||||
.filter(Gateway::has_ipr_address)
|
||||
.collect();
|
||||
Self::new(gw)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> Vec<Gateway> {
|
||||
self.gateways
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for GatewayList {
|
||||
type Item = Gateway;
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.gateways.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl nym_client_core::init::helpers::ConnectableGateway for Gateway {
|
||||
#[allow(unconditional_recursion)]
|
||||
fn node_id(&self) -> NodeId {
|
||||
self.node_id()
|
||||
}
|
||||
|
||||
fn identity(&self) -> nym_sdk::mixnet::NodeIdentity {
|
||||
self.identity()
|
||||
}
|
||||
|
||||
fn clients_address(&self, _prefer_ipv6: bool) -> Option<String> {
|
||||
// This is a bit of a sharp edge, but temporary until we can remove Option from host
|
||||
// and tls port when we add these to the vpn API endpoints.
|
||||
Some(
|
||||
self.clients_address_tls()
|
||||
.or(self.clients_address_no_tls())
|
||||
.unwrap_or("ws://".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_wss(&self) -> bool {
|
||||
self.clients_address_tls().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, strum::EnumIter)]
|
||||
pub enum GatewayType {
|
||||
MixnetEntry,
|
||||
MixnetExit,
|
||||
Wg,
|
||||
}
|
||||
|
||||
impl fmt::Display for GatewayType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GatewayType::MixnetEntry => write!(f, "mixnet entry"),
|
||||
GatewayType::MixnetExit => write!(f, "mixnet exit"),
|
||||
GatewayType::Wg => write!(f, "vpn"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nym_vpn_api_client::types::GatewayType> for GatewayType {
|
||||
fn from(gateway_type: nym_vpn_api_client::types::GatewayType) -> Self {
|
||||
match gateway_type {
|
||||
nym_vpn_api_client::types::GatewayType::MixnetEntry => GatewayType::MixnetEntry,
|
||||
nym_vpn_api_client::types::GatewayType::MixnetExit => GatewayType::MixnetExit,
|
||||
nym_vpn_api_client::types::GatewayType::Wg => GatewayType::Wg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GatewayType> for nym_vpn_api_client::types::GatewayType {
|
||||
fn from(gateway_type: GatewayType) -> Self {
|
||||
match gateway_type {
|
||||
GatewayType::MixnetEntry => nym_vpn_api_client::types::GatewayType::MixnetEntry,
|
||||
GatewayType::MixnetExit => nym_vpn_api_client::types::GatewayType::MixnetExit,
|
||||
GatewayType::Wg => nym_vpn_api_client::types::GatewayType::Wg,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub use nym_sdk::mixnet::{NodeIdentity, Recipient};
|
||||
use nym_validator_client::models::NymNodeData;
|
||||
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct IpPacketRouterAddress(Recipient);
|
||||
|
||||
impl IpPacketRouterAddress {
|
||||
pub fn try_from_base58_string(ip_packet_router_nym_address: &str) -> Result<Self> {
|
||||
Ok(Self(
|
||||
Recipient::try_from_base58_string(ip_packet_router_nym_address).map_err(|source| {
|
||||
Error::RecipientFormattingError {
|
||||
address: ip_packet_router_nym_address.to_string(),
|
||||
source,
|
||||
}
|
||||
})?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn try_from_described_gateway(gateway: &NymNodeData) -> Result<Self> {
|
||||
let address = gateway
|
||||
.clone()
|
||||
.ip_packet_router
|
||||
.map(|ipr| ipr.address)
|
||||
.ok_or(Error::MissingIpPacketRouterAddress)?;
|
||||
Ok(Self(Recipient::try_from_base58_string(&address).map_err(
|
||||
|source| Error::RecipientFormattingError { address, source },
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn gateway(&self) -> NodeIdentity {
|
||||
self.0.gateway()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpPacketRouterAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Recipient> for IpPacketRouterAddress {
|
||||
fn from(recipient: Recipient) -> Self {
|
||||
Self(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IpPacketRouterAddress> for Recipient {
|
||||
fn from(ipr_address: IpPacketRouterAddress) -> Self {
|
||||
ipr_address.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub(crate) mod auth_addresses;
|
||||
pub(crate) mod country;
|
||||
pub(crate) mod entry_point;
|
||||
pub(crate) mod exit_point;
|
||||
pub(crate) mod gateway;
|
||||
pub(crate) mod ipr_addresses;
|
||||
pub(crate) mod score;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use nym_vpn_api_client::types::ScoreThresholds;
|
||||
|
||||
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)]
|
||||
pub enum Score {
|
||||
High(u8),
|
||||
Medium(u8),
|
||||
Low(u8),
|
||||
None,
|
||||
}
|
||||
|
||||
impl Score {
|
||||
pub fn update_to_new_thresholds(&mut self, thresholds: ScoreThresholds) {
|
||||
let score = match self {
|
||||
Score::None => return,
|
||||
Score::High(score) | Score::Medium(score) | Score::Low(score) => *score,
|
||||
};
|
||||
*self = if score > thresholds.high {
|
||||
Score::High(score)
|
||||
} else if score > thresholds.medium {
|
||||
Score::Medium(score)
|
||||
} else if score > thresholds.low {
|
||||
Score::Low(score)
|
||||
} else {
|
||||
Score::None
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("identity not formatted correctly: {identity}")]
|
||||
NodeIdentityFormattingError {
|
||||
identity: String,
|
||||
source: nym_sdk::mixnet::ed25519::Ed25519RecoveryError,
|
||||
},
|
||||
|
||||
#[error("recipient is not formatted correctly: {address}")]
|
||||
RecipientFormattingError {
|
||||
address: String,
|
||||
source: nym_sdk::mixnet::RecipientFormattingError,
|
||||
},
|
||||
|
||||
#[error(transparent)]
|
||||
ValidatorClientError(#[from] nym_validator_client::ValidatorClientError),
|
||||
|
||||
#[error(transparent)]
|
||||
VpnApiClientError(#[from] nym_vpn_api_client::VpnApiClientError),
|
||||
|
||||
#[error("failed to resolve gateway hostname: {hostname}")]
|
||||
FailedToDnsResolveGateway {
|
||||
hostname: String,
|
||||
source: nym_http_api_client::HickoryDnsError,
|
||||
},
|
||||
|
||||
#[error("resolved hostname {0} but no IP address found")]
|
||||
ResolvedHostnameButNoIp(String),
|
||||
|
||||
#[error("failed to lookup described gateways")]
|
||||
FailedToLookupDescribedGateways(#[source] nym_validator_client::ValidatorClientError),
|
||||
|
||||
#[error("failed to lookup skimmed gateways")]
|
||||
FailedToLookupSkimmedGateways(#[source] nym_validator_client::ValidatorClientError),
|
||||
|
||||
#[error("failed to lookup skimmed nodes")]
|
||||
FailedToLookupSkimmedNodes(#[source] nym_validator_client::ValidatorClientError),
|
||||
|
||||
#[error("requested gateway not found in the remote list: {0}")]
|
||||
RequestedGatewayIdNotFound(String),
|
||||
|
||||
#[error("missing ip packet router address for gateway")]
|
||||
MissingIpPacketRouterAddress,
|
||||
|
||||
#[error("missing hostname or ip address for gateway")]
|
||||
MissingHostnameOrIpAddress { gateway_identity: String },
|
||||
|
||||
#[error("no matching gateway found: {requested_identity}")]
|
||||
NoMatchingGateway { requested_identity: String },
|
||||
|
||||
#[error(
|
||||
"no entry gateway available for location {requested_location}, available countries: {available_countries:?}"
|
||||
)]
|
||||
NoMatchingEntryGatewayForLocation {
|
||||
requested_location: String,
|
||||
available_countries: Vec<String>,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"no exit gateway available for location {requested_location}, available countries: {available_countries:?}"
|
||||
)]
|
||||
NoMatchingExitGatewayForLocation {
|
||||
requested_location: String,
|
||||
available_countries: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("no matching gateway found after selecting low latency: {requested_identity}")]
|
||||
NoMatchingGatewayAfterSelectingLowLatency { requested_identity: String },
|
||||
|
||||
#[error("failed to select gateway randomly")]
|
||||
FailedToSelectGatewayRandomly,
|
||||
|
||||
#[error("gateway {0} doesn't have a description available")]
|
||||
NoGatewayDescriptionAvailable(String),
|
||||
|
||||
#[error("failed to lookup gateway ip for gateway {0}")]
|
||||
FailedToLookupIp(String),
|
||||
|
||||
#[error("the url {url} doesn't parse to a host and/or a port: {reason}")]
|
||||
UrlError { url: url::Url, reason: String },
|
||||
|
||||
#[error("the provided gateway information is malformed")]
|
||||
MalformedGateway,
|
||||
|
||||
#[error("no connectivity")]
|
||||
Offline,
|
||||
}
|
||||
|
||||
// Result type based on our error type
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,508 @@
|
||||
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
net::{IpAddr, SocketAddr},
|
||||
};
|
||||
|
||||
pub use nym_sdk::UserAgent;
|
||||
use nym_validator_client::{NymApiClient, models::NymNodeDescription, nym_nodes::SkimmedNode};
|
||||
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::{
|
||||
Error, NymNode,
|
||||
entries::{
|
||||
country::Country,
|
||||
gateway::{Gateway, GatewayList, GatewayType, NymNodeList},
|
||||
},
|
||||
error::Result,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub nyxd_url: Url,
|
||||
pub api_url: Url,
|
||||
pub nym_vpn_api_url: Option<Url>,
|
||||
pub min_gateway_performance: Option<GatewayMinPerformance>,
|
||||
pub mix_score_thresholds: Option<ScoreThresholds>,
|
||||
pub wg_score_thresholds: Option<ScoreThresholds>,
|
||||
}
|
||||
|
||||
fn to_string<T: fmt::Display>(value: &Option<T>) -> String {
|
||||
match value {
|
||||
Some(value) => value.to_string(),
|
||||
None => "unset".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Config {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"nyxd_url: {}, api_url: {}, nym_vpn_api_url: {}",
|
||||
self.nyxd_url,
|
||||
self.api_url,
|
||||
to_string(&self.nym_vpn_api_url),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn nyxd_url(&self) -> &Url {
|
||||
&self.nyxd_url
|
||||
}
|
||||
|
||||
pub fn with_custom_nyxd_url(mut self, nyxd_url: Url) -> Self {
|
||||
self.nyxd_url = nyxd_url;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn api_url(&self) -> &Url {
|
||||
&self.api_url
|
||||
}
|
||||
|
||||
pub fn with_custom_api_url(mut self, api_url: Url) -> Self {
|
||||
self.api_url = api_url;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn nym_vpn_api_url(&self) -> Option<&Url> {
|
||||
self.nym_vpn_api_url.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_custom_nym_vpn_api_url(mut self, nym_vpn_api_url: Url) -> Self {
|
||||
self.nym_vpn_api_url = Some(nym_vpn_api_url);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_min_gateway_performance(
|
||||
mut self,
|
||||
min_gateway_performance: GatewayMinPerformance,
|
||||
) -> Self {
|
||||
self.min_gateway_performance = Some(min_gateway_performance);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedConfig {
|
||||
pub nyxd_socket_addrs: Vec<SocketAddr>,
|
||||
pub api_socket_addrs: Vec<SocketAddr>,
|
||||
pub nym_vpn_api_socket_addrs: Option<Vec<SocketAddr>>,
|
||||
}
|
||||
|
||||
impl ResolvedConfig {
|
||||
pub fn all_socket_addrs(&self) -> Vec<SocketAddr> {
|
||||
let mut socket_addrs = vec![];
|
||||
socket_addrs.extend(self.nyxd_socket_addrs.iter());
|
||||
socket_addrs.extend(self.api_socket_addrs.iter());
|
||||
if let Some(vpn_api_socket_addrs) = &self.nym_vpn_api_socket_addrs {
|
||||
socket_addrs.extend(vpn_api_socket_addrs.iter());
|
||||
}
|
||||
socket_addrs
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayClient {
|
||||
api_client: NymApiClient,
|
||||
nym_vpn_api_client: Option<nym_vpn_api_client::VpnApiClient>,
|
||||
nyxd_url: Url,
|
||||
min_gateway_performance: Option<GatewayMinPerformance>,
|
||||
mix_score_thresholds: Option<ScoreThresholds>,
|
||||
wg_score_thresholds: Option<ScoreThresholds>,
|
||||
}
|
||||
|
||||
impl GatewayClient {
|
||||
pub fn new(config: Config, user_agent: UserAgent) -> Result<Self> {
|
||||
Self::new_with_resolver_overrides(config, user_agent, None)
|
||||
}
|
||||
|
||||
pub fn new_with_resolver_overrides(
|
||||
config: Config,
|
||||
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 nym_vpn_api_client = config
|
||||
.nym_vpn_api_url
|
||||
.map(|url| {
|
||||
nym_vpn_api_client::VpnApiClient::new_with_resolver_overrides(
|
||||
url,
|
||||
user_agent.clone(),
|
||||
static_nym_api_ip_addresses,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
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 {
|
||||
api_url: self.api_client.api_url().clone(),
|
||||
nym_vpn_api_url: self
|
||||
.nym_vpn_api_client
|
||||
.as_ref()
|
||||
.map(|client| client.current_url().clone()),
|
||||
nyxd_url: self.nyxd_url.clone(),
|
||||
min_gateway_performance: self.min_gateway_performance,
|
||||
mix_score_thresholds: self.mix_score_thresholds,
|
||||
wg_score_thresholds: self.wg_score_thresholds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mixnet_min_performance(&self) -> Option<Percent> {
|
||||
self.min_gateway_performance
|
||||
.as_ref()
|
||||
.and_then(|min_performance| min_performance.mixnet_min_performance)
|
||||
}
|
||||
|
||||
pub fn vpn_min_performance(&self) -> Option<Percent> {
|
||||
self.min_gateway_performance
|
||||
.as_ref()
|
||||
.and_then(|min_performance| min_performance.vpn_min_performance)
|
||||
}
|
||||
|
||||
async fn lookup_described_nodes(&self) -> Result<Vec<NymNodeDescription>> {
|
||||
debug!("Fetching all described nodes from nym-api...");
|
||||
self.api_client
|
||||
.get_all_described_nodes()
|
||||
.await
|
||||
.map_err(Error::FailedToLookupDescribedGateways)
|
||||
}
|
||||
|
||||
async fn lookup_skimmed_gateways(&self) -> Result<Vec<SkimmedNode>> {
|
||||
debug!("Fetching skimmed entry assigned nodes from nym-api...");
|
||||
self.api_client
|
||||
.get_all_basic_entry_assigned_nodes()
|
||||
.await
|
||||
.map_err(Error::FailedToLookupSkimmedGateways)
|
||||
}
|
||||
|
||||
async fn lookup_skimmed_nodes(&self) -> Result<Vec<SkimmedNode>> {
|
||||
debug!("Fetching skimmed entry assigned nodes from nym-api...");
|
||||
self.api_client
|
||||
.get_all_basic_nodes()
|
||||
.await
|
||||
.map_err(Error::FailedToLookupSkimmedNodes)
|
||||
}
|
||||
|
||||
pub async fn lookup_gateway_ip_from_nym_api(&self, gateway_identity: &str) -> Result<IpAddr> {
|
||||
debug!("Fetching gateway ip from nym-api...");
|
||||
let mut ips = self
|
||||
.api_client
|
||||
.get_all_described_nodes()
|
||||
.await?
|
||||
.iter()
|
||||
.find_map(|node| {
|
||||
if node
|
||||
.description
|
||||
.host_information
|
||||
.keys
|
||||
.ed25519
|
||||
.to_base58_string()
|
||||
== gateway_identity
|
||||
{
|
||||
Some(node.description.host_information.ip_address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or(Error::RequestedGatewayIdNotFound(
|
||||
gateway_identity.to_string(),
|
||||
))?;
|
||||
|
||||
if ips.is_empty() {
|
||||
// nym-api should forbid this from ever happening, but we don't want to accidentally panic
|
||||
// if this assumption fails
|
||||
warn!("somehow {gateway_identity} hasn't provided any ip addresses!");
|
||||
return Err(Error::RequestedGatewayIdNotFound(
|
||||
gateway_identity.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
debug!("found the following ips for {gateway_identity}: {ips:?}");
|
||||
if ips.len() == 1 {
|
||||
// SAFETY: the vector is not empty, so unwrap is fine
|
||||
Ok(ips.pop().unwrap())
|
||||
} else {
|
||||
// chose a random one if there's more than one
|
||||
// SAFETY: the vector is not empty, so unwrap is fine
|
||||
let mut rng = thread_rng();
|
||||
let ip = ips.choose(&mut rng).unwrap();
|
||||
Ok(*ip)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lookup_all_gateways_from_nym_api(&self) -> Result<GatewayList> {
|
||||
let mut gateways = self
|
||||
.lookup_described_nodes()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|node| node.description.declared_role.entry)
|
||||
.filter_map(|gw| {
|
||||
Gateway::try_from(gw)
|
||||
.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);
|
||||
filter_on_mixnet_min_performance(&mut gateways, &self.min_gateway_performance);
|
||||
Ok(GatewayList::new(gateways))
|
||||
}
|
||||
|
||||
pub async fn lookup_all_nymnodes(&self) -> Result<NymNodeList> {
|
||||
let mut nodes = self
|
||||
.lookup_described_nodes()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|gw| {
|
||||
NymNode::try_from(gw)
|
||||
.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);
|
||||
filter_on_mixnet_min_performance(&mut nodes, &self.min_gateway_performance);
|
||||
Ok(GatewayList::new(nodes))
|
||||
}
|
||||
|
||||
pub async fn lookup_gateways_from_nym_api(&self, gw_type: GatewayType) -> Result<GatewayList> {
|
||||
match gw_type {
|
||||
GatewayType::MixnetEntry => self.lookup_entry_gateways_from_nym_api().await,
|
||||
GatewayType::MixnetExit => self.lookup_exit_gateways_from_nym_api().await,
|
||||
GatewayType::Wg => self.lookup_vpn_gateways_from_nym_api().await,
|
||||
}
|
||||
}
|
||||
|
||||
// This is currently the same as the set of all gateways, but it doesn't have to be.
|
||||
async fn lookup_entry_gateways_from_nym_api(&self) -> Result<GatewayList> {
|
||||
self.lookup_all_gateways_from_nym_api().await
|
||||
}
|
||||
|
||||
async fn lookup_exit_gateways_from_nym_api(&self) -> Result<GatewayList> {
|
||||
self.lookup_all_gateways_from_nym_api()
|
||||
.await
|
||||
.map(GatewayList::into_exit_gateways)
|
||||
}
|
||||
|
||||
async fn lookup_vpn_gateways_from_nym_api(&self) -> Result<GatewayList> {
|
||||
self.lookup_all_gateways_from_nym_api()
|
||||
.await
|
||||
.map(GatewayList::into_vpn_gateways)
|
||||
}
|
||||
|
||||
pub async fn lookup_gateway_ip(&self, gateway_identity: &str) -> Result<IpAddr> {
|
||||
if let Some(nym_vpn_api_client) = &self.nym_vpn_api_client {
|
||||
debug!("Fetching gateway ip from nym-vpn-api...");
|
||||
let gateway = nym_vpn_api_client
|
||||
.get_gateways(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find_map(|gw| {
|
||||
if gw.identity_key != gateway_identity {
|
||||
None
|
||||
} else {
|
||||
Gateway::try_from(gw)
|
||||
.inspect_err(|err| error!("Failed to parse gateway: {err}"))
|
||||
.ok()
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| Error::RequestedGatewayIdNotFound(gateway_identity.to_string()))?;
|
||||
gateway
|
||||
.lookup_ip()
|
||||
.ok_or(Error::FailedToLookupIp(gateway_identity.to_string()))
|
||||
} else {
|
||||
warn!("OPERATING IN FALLBACK MODE WITHOUT NYM-VPN-API!");
|
||||
self.lookup_gateway_ip_from_nym_api(gateway_identity).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lookup_all_gateways(&self) -> Result<GatewayList> {
|
||||
if let Some(nym_vpn_api_client) = &self.nym_vpn_api_client {
|
||||
debug!("Fetching all gateways from nym-vpn-api...");
|
||||
let gateways: Vec<_> = nym_vpn_api_client
|
||||
.get_gateways(self.min_gateway_performance)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|gw| {
|
||||
Gateway::try_from(gw)
|
||||
.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
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(GatewayList::new(gateways))
|
||||
} else {
|
||||
warn!("OPERATING IN FALLBACK MODE WITHOUT NYM-VPN-API!");
|
||||
self.lookup_all_gateways_from_nym_api().await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lookup_gateways(&self, gw_type: GatewayType) -> Result<GatewayList> {
|
||||
if let Some(nym_vpn_api_client) = &self.nym_vpn_api_client {
|
||||
debug!("Fetching {gw_type} gateways from nym-vpn-api...");
|
||||
let gateways: Vec<_> = nym_vpn_api_client
|
||||
.get_gateways_by_type(gw_type.into(), self.min_gateway_performance)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|gw| {
|
||||
Gateway::try_from(gw)
|
||||
.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
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(GatewayList::new(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
|
||||
// are updated to also include this field.
|
||||
fn append_performance(
|
||||
gateways: &mut [Gateway],
|
||||
basic_gw: Vec<nym_validator_client::nym_nodes::SkimmedNode>,
|
||||
) {
|
||||
debug!("Appending mixnet_performance to gateways");
|
||||
for gateway in gateways.iter_mut() {
|
||||
if let Some(basic_gw) = basic_gw
|
||||
.iter()
|
||||
.find(|bgw| bgw.ed25519_identity_pubkey == gateway.identity())
|
||||
{
|
||||
gateway.mixnet_performance = Some(basic_gw.performance);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Failed to append mixnet_performance, node {} not found among the skimmed nodes",
|
||||
gateway.identity()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_sdk::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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use nym_common::trace_err_chain;
|
||||
use nym_http_api_client::HickoryDnsResolver;
|
||||
|
||||
use crate::{Config, Error, error::Result, gateway_client::ResolvedConfig};
|
||||
|
||||
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,
|
||||
}
|
||||
})?;
|
||||
tracing::debug!("Resolved to: {addrs:?}");
|
||||
|
||||
let ips = addrs.iter().collect::<Vec<_>>();
|
||||
if ips.is_empty() {
|
||||
return Err(Error::ResolvedHostnameButNoIp(hostname.to_string()));
|
||||
}
|
||||
|
||||
Ok(ips)
|
||||
}
|
||||
|
||||
async fn url_to_socket_addr(unresolved_url: &url::Url) -> Result<Vec<SocketAddr>> {
|
||||
let port = unresolved_url
|
||||
.port_or_known_default()
|
||||
.ok_or(Error::UrlError {
|
||||
url: unresolved_url.clone(),
|
||||
reason: "missing port".to_string(),
|
||||
})?;
|
||||
let hostname = unresolved_url.host_str().ok_or(Error::UrlError {
|
||||
url: unresolved_url.clone(),
|
||||
reason: "missing hostname".to_string(),
|
||||
})?;
|
||||
|
||||
Ok(try_resolve_hostname(hostname)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|ip| SocketAddr::new(ip, port))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn resolve_config(config: &Config) -> Result<ResolvedConfig> {
|
||||
let nyxd_socket_addrs = url_to_socket_addr(config.nyxd_url()).await?;
|
||||
let api_socket_addrs = url_to_socket_addr(config.api_url()).await?;
|
||||
let nym_vpn_api_socket_addrs = if let Some(vpn_api_url) = config.nym_vpn_api_url() {
|
||||
Some(url_to_socket_addr(vpn_api_url).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ResolvedConfig {
|
||||
nyxd_socket_addrs,
|
||||
api_socket_addrs,
|
||||
nym_vpn_api_socket_addrs,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
mod caching_client;
|
||||
mod entries;
|
||||
mod error;
|
||||
mod gateway_client;
|
||||
mod helpers;
|
||||
|
||||
pub use nym_sdk::mixnet::{NodeIdentity, Recipient};
|
||||
pub use nym_vpn_api_client::types::{GatewayMinPerformance, Percent};
|
||||
|
||||
pub use crate::{
|
||||
caching_client::CachingGatewayClient,
|
||||
entries::{
|
||||
auth_addresses::{AuthAddress, AuthAddresses},
|
||||
country::Country,
|
||||
entry_point::EntryPoint,
|
||||
exit_point::ExitPoint,
|
||||
gateway::{
|
||||
Entry, Exit, Gateway, GatewayList, GatewayType, Location, NymNode, Probe, ProbeOutcome,
|
||||
},
|
||||
ipr_addresses::IpPacketRouterAddress,
|
||||
score::Score,
|
||||
},
|
||||
error::Error,
|
||||
gateway_client::{Config, GatewayClient, ResolvedConfig},
|
||||
helpers::resolve_config,
|
||||
};
|
||||
@@ -16,6 +16,9 @@ workspace = true
|
||||
bincode.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
nym-gateway-directory = { path = "../nym-gateway-directory" }
|
||||
nym-ip-packet-requests = { path = "../common/ip-packet-requests" }
|
||||
nym-sdk = {path = "../sdk/rust/nym-sdk" }
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -18,6 +18,9 @@ pub enum Error {
|
||||
)]
|
||||
ReceivedResponseWithNewVersion { expected: u8, received: u8 },
|
||||
|
||||
#[error("got reply for connect request, but it appears intended for the wrong address?")]
|
||||
GotReplyIntendedForWrongAddress,
|
||||
|
||||
#[error("unexpected connect response")]
|
||||
UnexpectedConnectResponse,
|
||||
|
||||
@@ -41,6 +44,10 @@ pub enum Error {
|
||||
|
||||
#[error(transparent)]
|
||||
Bincode(#[from] bincode::Error),
|
||||
#[error("failed to create connect request")]
|
||||
FailedToCreateConnectRequest {
|
||||
source: nym_ip_packet_requests::sign::SignatureError,
|
||||
},
|
||||
}
|
||||
|
||||
// Result type based on our error type
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
use nym_ip_packet_requests::{codec::MultiIpPacketCodec, v8::response::ControlResponse};
|
||||
use nym_sdk::mixnet::ReconstructedMessage;
|
||||
pub use nym_sdk::mixnet::ReconstructedMessage;
|
||||
use tokio_util::codec::FramedRead;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "nym-vpn-api-client"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
backon.workspace = true
|
||||
base64-url.workspace = true
|
||||
bip39 = { workspace = true, features = ["zeroize"] }
|
||||
bs58.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
itertools.workspace = true
|
||||
nym-compact-ecash.workspace = true
|
||||
nym-config.workspace = true
|
||||
nym-contracts-common.workspace = true
|
||||
nym-credential-proxy-requests.workspace = true
|
||||
nym-crypto = { workspace = true, features = ["asymmetric", "stream_cipher"] }
|
||||
nym-http-api-client.workspace = true
|
||||
nym-validator-client.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
time = { workspace = true, features = [
|
||||
"serde-human-readable",
|
||||
"serde-well-known",
|
||||
] }
|
||||
tokio = { workspace = true, features = [] }
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
bip39 = { workspace = true, features = ["zeroize"] }
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use nym_http_api_client::{ApiClient, NO_PARAMS};
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
client::NYM_VPN_API_TIMEOUT,
|
||||
error::{Result, VpnApiClientError},
|
||||
response::{NymWellknownDiscoveryItemResponse, RegisteredNetworksResponse},
|
||||
routes,
|
||||
};
|
||||
|
||||
/// Bootstrapping Environments and Network Discovery
|
||||
pub struct BootstrapVpnApiClient {
|
||||
inner: nym_http_api_client::Client,
|
||||
}
|
||||
|
||||
impl BootstrapVpnApiClient {
|
||||
/// Returns a VpnApiClient Based on locally set well known url and empty user agent.
|
||||
///
|
||||
/// THIS SHOULD ONLY BE USED FOR BOOTSTRAPPING.
|
||||
pub fn new(base_url: Url) -> Result<Self> {
|
||||
nym_http_api_client::Client::builder(base_url)
|
||||
.map(|builder| builder.with_timeout(NYM_VPN_API_TIMEOUT))
|
||||
.and_then(|builder| builder.build())
|
||||
.map(|c| Self { inner: c })
|
||||
.map_err(VpnApiClientError::CreateVpnApiClient)
|
||||
}
|
||||
|
||||
pub async fn get_wellknown_envs(&self) -> Result<RegisteredNetworksResponse> {
|
||||
self.inner
|
||||
.get_json(
|
||||
&[
|
||||
routes::PUBLIC,
|
||||
routes::V1,
|
||||
routes::WELLKNOWN,
|
||||
routes::ENVS_FILE,
|
||||
],
|
||||
NO_PARAMS,
|
||||
)
|
||||
.await
|
||||
.map_err(VpnApiClientError::GetNetworkEnvs)
|
||||
}
|
||||
|
||||
pub async fn get_wellknown_discovery(
|
||||
&self,
|
||||
network_name: &str,
|
||||
) -> Result<NymWellknownDiscoveryItemResponse> {
|
||||
self.inner
|
||||
.get_json(
|
||||
&[
|
||||
routes::PUBLIC,
|
||||
routes::V1,
|
||||
routes::WELLKNOWN,
|
||||
network_name,
|
||||
routes::DISCOVERY_FILE,
|
||||
],
|
||||
NO_PARAMS,
|
||||
)
|
||||
.await
|
||||
.map_err(VpnApiClientError::GetDiscoveryInfo)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
pub use nym_http_api_client::HttpClientError;
|
||||
|
||||
use nym_contracts_common::ContractsCommonError;
|
||||
|
||||
use crate::response::{ErrorMessage, NymErrorResponse, UnexpectedError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VpnApiClientError {
|
||||
#[error("failed tp create vpn api client")]
|
||||
CreateVpnApiClient(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get account")]
|
||||
GetAccount(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get account summary")]
|
||||
GetAccountSummary(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get devices")]
|
||||
GetDevices(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to register device")]
|
||||
RegisterDevice(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get active devices")]
|
||||
GetActiveDevices(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get device by id")]
|
||||
GetDeviceById(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get device zk-nym")]
|
||||
GetDeviceZkNyms(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to update device")]
|
||||
UpdateDevice(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to request zk-nym")]
|
||||
RequestZkNym(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get active zk-nym")]
|
||||
GetActiveZkNym(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get zk-nym by id")]
|
||||
GetZkNymById(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to confirm zk-nym download")]
|
||||
ConfirmZkNymDownloadById(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get free passes")]
|
||||
GetFreePasses(#[source] HttpClientError<ErrorMessage>),
|
||||
|
||||
#[error("failed to apply free pass")]
|
||||
ApplyFreepass(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get subscriptions")]
|
||||
GetSubscriptions(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to create subscription")]
|
||||
CreateSubscription(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get active subscription")]
|
||||
GetActiveSubscriptions(#[source] HttpClientError<NymErrorResponse>),
|
||||
|
||||
#[error("failed to get gateways")]
|
||||
GetGateways(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get gateway countries")]
|
||||
GetGatewayCountries(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get entry gateways")]
|
||||
GetEntryGateways(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get entry gateway countries")]
|
||||
GetEntryGatewayCountries(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get exit gateways")]
|
||||
GetExitGateways(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get exit gateway countries")]
|
||||
GetExitGatewayCountries(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get vpn gateways")]
|
||||
GetVpnGateways(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get vpn gateway countries")]
|
||||
GetVpnGatewayCountries(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("invalud percent value")]
|
||||
InvalidPercentValue(#[source] ContractsCommonError),
|
||||
|
||||
#[error("failed to derive from path")]
|
||||
CosmosDeriveFromPath(
|
||||
#[source] nym_validator_client::signing::direct_wallet::DirectSecp256k1HdWalletError,
|
||||
),
|
||||
|
||||
#[error("failed to get directory zk-nym ticketbook partial verification keys")]
|
||||
GetDirectoryZkNymsTicketbookPartialVerificationKeys(#[source] HttpClientError<ErrorMessage>),
|
||||
|
||||
#[error("failed to get health")]
|
||||
GetHealth(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get usage")]
|
||||
GetUsage(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get registered network environments")]
|
||||
GetNetworkEnvs(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get discovery info")]
|
||||
GetDiscoveryInfo(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to get vpn network Details")]
|
||||
GetVpnNetworkDetails(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("failed to post account")]
|
||||
PostAccount(#[source] HttpClientError<UnexpectedError>),
|
||||
|
||||
#[error("create account")]
|
||||
CreateAccount(#[source] crate::types::AccountError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, VpnApiClientError>;
|
||||
|
||||
impl TryFrom<VpnApiClientError> for NymErrorResponse {
|
||||
type Error = VpnApiClientError;
|
||||
|
||||
fn try_from(response: VpnApiClientError) -> std::result::Result<Self, Self::Error> {
|
||||
crate::response::extract_error_response(&response).ok_or(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl VpnApiClientError {
|
||||
pub fn http_client_error<T>(&self) -> Option<&HttpClientError<T>>
|
||||
where
|
||||
T: std::fmt::Display + std::fmt::Debug + 'static,
|
||||
{
|
||||
self.source()
|
||||
.and_then(|source| source.downcast_ref::<HttpClientError<T>>())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use nym_validator_client::{DirectSecp256k1HdWallet, signing::signer::OfflineSigner};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::types::VpnApiTime;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct JwtHeader {
|
||||
/// Type that is always "jwt"
|
||||
typ: String,
|
||||
/// Either "ES256K" or "ECDSA"
|
||||
///
|
||||
/// NOTE: These JWTs are not meant to be used outside NymVPN API, so the encoding of their signatures
|
||||
/// serialisation formats do not follow any RFCs, because NymVPN software creates and consumes them
|
||||
///
|
||||
/// Elliptic curve signatures using secp256k1 scheme, sadly not in the table of standard algorithms in
|
||||
/// https://www.rfc-editor.org/rfc/rfc7518#section-3.1. This scheme is chosen to match the signatures used
|
||||
/// in the Nyx chain based on the Cosmos SDK.
|
||||
///
|
||||
alg: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct JwtPayload {
|
||||
/// Issued at, as a Unix epoch, timezone is UTC
|
||||
iat: u128,
|
||||
/// The number of seconds the token is valid for, after the issued at UTC epoch
|
||||
exp: u8,
|
||||
/// The base58 public key of the account (for signature verification)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pubkey: Option<String>,
|
||||
/// The subject is the Cosmos account id of the user, or the public key of the device
|
||||
sub: String,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Jwt {
|
||||
header: JwtHeader,
|
||||
payload: JwtPayload,
|
||||
signature: String,
|
||||
jwt: String,
|
||||
}
|
||||
|
||||
impl Jwt {
|
||||
pub fn new_secp256k1(wallet: &DirectSecp256k1HdWallet) -> Jwt {
|
||||
let timestamp = std::time::UNIX_EPOCH.elapsed().unwrap().as_secs() as u128;
|
||||
tracing::debug!("timestamp: {}", timestamp);
|
||||
Jwt::new_secp256k1_with_now(wallet, timestamp)
|
||||
}
|
||||
|
||||
pub fn new_secp256k1_synced(wallet: &DirectSecp256k1HdWallet, remote_time: VpnApiTime) -> Jwt {
|
||||
Jwt::new_secp256k1_with_now(wallet, remote_time.estimate_remote_now_unix())
|
||||
}
|
||||
|
||||
pub fn new_secp256k1_with_now(wallet: &DirectSecp256k1HdWallet, now: u128) -> Jwt {
|
||||
let account = wallet.get_accounts().unwrap(); // TODO: result
|
||||
let address = account[0].address();
|
||||
let public_key = account[0].public_key().to_bytes();
|
||||
|
||||
let header = JwtHeader {
|
||||
typ: "JWT".to_string(),
|
||||
alg: "ES256K".to_string(),
|
||||
};
|
||||
let payload = JwtPayload {
|
||||
iat: now,
|
||||
exp: 30,
|
||||
pubkey: Some(bs58::encode(&public_key).into_string()),
|
||||
sub: address.to_string(),
|
||||
};
|
||||
|
||||
let header_base64 = base64_url::encode(&json!(header.clone()).to_string());
|
||||
let payload_base64 = base64_url::encode(&json!(payload.clone()).to_string());
|
||||
let message = format!("{header_base64}.{payload_base64}").into_bytes();
|
||||
|
||||
let signature = wallet.sign_raw(address, message).unwrap(); // TODO: result
|
||||
let signature_bytes = signature.to_bytes().to_vec();
|
||||
|
||||
let signature_base64 = base64_url::encode(&signature_bytes);
|
||||
|
||||
let jwt = format!("{header_base64}.{payload_base64}.{signature_base64}");
|
||||
|
||||
Jwt {
|
||||
header,
|
||||
payload,
|
||||
signature: signature_base64,
|
||||
jwt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_ecdsa(key_pair: &ed25519::KeyPair) -> Jwt {
|
||||
let timestamp = std::time::UNIX_EPOCH.elapsed().unwrap().as_secs() as u128;
|
||||
Jwt::new_ecdsa_with_now(key_pair, timestamp)
|
||||
}
|
||||
|
||||
pub fn new_ecdsa_synced(key_pair: &ed25519::KeyPair, remote_time: VpnApiTime) -> Jwt {
|
||||
Jwt::new_ecdsa_with_now(key_pair, remote_time.estimate_remote_now_unix())
|
||||
}
|
||||
|
||||
pub fn new_ecdsa_with_now(key_pair: &ed25519::KeyPair, now: u128) -> Jwt {
|
||||
let header = JwtHeader {
|
||||
typ: "JWT".to_string(),
|
||||
alg: "ECDSA".to_string(),
|
||||
};
|
||||
let payload = JwtPayload {
|
||||
iat: now,
|
||||
exp: 30,
|
||||
pubkey: None,
|
||||
sub: key_pair.public_key().to_base58_string(),
|
||||
};
|
||||
|
||||
let header_base64 = base64_url::encode(&json!(header.clone()).to_string());
|
||||
let payload_base64 = base64_url::encode(&json!(payload.clone()).to_string());
|
||||
let message = format!("{header_base64}.{payload_base64}").into_bytes();
|
||||
let to_sign = Sha256::digest(&message);
|
||||
|
||||
let signature = key_pair.private_key().sign(to_sign);
|
||||
let signature_bytes = signature.to_bytes();
|
||||
|
||||
let signature_base64 = base64_url::encode(&signature_bytes);
|
||||
|
||||
let jwt = format!("{header_base64}.{payload_base64}.{signature_base64}");
|
||||
|
||||
Jwt {
|
||||
header,
|
||||
payload,
|
||||
signature: signature_base64,
|
||||
jwt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Jwt {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.jwt)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use nym_validator_client::DirectSecp256k1HdWallet;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn get_secp256k1_keypair() -> DirectSecp256k1HdWallet {
|
||||
let mnemonic = "kiwi ketchup mix canvas curve ribbon congress method feel frozen act annual aunt comfort side joy mesh palace tennis cannon orange name tortoise piece";
|
||||
let mnemonic = bip39::Mnemonic::parse(mnemonic).unwrap();
|
||||
DirectSecp256k1HdWallet::from_mnemonic("n", mnemonic)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secp256k1_jwt_matches_javascript() {
|
||||
let now = 1722535718u128;
|
||||
let jwt_expected_from_js_snapshot = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjMwLCJpYXQiOjE3MjI1MzU3MTgsInB1YmtleSI6IndneFpTM25CbWJBd2Nud0FpTnlDTjE5dWNTZHo5cVdkUXlidDJyYWtQVUhyIiwic3ViIjoibjE4Y2phemx4dTd0ODZzODV3YWwzbTdobms4ZXE3cGNmYWtoZHE1MyJ9.qxwY96D-vzMxHWZ840_l6YVDeuZeEkYqz2FaPS8ROztEqipXWCYUi8M1YTH1ZUuNyjgDAMS3NAM3hYvY09ODWQ";
|
||||
let jwt_expected_from_js_snapshot_components: Vec<&str> =
|
||||
jwt_expected_from_js_snapshot.split('.').collect();
|
||||
|
||||
let wallet = get_secp256k1_keypair();
|
||||
let jwt = Jwt::new_secp256k1_with_now(&wallet, now);
|
||||
|
||||
let jwt_str = jwt.to_string();
|
||||
let jwt_components: Vec<&str> = jwt_str.split('.').collect();
|
||||
|
||||
if jwt_str != jwt_expected_from_js_snapshot {
|
||||
println!("== secp256k1 / ED256K1 ==");
|
||||
println!(
|
||||
"jwt_expected_from_js_snapshot = {}",
|
||||
jwt_expected_from_js_snapshot
|
||||
);
|
||||
println!("jwt_str = {}", jwt_str);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[0],
|
||||
jwt_components[0]
|
||||
); // header
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[1],
|
||||
jwt_components[1]
|
||||
); // payload
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[2],
|
||||
jwt_components[2]
|
||||
); // signature
|
||||
|
||||
assert_eq!(jwt_expected_from_js_snapshot, jwt_str); // whole JWT strings
|
||||
}
|
||||
|
||||
fn get_ed25519_keypair() -> ed25519::KeyPair {
|
||||
// let mnemonic = "kiwi ketchup mix canvas curve ribbon congress method feel frozen act annual aunt comfort side joy mesh palace tennis cannon orange name tortoise piece";
|
||||
let private_key_base58 = "9JqXnPvTrWkq1Yq66d8GbXrcz5eryAhPZvZ46cEsBPUY";
|
||||
let public_key_base58 = "4SPdxfBYsuARBw6REQQa5vFiKcvmYiet9sSWqb751i3Z";
|
||||
|
||||
let private_key = bs58::decode(private_key_base58).into_vec().unwrap();
|
||||
let public_key = bs58::decode(public_key_base58).into_vec().unwrap();
|
||||
|
||||
ed25519::KeyPair::from_bytes(&private_key, &public_key).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_ecdsa_jwt_matches_javascript() {
|
||||
let now = 1722535718u128;
|
||||
let jwt_expected_from_js_snapshot = "eyJhbGciOiJFQ0RTQSIsInR5cCI6IkpXVCJ9.eyJleHAiOjMwLCJpYXQiOjE3MjI1MzU3MTgsInN1YiI6IjRTUGR4ZkJZc3VBUkJ3NlJFUVFhNXZGaUtjdm1ZaWV0OXNTV3FiNzUxaTNaIn0.wSd8y1QdqOVYLf2uTMlnymmiIPQwpxXWd2QvPZ-XqV8O1PNiurQO5JPU65SnaOfggJVA5pnAgZLbj9ciOJKIDg";
|
||||
let jwt_expected_from_js_snapshot_components: Vec<&str> =
|
||||
jwt_expected_from_js_snapshot.split('.').collect();
|
||||
|
||||
let key_pair = get_ed25519_keypair();
|
||||
|
||||
let jwt = Jwt::new_ecdsa_with_now(&key_pair, now);
|
||||
|
||||
let jwt_str = jwt.to_string();
|
||||
let jwt_components: Vec<&str> = jwt_str.split('.').collect();
|
||||
|
||||
if jwt_str != jwt_expected_from_js_snapshot {
|
||||
println!("== ed25519 / ECDSA ==");
|
||||
println!(
|
||||
"jwt_expected_from_js_snapshot = {}",
|
||||
jwt_expected_from_js_snapshot
|
||||
);
|
||||
println!("jwt_str = {}", jwt_str);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[0],
|
||||
jwt_components[0]
|
||||
); // header
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[1],
|
||||
jwt_components[1]
|
||||
); // payload
|
||||
assert_eq!(
|
||||
jwt_expected_from_js_snapshot_components[2],
|
||||
jwt_components[2]
|
||||
); // signature
|
||||
|
||||
assert_eq!(jwt_expected_from_js_snapshot, jwt_str); // whole JWT strings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub mod response;
|
||||
pub mod types;
|
||||
|
||||
pub(crate) mod jwt;
|
||||
|
||||
mod bootstrap;
|
||||
mod client;
|
||||
mod error;
|
||||
mod network_compatibility;
|
||||
mod request;
|
||||
mod routes;
|
||||
|
||||
pub use bootstrap::BootstrapVpnApiClient;
|
||||
pub use client::VpnApiClient;
|
||||
pub use error::{HttpClientError, VpnApiClientError};
|
||||
pub use network_compatibility::NetworkCompatibility;
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use crate::response::NetworkCompatibilityResponse;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkCompatibility {
|
||||
pub core: String,
|
||||
pub ios: String,
|
||||
pub macos: String,
|
||||
pub tauri: String,
|
||||
pub android: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for NetworkCompatibility {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"core: {}, ios: {}, macos: {}, tauri: {}, android: {}",
|
||||
self.core, self.ios, self.macos, self.tauri, self.android
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NetworkCompatibilityResponse> for NetworkCompatibility {
|
||||
fn from(response: NetworkCompatibilityResponse) -> Self {
|
||||
NetworkCompatibility {
|
||||
core: response.core,
|
||||
ios: response.ios,
|
||||
macos: response.macos,
|
||||
tauri: response.tauri,
|
||||
android: response.android,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAccountRequestBody {
|
||||
pub account_addr: String,
|
||||
pub pub_key: String,
|
||||
pub signature_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterDeviceRequestBody {
|
||||
pub device_identity_key: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestZkNymRequestBody {
|
||||
pub withdrawal_request: String,
|
||||
pub ecash_pubkey: String,
|
||||
pub expiration_date: String,
|
||||
pub ticketbook_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApplyFreepassRequestBody {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSubscriptionInvoicesRequestBody {
|
||||
pub subscription: String,
|
||||
pub date: String,
|
||||
pub status: CreateSubscriptionInvoicesStatus,
|
||||
pub invoice_no: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreateSubscriptionInvoicesStatus {
|
||||
Unpaid,
|
||||
Paid,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSubscriptionRequestBody {
|
||||
pub valid_from_utc: String,
|
||||
pub subscription_kind: CreateSubscriptionKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreateSubscriptionKind {
|
||||
OneMonth,
|
||||
OneYear,
|
||||
TwoYears,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestRefundRequestBody {
|
||||
subscription_invoice: String,
|
||||
status: RequestRefundRequestStatus,
|
||||
user_reason: RequestRefundRequestUserReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestRefundRequestStatus {
|
||||
Pending,
|
||||
Complete,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestRefundRequestUserReason {
|
||||
SubscriptionInError,
|
||||
PoorPerformance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateDeviceRequestBody {
|
||||
pub status: UpdateDeviceRequestStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UpdateDeviceRequestStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
DeleteMe,
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::{collections::HashSet, fmt, net::IpAddr};
|
||||
|
||||
use itertools::Itertools;
|
||||
use nym_contracts_common::Percent;
|
||||
use nym_credential_proxy_requests::api::v1::ticketbook::models::TicketbookWalletSharesResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::network_compatibility::NetworkCompatibility;
|
||||
|
||||
const MAX_PROBE_RESULT_AGE_MINUTES: i64 = 60;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NymVpnRegisterAccountResponse {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub account_addr: String,
|
||||
pub status: NymVpnRegisterAccountStatusResponse,
|
||||
pub account_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnRegisterAccountStatusResponse {
|
||||
Active,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NymVpnAccountResponse {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub account_addr: String,
|
||||
pub status: NymVpnAccountStatusResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnAccountStatusResponse {
|
||||
Active,
|
||||
Inactive,
|
||||
DeleteMe,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnAccountSummaryResponse {
|
||||
pub account: NymVpnAccountResponse,
|
||||
pub subscription: NymVpnAccountSummarySubscription,
|
||||
pub devices: NymVpnAccountSummaryDevices,
|
||||
pub fair_usage: NymVpnAccountSummaryFairUsage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnAccountSummarySubscription {
|
||||
pub is_active: bool,
|
||||
pub active: Option<NymVpnSubscription>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnAccountSummaryDevices {
|
||||
pub active: u64,
|
||||
pub max: u64,
|
||||
pub remaining: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[allow(non_snake_case)]
|
||||
// These fields have the substring 'GB' in them, meaning we can't use `rename_all = "camelCase"`
|
||||
// like for the other structs
|
||||
pub struct NymVpnAccountSummaryFairUsage {
|
||||
pub usedGB: u64,
|
||||
pub limitGB: u64,
|
||||
pub resetsOnUtc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnHealthResponse {
|
||||
pub status: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub timestamp_utc: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NymVpnDevice {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub device_identity_key: String,
|
||||
pub status: NymVpnDeviceStatus,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnDeviceStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
DeleteMe,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnDevicesResponse {
|
||||
pub total_items: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub items: Vec<NymVpnDevice>,
|
||||
}
|
||||
|
||||
impl fmt::Display for NymVpnDevicesResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.items
|
||||
.iter()
|
||||
.format_with(", ", |item, f| f(&format_args!("{item:?}")))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnRefundsResponse {
|
||||
pub total_items: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub items: Vec<NymVpnRefund>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NymVpnRefund {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub subscription_invoice: String,
|
||||
pub status: NymVpnRefundStatus,
|
||||
pub user_reason: NymVpnRefundUserReason,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnRefundStatus {
|
||||
Pending,
|
||||
Complete,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnRefundUserReason {
|
||||
SubscriptionInError,
|
||||
PoorPerformance,
|
||||
Other,
|
||||
}
|
||||
|
||||
// Legacy type, because the blinded_shares response for the POST seems to be different than the GET
|
||||
// Remove once it's not needed anymore
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct NymVpnZkNymPost {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub id: String,
|
||||
pub ticketbook_type: String,
|
||||
pub valid_until_utc: String,
|
||||
pub valid_from_utc: String,
|
||||
pub issued_bandwidth_in_gb: f64,
|
||||
pub blinded_shares: Option<Vec<Option<TicketbookWalletSharesResponse>>>,
|
||||
pub status: NymVpnZkNymStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct NymVpnZkNym {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub id: String,
|
||||
pub ticketbook_type: String,
|
||||
pub valid_until_utc: String,
|
||||
pub valid_from_utc: String,
|
||||
pub issued_bandwidth_in_gb: f64,
|
||||
pub blinded_shares: Option<TicketbookWalletSharesResponse>,
|
||||
pub status: NymVpnZkNymStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, strum::Display)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnZkNymStatus {
|
||||
Pending,
|
||||
Active,
|
||||
Revoking,
|
||||
Revoked,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnZkNymResponse {
|
||||
pub total_items: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub items: Vec<NymVpnZkNym>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NymVpnSubscription {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub id: String,
|
||||
pub valid_until_utc: String,
|
||||
pub valid_from_utc: String,
|
||||
pub status: NymVpnSubscriptionStatus,
|
||||
pub kind: NymVpnSubscriptionKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnSubscriptionStatus {
|
||||
Pending,
|
||||
Complete,
|
||||
Active,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NymVpnSubscriptionKind {
|
||||
OneMonth,
|
||||
OneYear,
|
||||
TwoYears,
|
||||
Freepass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnSubscriptionResponse {
|
||||
pub is_subscription_active: bool,
|
||||
pub subscription: Option<NymVpnSubscription>,
|
||||
pub remaining_allowance_in_gb: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnSubscriptionsResponse {
|
||||
pub total_items: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub items: Vec<NymVpnSubscription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymVpnUsagesResponse {
|
||||
pub total_items: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub items: Vec<NymVpnUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct NymVpnUsage {
|
||||
pub created_on_utc: String,
|
||||
pub last_updated_utc: String,
|
||||
pub id: String,
|
||||
pub subscription_id: String,
|
||||
pub valid_until_utc: String,
|
||||
pub valid_from_utc: String,
|
||||
pub bandwidth_allowance_gb: f64,
|
||||
pub bandwidth_used_gb: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NymDirectoryGatewaysResponse(Vec<NymDirectoryGateway>);
|
||||
|
||||
impl NymDirectoryGatewaysResponse {
|
||||
pub fn into_inner(self) -> Vec<NymDirectoryGateway> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for NymDirectoryGatewaysResponse {
|
||||
type Item = NymDirectoryGateway;
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NymDirectoryGateway {
|
||||
pub identity_key: String,
|
||||
pub name: String,
|
||||
pub ip_packet_router: Option<IpPacketRouter>,
|
||||
pub authenticator: Option<Authenticator>,
|
||||
pub location: Location,
|
||||
pub last_probe: Option<Probe>,
|
||||
pub ip_addresses: Vec<IpAddr>,
|
||||
pub mix_port: u16,
|
||||
pub role: Role,
|
||||
pub entry: EntryInformation,
|
||||
// The performance data here originates from the nym-api, and is effectively mixnet performance
|
||||
// at the time of writing this
|
||||
pub performance: Percent,
|
||||
pub build_information: Option<BuildInformation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EntryInformation {
|
||||
pub hostname: Option<String>,
|
||||
pub ws_port: u16,
|
||||
pub wss_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UxScore {
|
||||
pub max_score: u8,
|
||||
pub current_score: u8,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct IpPacketRouter {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Authenticator {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub enum Role {
|
||||
// a properly active mixnode
|
||||
Mixnode {
|
||||
layer: u8,
|
||||
},
|
||||
|
||||
#[serde(alias = "entry", alias = "gateway")]
|
||||
EntryGateway,
|
||||
|
||||
#[serde(alias = "exit")]
|
||||
ExitGateway,
|
||||
|
||||
// equivalent of node that's in rewarded set but not in the inactive set
|
||||
Standby,
|
||||
|
||||
Inactive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BuildInformation {
|
||||
pub build_version: String,
|
||||
pub commit_branch: String,
|
||||
pub commit_sha: String,
|
||||
}
|
||||
|
||||
impl NymDirectoryGateway {
|
||||
pub fn is_fully_operational_entry(&self) -> bool {
|
||||
self.last_probe
|
||||
.as_ref()
|
||||
.map(|probe| probe.is_fully_operational_entry())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_fully_operational_exit(&self) -> bool {
|
||||
self.last_probe
|
||||
.as_ref()
|
||||
.map(|probe| probe.is_fully_operational_exit())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Location {
|
||||
pub two_letter_iso_country_code: String,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Probe {
|
||||
pub last_updated_utc: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
pub fn is_fully_operational_entry(&self) -> bool {
|
||||
if !is_recently_updated(&self.last_updated_utc) {
|
||||
return false;
|
||||
}
|
||||
self.outcome.is_fully_operational_entry()
|
||||
}
|
||||
|
||||
pub fn is_fully_operational_exit(&self) -> bool {
|
||||
if !is_recently_updated(&self.last_updated_utc) {
|
||||
return false;
|
||||
}
|
||||
self.outcome.is_fully_operational_exit()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeOutcome {
|
||||
pub as_entry: Entry,
|
||||
pub as_exit: Option<Exit>,
|
||||
pub wg: Option<WgProbeResults>,
|
||||
}
|
||||
|
||||
impl ProbeOutcome {
|
||||
pub fn is_fully_operational_entry(&self) -> bool {
|
||||
self.as_entry.can_connect && self.as_entry.can_route
|
||||
}
|
||||
|
||||
pub fn is_fully_operational_exit(&self) -> bool {
|
||||
self.as_entry.can_connect
|
||||
&& self.as_entry.can_route
|
||||
&& self.as_exit.as_ref().is_some_and(|exit| {
|
||||
exit.can_connect
|
||||
&& exit.can_route_ip_v4
|
||||
&& exit.can_route_ip_external_v4
|
||||
&& exit.can_route_ip_v6
|
||||
&& exit.can_route_ip_external_v6
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub can_connect: bool,
|
||||
pub can_route: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Exit {
|
||||
pub can_connect: bool,
|
||||
pub can_route_ip_v4: bool,
|
||||
pub can_route_ip_external_v4: bool,
|
||||
pub can_route_ip_v6: bool,
|
||||
pub can_route_ip_external_v6: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename = "wg")]
|
||||
pub struct WgProbeResults {
|
||||
pub can_register: bool,
|
||||
pub can_handshake: bool,
|
||||
pub can_resolve_dns: bool,
|
||||
pub ping_hosts_performance: f32,
|
||||
pub ping_ips_performance: f32,
|
||||
}
|
||||
|
||||
fn is_recently_updated(last_updated_utc: &str) -> bool {
|
||||
if let Ok(last_updated) = last_updated_utc.parse::<chrono::DateTime<chrono::Utc>>() {
|
||||
let now = chrono::Utc::now();
|
||||
let duration = now - last_updated;
|
||||
duration.num_minutes() < MAX_PROBE_RESULT_AGE_MINUTES
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NymDirectoryGatewayCountriesResponse(Vec<NymDirectoryCountry>);
|
||||
|
||||
impl NymDirectoryGatewayCountriesResponse {
|
||||
pub fn into_inner(self) -> Vec<NymDirectoryCountry> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for NymDirectoryGatewayCountriesResponse {
|
||||
type Item = NymDirectoryCountry;
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NymDirectoryCountry(String);
|
||||
|
||||
impl NymDirectoryCountry {
|
||||
pub fn iso_code(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for NymDirectoryCountry {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NymErrorResponse {
|
||||
pub message: String,
|
||||
pub message_id: Option<String>,
|
||||
pub code_reference_id: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for NymErrorResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let fields = [
|
||||
Some(format!("message: {}", self.message)),
|
||||
self.message_id
|
||||
.as_deref()
|
||||
.map(|x| format!("message_id: {x}")),
|
||||
self.code_reference_id
|
||||
.as_deref()
|
||||
.map(|x| format!("code_reference_id: {x}")),
|
||||
Some(format!("status: {}", self.status)),
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|x| x.clone())
|
||||
.collect::<Vec<_>>();
|
||||
write!(f, "{}", fields.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorMessage {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorMessage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnexpectedError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for UnexpectedError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StatusOk {
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for StatusOk {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.status)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_error_response<E>(err: &E) -> Option<NymErrorResponse>
|
||||
where
|
||||
E: std::error::Error + 'static,
|
||||
{
|
||||
let mut source = err.source();
|
||||
while let Some(err) = source {
|
||||
if let Some(status) = err
|
||||
.downcast_ref::<nym_http_api_client::HttpClientError<NymErrorResponse>>()
|
||||
.and_then(extract_error_response_inner::<NymErrorResponse>)
|
||||
{
|
||||
return Some(status);
|
||||
}
|
||||
source = err.source();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_error_response_inner<E>(err: &nym_http_api_client::HttpClientError<E>) -> Option<E>
|
||||
where
|
||||
E: Clone + std::fmt::Display,
|
||||
{
|
||||
match err {
|
||||
nym_http_api_client::HttpClientError::EndpointFailure { error, .. } => Some(error.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// The response type we fetch from the discovery endpoint
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NymWellknownDiscoveryItemResponse {
|
||||
pub network_name: String,
|
||||
pub nym_api_url: String,
|
||||
pub nym_vpn_api_url: String,
|
||||
pub account_management: Option<AccountManagementResponse>,
|
||||
pub feature_flags: Option<serde_json::Value>,
|
||||
pub system_messages: Option<Vec<SystemMessageResponse>>,
|
||||
pub system_configuration: Option<SystemConfigurationResponse>,
|
||||
pub network_compatibility: Option<NetworkCompatibilityResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct AccountManagementResponse {
|
||||
pub url: String,
|
||||
pub paths: AccountManagementPathsResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct AccountManagementPathsResponse {
|
||||
pub sign_up: String,
|
||||
pub sign_in: String,
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemMessageResponse {
|
||||
pub name: String,
|
||||
pub display_from: String,
|
||||
pub display_until: String,
|
||||
pub message: String,
|
||||
pub properties: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct SystemConfigurationResponse {
|
||||
pub mix_thresholds: ScoreThresholdsResponse,
|
||||
pub wg_thresholds: ScoreThresholdsResponse,
|
||||
pub statistics_recipient: Option<String>,
|
||||
pub min_supported_app_versions: Option<NetworkCompatibility>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkCompatibilityResponse {
|
||||
pub core: String,
|
||||
pub macos: String,
|
||||
pub ios: String,
|
||||
pub tauri: String,
|
||||
pub android: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct ScoreThresholdsResponse {
|
||||
pub high: u8,
|
||||
pub medium: u8,
|
||||
pub low: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NymWellknownDiscoveryItem {
|
||||
pub network_name: String,
|
||||
pub nym_api_url: String,
|
||||
pub nym_vpn_api_url: String,
|
||||
}
|
||||
|
||||
pub type RegisteredNetworksResponse = HashSet<String>;
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub(crate) const PUBLIC: &str = "public";
|
||||
pub(crate) const V1: &str = "v1";
|
||||
pub(crate) const ACCOUNT: &str = "account";
|
||||
pub(crate) const APPLE: &str = "apple";
|
||||
pub(crate) const HEALTH: &str = "health";
|
||||
pub(crate) const SUMMARY: &str = "summary";
|
||||
pub(crate) const DEVICE: &str = "device";
|
||||
pub(crate) const ACTIVE: &str = "active";
|
||||
pub(crate) const ZKNYM: &str = "zknym";
|
||||
pub(crate) const AVAILABLE: &str = "available";
|
||||
pub(crate) const FREEPASS: &str = "freepass";
|
||||
pub(crate) const SUBSCRIPTION: &str = "subscription";
|
||||
pub(crate) const USAGE: &str = "usage";
|
||||
pub(crate) const DIRECTORY: &str = "directory";
|
||||
pub(crate) const GATEWAYS: &str = "gateways";
|
||||
pub(crate) const COUNTRIES: &str = "countries";
|
||||
pub(crate) const ENTRY: &str = "entry";
|
||||
pub(crate) const EXIT: &str = "exit";
|
||||
pub(crate) const ZK_NYMS: &str = "zk-nyms";
|
||||
pub(crate) const TICKETBOOK: &str = "ticketbook";
|
||||
pub(crate) const PARTIAL_VERIFICATION_KEYS: &str = "partial-verification-keys";
|
||||
|
||||
pub(crate) const SHOW_VPN_ONLY: &str = "show_vpn_only";
|
||||
pub(crate) const VPN_MIN_PERFORMANCE: &str = "vpn_min_performance";
|
||||
pub(crate) const MIXNET_MIN_PERFORMANCE: &str = "mixnet_min_performance";
|
||||
|
||||
pub(crate) const WELLKNOWN: &str = ".wellknown";
|
||||
pub(crate) const ENVS_FILE: &str = "envs.json";
|
||||
pub(crate) const DISCOVERY_FILE: &str = "discovery.json";
|
||||
pub(crate) const CURRENT_ENV: &str = "current-env.json";
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use nym_compact_ecash::scheme::keygen::KeyPairUser;
|
||||
use nym_validator_client::{
|
||||
DirectSecp256k1HdWallet, nyxd::bip32::DerivationPath, signing::signer::OfflineSigner as _,
|
||||
};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::{VpnApiClientError, error::Result, jwt::Jwt};
|
||||
|
||||
const MAX_ACCEPTABLE_SKEW_SECONDS: i64 = 60;
|
||||
const SKEW_SECONDS_CONSIDERED_SAME: i64 = 2;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("wallet error")]
|
||||
Wallet(#[from] nym_validator_client::signing::direct_wallet::DirectSecp256k1HdWalletError),
|
||||
|
||||
#[error("no accounts in wallet")]
|
||||
NoAccounts,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VpnApiAccount {
|
||||
wallet: DirectSecp256k1HdWallet,
|
||||
id: String,
|
||||
pub_key: String,
|
||||
signature_base64: String,
|
||||
}
|
||||
|
||||
impl VpnApiAccount {
|
||||
fn derive_from_wallet(wallet: DirectSecp256k1HdWallet) -> std::result::Result<Self, Error> {
|
||||
let accounts = wallet.get_accounts()?;
|
||||
let address = accounts.first().ok_or(Error::NoAccounts)?.address();
|
||||
let id = address.to_string();
|
||||
let pub_key = bs58::encode(
|
||||
accounts
|
||||
.first()
|
||||
.ok_or(Error::NoAccounts)?
|
||||
.public_key()
|
||||
.to_bytes(),
|
||||
)
|
||||
.into_string();
|
||||
|
||||
let message = id.clone().into_bytes();
|
||||
let signature = wallet.sign_raw(address, message)?;
|
||||
let signature_bytes = signature.to_bytes().to_vec();
|
||||
let signature_base64 = base64_url::encode(&signature_bytes);
|
||||
|
||||
Ok(Self {
|
||||
wallet,
|
||||
id,
|
||||
pub_key,
|
||||
signature_base64,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn random() -> Result<(Self, bip39::Mnemonic)> {
|
||||
let mnemonic = bip39::Mnemonic::generate(24).unwrap();
|
||||
let wallet = DirectSecp256k1HdWallet::from_mnemonic("n", mnemonic.clone());
|
||||
let account = Self::derive_from_wallet(wallet).map_err(VpnApiClientError::CreateAccount)?;
|
||||
Ok((account, mnemonic))
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn pub_key(&self) -> &str {
|
||||
&self.pub_key
|
||||
}
|
||||
|
||||
pub fn signature_base64(&self) -> &str {
|
||||
&self.signature_base64
|
||||
}
|
||||
|
||||
pub(crate) fn jwt(&self, remote_time: Option<VpnApiTime>) -> Jwt {
|
||||
match remote_time {
|
||||
Some(remote_time) => Jwt::new_secp256k1_synced(&self.wallet, remote_time),
|
||||
None => Jwt::new_secp256k1(&self.wallet),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_ecash_keypair(&self) -> Result<KeyPairUser> {
|
||||
let hd_path = cosmos_derivation_path();
|
||||
let extended_private_key = self
|
||||
.wallet
|
||||
.derive_extended_private_key(&hd_path)
|
||||
.map_err(VpnApiClientError::CosmosDeriveFromPath)?;
|
||||
Ok(KeyPairUser::new_seeded(
|
||||
extended_private_key.private_key().to_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_mnemonic(&self) -> String {
|
||||
self.wallet.mnemonic()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<bip39::Mnemonic> for VpnApiAccount {
|
||||
type Error = VpnApiClientError;
|
||||
|
||||
fn try_from(mnemonic: bip39::Mnemonic) -> std::result::Result<Self, Self::Error> {
|
||||
let wallet = DirectSecp256k1HdWallet::from_mnemonic("n", mnemonic.clone());
|
||||
Self::derive_from_wallet(wallet).map_err(VpnApiClientError::CreateAccount)
|
||||
}
|
||||
}
|
||||
|
||||
fn cosmos_derivation_path() -> DerivationPath {
|
||||
nym_config::defaults::COSMOS_DERIVATION_PATH
|
||||
.parse()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VpnApiTime {
|
||||
// The local time on the client.
|
||||
pub local_time: OffsetDateTime,
|
||||
|
||||
// The estimated time on the remote server. Based on RTT, it's not guaranteed to be accurate.
|
||||
pub estimated_remote_time: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl VpnApiTime {
|
||||
pub fn from_estimated_remote_time(
|
||||
local_time: OffsetDateTime,
|
||||
estimated_remote_time: OffsetDateTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
local_time,
|
||||
estimated_remote_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_remote_timestamp(
|
||||
local_time_before_request: OffsetDateTime,
|
||||
remote_timestamp: OffsetDateTime,
|
||||
local_time_after_request: OffsetDateTime,
|
||||
) -> Self {
|
||||
let rtt = local_time_after_request - local_time_before_request;
|
||||
let estimated_remote_time = remote_timestamp + (rtt / 2);
|
||||
Self {
|
||||
local_time: local_time_after_request,
|
||||
estimated_remote_time,
|
||||
}
|
||||
}
|
||||
|
||||
// Local time minus remote time. Meaning if the value is positive, the local time is ahead
|
||||
// of the remote time.
|
||||
pub fn local_time_ahead_skew(&self) -> Duration {
|
||||
self.local_time - self.estimated_remote_time
|
||||
}
|
||||
|
||||
pub fn is_almost_same(&self) -> bool {
|
||||
self.local_time_ahead_skew().abs().whole_seconds() < SKEW_SECONDS_CONSIDERED_SAME
|
||||
}
|
||||
|
||||
pub fn is_acceptable_synced(&self) -> bool {
|
||||
self.local_time_ahead_skew().abs().whole_seconds() < MAX_ACCEPTABLE_SKEW_SECONDS
|
||||
}
|
||||
|
||||
pub fn is_synced(&self) -> VpnApiTimeSynced {
|
||||
if self.is_almost_same() {
|
||||
VpnApiTimeSynced::AlmostSame
|
||||
} else if self.is_acceptable_synced() {
|
||||
VpnApiTimeSynced::AcceptableSynced
|
||||
} else {
|
||||
VpnApiTimeSynced::NotSynced
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_remote_now(&self) -> OffsetDateTime {
|
||||
tracing::debug!(
|
||||
"Estimating remote now using (local time ahead) skew: {}",
|
||||
self.local_time_ahead_skew()
|
||||
);
|
||||
let local_time_now = OffsetDateTime::now_utc();
|
||||
local_time_now - self.local_time_ahead_skew()
|
||||
}
|
||||
|
||||
pub fn estimate_remote_now_unix(&self) -> u128 {
|
||||
self.estimate_remote_now().unix_timestamp() as u128
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VpnApiTime {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Local time: {}, Remote time: {}, Skew: {}",
|
||||
self.local_time,
|
||||
self.estimated_remote_time,
|
||||
self.local_time_ahead_skew(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum VpnApiTimeSynced {
|
||||
AlmostSame,
|
||||
AcceptableSynced,
|
||||
NotSynced,
|
||||
}
|
||||
|
||||
impl VpnApiTimeSynced {
|
||||
pub fn is_synced(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
VpnApiTimeSynced::AlmostSame | VpnApiTimeSynced::AcceptableSynced
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_not_synced(&self) -> bool {
|
||||
!self.is_synced()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::types::test_fixtures::{TEST_DEFAULT_MNEMONIC, TEST_DEFAULT_MNEMONIC_ID};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_account_from_mnemonic() {
|
||||
let account =
|
||||
VpnApiAccount::try_from(bip39::Mnemonic::parse(TEST_DEFAULT_MNEMONIC).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(account.id(), TEST_DEFAULT_MNEMONIC_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_random_account() {
|
||||
let (_, mnemonic) = VpnApiAccount::random().unwrap();
|
||||
assert_eq!(mnemonic.word_count(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_wallets() {
|
||||
for word_count in [12, 24] {
|
||||
let wallet = DirectSecp256k1HdWallet::generate("n", word_count).unwrap();
|
||||
VpnApiAccount::derive_from_wallet(wallet).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
use sha2::Digest as _;
|
||||
|
||||
use crate::{jwt::Jwt, request::UpdateDeviceRequestStatus};
|
||||
|
||||
use super::VpnApiTime;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Device {
|
||||
keypair: Arc<ed25519::KeyPair>,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn identity_key(&self) -> &ed25519::PublicKey {
|
||||
self.keypair.public_key()
|
||||
}
|
||||
|
||||
pub(crate) fn jwt(&self, remote_time: Option<VpnApiTime>) -> Jwt {
|
||||
match remote_time {
|
||||
Some(remote_time) => Jwt::new_ecdsa_synced(&self.keypair, remote_time),
|
||||
None => Jwt::new_ecdsa(&self.keypair),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign<M: AsRef<[u8]>>(&self, message: M) -> DeviceSignature {
|
||||
let digest = {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(message);
|
||||
hasher.finalize()
|
||||
};
|
||||
|
||||
DeviceSignature(self.keypair.private_key().sign(digest))
|
||||
}
|
||||
|
||||
pub fn sign_identity_key(&self) -> DeviceSignature {
|
||||
self.sign(self.identity_key().to_base58_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Device {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Device {{ {} }}", self.identity_key().to_base58_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Device {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Device {{ identity_key: {} }}",
|
||||
self.identity_key().to_base58_string()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<ed25519::KeyPair>> for Device {
|
||||
fn from(keypair: Arc<ed25519::KeyPair>) -> Self {
|
||||
Self { keypair }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ed25519::KeyPair> for Device {
|
||||
fn from(keypair: ed25519::KeyPair) -> Self {
|
||||
Self {
|
||||
keypair: Arc::new(keypair),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In tests we create from a mnemonic, in production these are always created directly from
|
||||
// the keypair
|
||||
#[cfg(test)]
|
||||
impl From<bip39::Mnemonic> for Device {
|
||||
fn from(mnemonic: bip39::Mnemonic) -> Self {
|
||||
let (entropy, _) = mnemonic.to_entropy_array();
|
||||
// Entropy is statically >= 32 bytes, so we can safely extract the first 32
|
||||
// bytes
|
||||
let seed = &entropy[0..32];
|
||||
|
||||
let signing_key = ed25519::PrivateKey::from_bytes(seed).unwrap();
|
||||
let verifying_key = signing_key.public_key();
|
||||
|
||||
let privkey = signing_key.to_bytes().to_vec();
|
||||
let pubkey = verifying_key.to_bytes().to_vec();
|
||||
|
||||
let keypair = ed25519::KeyPair::from_bytes(&privkey, &pubkey).unwrap();
|
||||
|
||||
Self {
|
||||
keypair: Arc::new(keypair),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeviceStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
DeleteMe,
|
||||
}
|
||||
|
||||
impl From<DeviceStatus> for UpdateDeviceRequestStatus {
|
||||
fn from(status: DeviceStatus) -> Self {
|
||||
match status {
|
||||
DeviceStatus::Active => UpdateDeviceRequestStatus::Active,
|
||||
DeviceStatus::Inactive => UpdateDeviceRequestStatus::Inactive,
|
||||
DeviceStatus::DeleteMe => UpdateDeviceRequestStatus::DeleteMe,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeviceSignature(ed25519::Signature);
|
||||
|
||||
impl DeviceSignature {
|
||||
pub fn to_bytes(&self) -> [u8; 64] {
|
||||
self.0.to_bytes()
|
||||
}
|
||||
|
||||
pub fn to_base64_url_string(&self) -> String {
|
||||
base64_url::encode(&self.to_bytes())
|
||||
}
|
||||
|
||||
pub fn to_base64_string(&self) -> String {
|
||||
base64_url::unescape(&self.to_base64_url_string()).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::types::test_fixtures::{
|
||||
TEST_DEFAULT_DEVICE_IDENTITY_KEY, TEST_DEFAULT_DEVICE_MNEMONIC,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ed25519_keypair_fixture() -> ed25519::KeyPair {
|
||||
// The mnemonic used to generate the keypair
|
||||
let _mnemonic = "kiwi ketchup mix canvas curve ribbon congress method feel frozen act annual aunt comfort side joy mesh palace tennis cannon orange name tortoise piece";
|
||||
|
||||
// The corresponding keypair generated from the mnemonic
|
||||
let private_key_base58 = "9JqXnPvTrWkq1Yq66d8GbXrcz5eryAhPZvZ46cEsBPUY";
|
||||
let public_key_base58 = "4SPdxfBYsuARBw6REQQa5vFiKcvmYiet9sSWqb751i3Z";
|
||||
|
||||
let private_key = bs58::decode(private_key_base58).into_vec().unwrap();
|
||||
let public_key = bs58::decode(public_key_base58).into_vec().unwrap();
|
||||
|
||||
ed25519::KeyPair::from_bytes(&private_key, &public_key).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_ed25519_keypair_fixture() {
|
||||
let device = Device::from(
|
||||
bip39::Mnemonic::parse("kiwi ketchup mix canvas curve ribbon congress method feel frozen act annual aunt comfort side joy mesh palace tennis cannon orange name tortoise piece").unwrap()
|
||||
);
|
||||
let expected_keypair = ed25519_keypair_fixture();
|
||||
assert_eq!(
|
||||
device.keypair.private_key().to_base58_string(),
|
||||
expected_keypair.private_key().to_base58_string()
|
||||
);
|
||||
assert_eq!(
|
||||
device.keypair.public_key().to_base58_string(),
|
||||
expected_keypair.public_key().to_base58_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_device_from_mnemonic_1() {
|
||||
let device = Device::from(bip39::Mnemonic::parse(TEST_DEFAULT_DEVICE_MNEMONIC).unwrap());
|
||||
assert_eq!(
|
||||
device.identity_key().to_base58_string(),
|
||||
TEST_DEFAULT_DEVICE_IDENTITY_KEY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_device_from_mnemonic_2() {
|
||||
let device = Device::from(
|
||||
bip39::Mnemonic::parse("kiwi ketchup mix canvas curve ribbon congress method feel frozen act annual aunt comfort side joy mesh palace tennis cannon orange name tortoise piece").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
device.identity_key().to_base58_string(),
|
||||
"4SPdxfBYsuARBw6REQQa5vFiKcvmYiet9sSWqb751i3Z",
|
||||
);
|
||||
assert_eq!(
|
||||
device.keypair.private_key().to_base58_string(),
|
||||
"9JqXnPvTrWkq1Yq66d8GbXrcz5eryAhPZvZ46cEsBPUY",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_device_from_keypair() {
|
||||
let device = Device::from(ed25519_keypair_fixture());
|
||||
assert_eq!(
|
||||
device.keypair.public_key().to_base58_string(),
|
||||
"4SPdxfBYsuARBw6REQQa5vFiKcvmYiet9sSWqb751i3Z",
|
||||
);
|
||||
assert_eq!(
|
||||
device.keypair.private_key().to_base58_string(),
|
||||
"9JqXnPvTrWkq1Yq66d8GbXrcz5eryAhPZvZ46cEsBPUY",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_identity_key() {
|
||||
let device = Device::from(bip39::Mnemonic::parse(TEST_DEFAULT_DEVICE_MNEMONIC).unwrap());
|
||||
assert_eq!(
|
||||
device.identity_key().to_base58_string(),
|
||||
TEST_DEFAULT_DEVICE_IDENTITY_KEY
|
||||
);
|
||||
|
||||
let signature = device.sign_identity_key().to_base64_string();
|
||||
assert_eq!(
|
||||
signature,
|
||||
"W5Zv1QhG37Al0QQH/9tqOmv1MU9IjfWP1xDq116GGSu/1Z6cnAW0sOyfrIiqdEleUKJB9wC/HjcsifaogymWAw=="
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub use nym_contracts_common::Percent;
|
||||
|
||||
use crate::VpnApiClientError;
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct GatewayMinPerformance {
|
||||
pub mixnet_min_performance: Option<Percent>,
|
||||
pub vpn_min_performance: Option<Percent>,
|
||||
}
|
||||
|
||||
impl GatewayMinPerformance {
|
||||
pub fn from_percentage_values(
|
||||
mixnet_min_performance: Option<u64>,
|
||||
vpn_min_performance: Option<u64>,
|
||||
) -> Result<Self, VpnApiClientError> {
|
||||
let mixnet_min_performance = mixnet_min_performance
|
||||
.map(Percent::from_percentage_value)
|
||||
.transpose()
|
||||
.map_err(VpnApiClientError::InvalidPercentValue)?;
|
||||
let vpn_min_performance = vpn_min_performance
|
||||
.map(Percent::from_percentage_value)
|
||||
.transpose()
|
||||
.map_err(VpnApiClientError::InvalidPercentValue)?;
|
||||
Ok(Self {
|
||||
mixnet_min_performance,
|
||||
vpn_min_performance,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_param(self) -> Vec<(String, String)> {
|
||||
let mut params = vec![];
|
||||
if let Some(threshold) = self.mixnet_min_performance {
|
||||
params.push((
|
||||
crate::routes::MIXNET_MIN_PERFORMANCE.to_string(),
|
||||
threshold.to_string(),
|
||||
));
|
||||
};
|
||||
if let Some(threshold) = self.vpn_min_performance {
|
||||
params.push((
|
||||
crate::routes::VPN_MIN_PERFORMANCE.to_string(),
|
||||
threshold.to_string(),
|
||||
));
|
||||
};
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GatewayType {
|
||||
MixnetEntry,
|
||||
MixnetExit,
|
||||
Wg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScoreThresholds {
|
||||
pub high: u8,
|
||||
pub medium: u8,
|
||||
pub low: u8,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
mod account;
|
||||
mod device;
|
||||
mod gateway;
|
||||
mod platform;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_fixtures;
|
||||
|
||||
pub use account::{Error as AccountError, VpnApiAccount, VpnApiTime, VpnApiTimeSynced};
|
||||
pub use device::{Device, DeviceStatus};
|
||||
pub use gateway::{GatewayMinPerformance, GatewayType, ScoreThresholds};
|
||||
pub use platform::Platform;
|
||||
|
||||
pub use nym_contracts_common::{NaiveFloat, Percent};
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Platform {
|
||||
Apple,
|
||||
Unspecified,
|
||||
}
|
||||
impl Platform {
|
||||
pub fn api_path_component(&self) -> &'static str {
|
||||
match self {
|
||||
Platform::Apple => crate::routes::APPLE,
|
||||
Platform::Unspecified => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
// The default acccount mnemonic, the same as in the js integration tests
|
||||
pub(super) const TEST_DEFAULT_MNEMONIC: &str = "range mystery picture decline olympic acoustic lesson quick rebuild panda royal fold start leader egg hammer width olympic worry length crawl couch link mobile";
|
||||
pub(super) const TEST_DEFAULT_MNEMONIC_ID: &str = "n1sslaag27wfydyrvyua72hg5e0vteglxrs8nw3c";
|
||||
|
||||
// The default device mnemonic, the same as in the js integration tests
|
||||
pub(super) const TEST_DEFAULT_DEVICE_MNEMONIC: &str = "pitch deputy proof fire movie put bread ribbon what chef zebra car vacuum gadget steak board state oyster layer glory barely thrive nice box";
|
||||
pub(super) const TEST_DEFAULT_DEVICE_IDENTITY_KEY: &str =
|
||||
"FJDUECYAeosXhNGjxf8w5MJM7N2DfDwQznvWwTxJz6ft";
|
||||
@@ -50,7 +50,7 @@ nym-bin-common = { workspace = true, features = [
|
||||
"basic_tracing",
|
||||
] }
|
||||
|
||||
nym-ip-packet-client = { git = "https://github.com/nymtech/nym-vpn-client", rev = "5c3aacfce158aac73cbd3fd905d90269f8bacc01" } # max/re-export-test https://github.com/nymtech/nym-vpn-client/commit/5c3aacfce158aac73cbd3fd905d90269f8bacc01
|
||||
# nym-ip-packet-client = { git = "https://github.com/nymtech/nym-vpn-client", rev = "5c3aacfce158aac73cbd3fd905d90269f8bacc01" }
|
||||
# nym-gateway-directory
|
||||
|
||||
bytecodec = { workspace = true }
|
||||
|
||||
Reference in New Issue
Block a user