From 0d7487f530a539d7775fbd4c570d880c4906f7e2 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Fri, 31 Oct 2025 13:24:27 +0100 Subject: [PATCH 01/23] bump versions --- Cargo.lock | 14 +++++++------- clients/native/Cargo.toml | 2 +- clients/socks5/Cargo.toml | 2 +- nym-api/Cargo.toml | 2 +- nym-node/Cargo.toml | 2 +- service-providers/network-requester/Cargo.toml | 2 +- tools/nym-cli/Cargo.toml | 2 +- tools/nymvisor/Cargo.toml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e92b97d12c..30e33a0ea3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4824,7 +4824,7 @@ dependencies = [ [[package]] name = "nym-api" -version = "1.1.68" +version = "1.1.69" dependencies = [ "anyhow", "async-trait", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "nym-cli" -version = "1.1.65" +version = "1.1.66" dependencies = [ "anyhow", "base64 0.22.1", @@ -5133,7 +5133,7 @@ dependencies = [ [[package]] name = "nym-client" -version = "1.1.65" +version = "1.1.66" dependencies = [ "bs58", "clap", @@ -6354,7 +6354,7 @@ dependencies = [ [[package]] name = "nym-network-requester" -version = "1.1.66" +version = "1.1.67" dependencies = [ "addr", "anyhow", @@ -6404,7 +6404,7 @@ dependencies = [ [[package]] name = "nym-node" -version = "1.20.0" +version = "1.21.0" dependencies = [ "anyhow", "arc-swap", @@ -6931,7 +6931,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.65" +version = "1.1.66" dependencies = [ "bs58", "clap", @@ -7670,7 +7670,7 @@ dependencies = [ [[package]] name = "nymvisor" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "bytes", diff --git a/clients/native/Cargo.toml b/clients/native/Cargo.toml index 64ce9ba170..3b23db96b3 100644 --- a/clients/native/Cargo.toml +++ b/clients/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-client" -version = "1.1.65" +version = "1.1.66" authors = ["Dave Hrycyszyn ", "Jędrzej Stuczyński "] description = "Implementation of the Nym Client" edition = "2021" diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index b083d8bd40..a4e1b2d6b4 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-socks5-client" -version = "1.1.65" +version = "1.1.66" authors = ["Dave Hrycyszyn "] description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address" edition = "2021" diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index ef70dde2fc..1e3cf67d48 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "nym-api" license = "GPL-3.0" -version = "1.1.68" +version = "1.1.69" authors.workspace = true edition = "2021" rust-version.workspace = true diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index 5c13fb4896..6d43b0aa97 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node" -version = "1.20.0" +version = "1.21.0" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/service-providers/network-requester/Cargo.toml b/service-providers/network-requester/Cargo.toml index 5fe09e0a29..bbddd16187 100644 --- a/service-providers/network-requester/Cargo.toml +++ b/service-providers/network-requester/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "nym-network-requester" license = "GPL-3.0" -version = "1.1.66" +version = "1.1.67" authors.workspace = true edition.workspace = true rust-version = "1.85" diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index a0bb95e645..41f0d2964e 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-cli" -version = "1.1.65" +version = "1.1.66" authors.workspace = true edition = "2021" license.workspace = true diff --git a/tools/nymvisor/Cargo.toml b/tools/nymvisor/Cargo.toml index 84887e27a5..a4fc38d2fa 100644 --- a/tools/nymvisor/Cargo.toml +++ b/tools/nymvisor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nymvisor" -version = "0.1.30" +version = "0.1.31" authors.workspace = true repository.workspace = true homepage.workspace = true From 1400db6156a7aa17946f540ab428b94d0708b194 Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 6 Nov 2025 12:37:27 -0700 Subject: [PATCH 02/23] DNS Reliability Fixes (#6175) --- Cargo.lock | 1 + common/client-core/src/error.rs | 2 +- .../client-libs/gateway-client/src/error.rs | 2 +- common/http-api-client/Cargo.toml | 4 +- common/http-api-client/src/dns.rs | 148 +++++++++++++++--- common/http-api-client/src/lib.rs | 2 +- 6 files changed, 132 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d5dd1e0db..56354ba68f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6057,6 +6057,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tracing", + "tracing-subscriber", "url", "wasmtimer", ] diff --git a/common/client-core/src/error.rs b/common/client-core/src/error.rs index dc6e4fc1b2..eea24910d0 100644 --- a/common/client-core/src/error.rs +++ b/common/client-core/src/error.rs @@ -45,7 +45,7 @@ pub enum ClientCoreError { #[cfg(not(target_arch = "wasm32"))] #[error("resolution failed: {0}")] - ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError), + ResolutionFailed(#[from] nym_http_api_client::ResolveError), #[error("no gateways on network")] NoGatewaysOnNetwork, diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index 8766f9f5a6..eaea37c586 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -54,7 +54,7 @@ pub enum GatewayClientError { #[cfg(not(target_arch = "wasm32"))] #[error("resolution failed: {0}")] - ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError), + ResolutionFailed(#[from] nym_http_api_client::ResolveError), #[error("No shared key was provided or obtained")] NoSharedKeyAvailable, diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index 35da8e0e11..411f9ad846 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -32,6 +32,7 @@ thiserror = { workspace = true } tracing = { workspace = true } itertools = { workspace = true } inventory = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros", "time"] } # used for decoding text responses (they were already implicitly included) bytes = { workspace = true } @@ -52,5 +53,4 @@ workspace = true features = ["tokio"] [dev-dependencies] -tokio = { workspace = true, features = ["rt", "macros"] } - +tracing-subscriber.workspace = true diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index d3fa9dde35..016f4383f7 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -32,17 +32,18 @@ use crate::ClientBuilder; use std::{ net::SocketAddr, sync::{Arc, LazyLock}, + time::Duration, }; use hickory_resolver::{ - ResolveError, TokioResolver, + TokioResolver, config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, lookup_ip::{LookupIp, LookupIpIntoIter}, name_server::TokioConnectionProvider, }; use once_cell::sync::OnceCell; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use tracing::warn; +use tracing::{info, warn}; impl ClientBuilder { /// Override the DNS resolver implementation used by the underlying http client. @@ -72,11 +73,13 @@ static SHARED_RESOLVER: LazyLock = LazyLock::new(|| { }); #[derive(Debug, thiserror::Error)] -#[error("hickory-dns resolver error: {hickory_error}")] +#[allow(missing_docs)] /// Error occurring while resolving a hostname into an IP address. -pub struct HickoryDnsError { - #[from] - hickory_error: ResolveError, +pub enum ResolveError { + #[error("hickory-dns resolver error: {0}")] + HickoryDnsError(#[from] hickory_resolver::ResolveError), + #[error("high level lookup timed out")] + Timeout, } /// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. @@ -87,7 +90,7 @@ pub struct HickoryDnsError { /// The default initialization uses a shared underlying `AsyncResolver`. If a thread local resolver /// is required use `thread_resolver()` to build a resolver with an independently instantiated /// internal `AsyncResolver`. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct HickoryDnsResolver { // Since we might not have been called in the context of a // Tokio Runtime in initialization, so we must delay the actual @@ -95,6 +98,20 @@ pub struct HickoryDnsResolver { state: Arc>, fallback: Option>>, dont_use_shared: bool, + /// Overall timeout for dns lookup associated with any individual host resolution. For example, + /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached. + overall_dns_timeout: Duration, +} + +impl Default for HickoryDnsResolver { + fn default() -> Self { + Self { + state: Default::default(), + fallback: Default::default(), + dont_use_shared: Default::default(), + overall_dns_timeout: Duration::from_secs(10), + } + } } impl Resolve for HickoryDnsResolver { @@ -102,6 +119,7 @@ impl Resolve for HickoryDnsResolver { let resolver = self.state.clone(); let maybe_fallback = self.fallback.clone(); let independent = self.dont_use_shared; + let overall_dns_timeout = self.overall_dns_timeout; Box::pin(async move { let resolver = resolver.get_or_try_init(|| { // using a closure here is slightly gross, but this makes sure that if the @@ -113,10 +131,14 @@ impl Resolve for HickoryDnsResolver { } })?; + let resolve_fut = + tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolver.lookup_ip(name.as_str()).await { - Ok(res) => res, - Err(e) => { + let lookup = match resolve_fut.await { + Ok(Ok(res)) => res, + Err(_) => return Err(ResolveError::Timeout.into()), + Ok(Err(e)) => { if let Some(ref fallback) = maybe_fallback { // on failure use the fall back system configured DNS resolver if !e.is_no_records_found() { @@ -162,13 +184,15 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { + pub async fn resolve_str(&self, name: &str) -> Result { let resolver = self.state.get_or_try_init(|| self.new_resolver())?; + let resolve_fut = tokio::time::timeout(self.overall_dns_timeout, resolver.lookup_ip(name)); // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolver.lookup_ip(name).await { - Ok(res) => res, - Err(e) => { + let lookup = match resolve_fut.await { + Ok(Ok(res)) => res, + Err(_) => return Err(ResolveError::Timeout), + Ok(Err(e)) => { if let Some(ref fallback) = self.fallback { // on failure use the fall back system configured DNS resolver if !e.is_no_records_found() { @@ -194,7 +218,7 @@ impl HickoryDnsResolver { } } - fn new_resolver(&self) -> Result { + fn new_resolver(&self) -> Result { if self.dont_use_shared { new_resolver() } else { @@ -202,7 +226,7 @@ impl HickoryDnsResolver { } } - fn new_resolver_system(&self) -> Result { + fn new_resolver_system(&self) -> Result { if self.dont_use_shared || SHARED_RESOLVER.fallback.is_none() { new_resolver_system() } else { @@ -216,7 +240,7 @@ impl HickoryDnsResolver { } /// Enable fallback to the system default resolver if the primary (DoX) resolver fails - pub fn enable_system_fallback(&mut self) -> Result<(), HickoryDnsError> { + pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> { self.fallback = Some(Default::default()); let _ = self .fallback @@ -235,18 +259,34 @@ impl HickoryDnsResolver { /// Create a new resolver with a custom DoT based configuration. The options are overridden to look /// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm. -fn new_resolver() -> Result { +/// +/// Timeout Defaults to 5 seconds +/// Number of retries after lookup failure before giving up Defaults to 2 +/// +/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup. +/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs. +fn new_resolver() -> Result { + info!("building new configured resolver"); + let mut name_servers = NameServerConfigGroup::quad9_tls(); name_servers.merge(NameServerConfigGroup::quad9_https()); name_servers.merge(NameServerConfigGroup::cloudflare_tls()); name_servers.merge(NameServerConfigGroup::cloudflare_https()); + configure_and_build_resolver(name_servers) +} + +fn configure_and_build_resolver( + name_servers: NameServerConfigGroup, +) -> Result { let config = ResolverConfig::from_parts(None, Vec::new(), name_servers); let mut resolver_builder = TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; resolver_builder.options_mut().server_ordering_strategy = ServerOrderingStrategy::RoundRobin; + // Cache successful responses for queries received by this resolver for 30 min minimum. + resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800)); Ok(resolver_builder.build()) } @@ -254,7 +294,7 @@ fn new_resolver() -> Result { /// Create a new resolver with the default configuration, which reads from the system DNS config /// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6 /// addresses to work with "happy eyeballs" algorithm. -fn new_resolver_system() -> Result { +fn new_resolver_system() -> Result { let mut resolver_builder = TokioResolver::builder_tokio()?; resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; @@ -266,8 +306,9 @@ mod test { use super::*; #[tokio::test] - async fn reqwest_hickory_doh() { - let resolver = HickoryDnsResolver::default(); + async fn reqwest_with_custom_dns() { + let var_name = HickoryDnsResolver::default(); + let resolver = var_name; let client = reqwest::ClientBuilder::new() .dns_resolver(resolver.into()) .build() @@ -286,7 +327,7 @@ mod test { } #[tokio::test] - async fn dns_lookup() -> Result<(), HickoryDnsError> { + async fn dns_lookup() -> Result<(), ResolveError> { let resolver = HickoryDnsResolver::default(); let domain = "ifconfig.me"; @@ -297,3 +338,66 @@ mod test { Ok(()) } } + +#[cfg(test)] +mod failure_test { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + /// IP addresses guaranteed to fail attempts to resolve + /// + /// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6) + const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)), + ]; + + // Create a resolver that behaves the same as the custom configured router, except for the fact + // that it is guaranteed to fail. + fn build_broken_resolver() -> Result { + info!("building new faulty resolver"); + + let mut broken_ns_group = NameServerConfigGroup::from_ips_tls( + GUARANTEED_BROKEN_IPS_1, + 853, + "cloudflare-dns.com".to_string(), + true, + ); + let broken_ns_https = NameServerConfigGroup::from_ips_https( + GUARANTEED_BROKEN_IPS_1, + 443, + "cloudflare-dns.com".to_string(), + true, + ); + broken_ns_group.merge(broken_ns_https); + + configure_and_build_resolver(broken_ns_group) + } + + #[tokio::test] + async fn dns_lookup_failures() -> Result<(), ResolveError> { + let time_start = std::time::Instant::now(); + + let r = OnceCell::new(); + r.set(build_broken_resolver().expect("failed to build resolver")) + .expect("broken resolver init error"); + + // create a new resolver that won't mess with the shared resolver used by other tests + let resolver = HickoryDnsResolver { + dont_use_shared: true, + state: Arc::new(r), + ..Default::default() + }; + build_broken_resolver()?; + let domain = "ifconfig.me"; + let result = resolver.resolve_str(domain).await; + assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout))); + + let duration = time_start.elapsed(); + assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1)); + + Ok(()) + } +} diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 220d151ba7..f2c4e09742 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -179,7 +179,7 @@ mod dns; mod path; #[cfg(not(target_arch = "wasm32"))] -pub use dns::{HickoryDnsError, HickoryDnsResolver}; +pub use dns::{HickoryDnsResolver, ResolveError}; // helper for generating user agent based on binary information #[cfg(not(target_arch = "wasm32"))] From 806f807f02a3f0fa00920b6804fcbb5737288a7d Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 6 Nov 2025 16:46:39 -0700 Subject: [PATCH 03/23] Implement Static DNS fallback (#6178) --- common/http-api-client/Cargo.toml | 1 - common/http-api-client/src/dns.rs | 275 ++++++++++++------ common/http-api-client/src/dns/constants.rs | 73 +++++ .../src/dns/static_resolver.rs | 89 ++++++ 4 files changed, 356 insertions(+), 82 deletions(-) create mode 100644 common/http-api-client/src/dns/constants.rs create mode 100644 common/http-api-client/src/dns/static_resolver.rs diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index 411f9ad846..e748e37888 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -33,7 +33,6 @@ tracing = { workspace = true } itertools = { workspace = true } inventory = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "time"] } - # used for decoding text responses (they were already implicitly included) bytes = { workspace = true } encoding_rs = { workspace = true } diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 016f4383f7..158a867f4e 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -30,7 +30,9 @@ use crate::ClientBuilder; use std::{ - net::SocketAddr, + collections::HashMap, + net::{IpAddr, SocketAddr}, + str::FromStr, sync::{Arc, LazyLock}, time::Duration, }; @@ -38,12 +40,16 @@ use std::{ use hickory_resolver::{ TokioResolver, config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, - lookup_ip::{LookupIp, LookupIpIntoIter}, + lookup_ip::LookupIpIntoIter, name_server::TokioConnectionProvider, }; use once_cell::sync::OnceCell; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use tracing::{info, warn}; +use tracing::*; + +mod constants; +mod static_resolver; +pub use static_resolver::*; impl ClientBuilder { /// Override the DNS resolver implementation used by the underlying http client. @@ -60,10 +66,6 @@ impl ClientBuilder { } } -struct SocketAddrs { - iter: LookupIpIntoIter, -} - // n.b. static items do not call [`Drop`] on program termination, so this won't be deallocated. // this is fine, as the OS can deallocate the terminated program faster than we can free memory // but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional. @@ -76,10 +78,14 @@ static SHARED_RESOLVER: LazyLock = LazyLock::new(|| { #[allow(missing_docs)] /// Error occurring while resolving a hostname into an IP address. pub enum ResolveError { + #[error("invalid name: {0}")] + InvalidNameError(String), #[error("hickory-dns resolver error: {0}")] - HickoryDnsError(#[from] hickory_resolver::ResolveError), + ResolveError(#[from] hickory_resolver::ResolveError), #[error("high level lookup timed out")] Timeout, + #[error("hostname not found in static lookup table")] + StaticLookupMiss, } /// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. @@ -97,6 +103,7 @@ pub struct HickoryDnsResolver { // construction of the resolver. state: Arc>, fallback: Option>>, + static_base: Option>>, dont_use_shared: bool, /// Overall timeout for dns lookup associated with any individual host resolution. For example, /// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached. @@ -108,6 +115,7 @@ impl Default for HickoryDnsResolver { Self { state: Default::default(), fallback: Default::default(), + static_base: Default::default(), dont_use_shared: Default::default(), overall_dns_timeout: Duration::from_secs(10), } @@ -118,60 +126,86 @@ impl Resolve for HickoryDnsResolver { fn resolve(&self, name: Name) -> Resolving { let resolver = self.state.clone(); let maybe_fallback = self.fallback.clone(); + let maybe_static = self.static_base.clone(); let independent = self.dont_use_shared; let overall_dns_timeout = self.overall_dns_timeout; Box::pin(async move { - let resolver = resolver.get_or_try_init(|| { - // using a closure here is slightly gross, but this makes sure that if the - // lazy-init returns an error it can be handled by the client - if independent { - new_resolver() - } else { - Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone()) - } - })?; + resolve( + name, + resolver, + maybe_fallback, + maybe_static, + independent, + overall_dns_timeout, + ) + .await + .map_err(|e| Box::new(e) as Box) + }) + } +} - let resolve_fut = - tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); - - // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolve_fut.await { - Ok(Ok(res)) => res, - Err(_) => return Err(ResolveError::Timeout.into()), - Ok(Err(e)) => { - if let Some(ref fallback) = maybe_fallback { - // on failure use the fall back system configured DNS resolver - if !e.is_no_records_found() { - warn!("primary DNS failed w/ error {e}: using system fallback"); - } - let resolver = fallback.get_or_try_init(|| { - // using a closure here is slightly gross, but this makes sure that if the - // lazy-init returns an error it can be handled by the client - if independent { - new_resolver_system() - } else { - Ok(SHARED_RESOLVER - .fallback - .as_ref() - .ok_or(e)? // if the shared resolver has no fallback return the original error - .get_or_try_init(new_resolver_system)? - .clone()) - } - })?; - - resolver.lookup_ip(name.as_str()).await? - } else { - return Err(e.into()); - } - } - }; +async fn resolve( + name: Name, + resolver: Arc>, + maybe_fallback: Option>>, + maybe_static: Option>>, + independent: bool, + overall_dns_timeout: Duration, +) -> Result { + let resolver = resolver.get_or_try_init(|| HickoryDnsResolver::new_resolver(independent))?; + // Attempt a lookup using the primary resolver + let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + let primary_err = match resolve_fut.await { + Err(_) => ResolveError::Timeout, + Ok(Ok(lookup)) => { let addrs: Addrs = Box::new(SocketAddrs { iter: lookup.into_iter(), }); - Ok(addrs) - }) + return Ok(addrs); + } + Ok(Err(e)) => { + // on failure use the fall back system configured DNS resolver + if !e.is_no_records_found() { + warn!("primary DNS failed w/ error: {e}"); + } + e.into() + } + }; + + // If the primary resolver encountered an error, attempt a lookup using the fallback + // resolver if one is configured. + if let Some(ref fallback) = maybe_fallback { + let resolver = + fallback.get_or_try_init(|| HickoryDnsResolver::new_resolver_system(independent))?; + + let resolve_fut = + tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str())); + if let Ok(Ok(lookup)) = resolve_fut.await { + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + return Ok(addrs); + } } + + // If no record has been found and a static map of fallback addresses is configured + // check the table for our entry + if let Some(ref static_resolver) = maybe_static { + debug!("checking static"); + let resolver = + static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent)); + + if let Ok(addrs) = resolver.resolve(name).await { + return Ok(addrs); + } + } + + Err(primary_err) +} + +struct SocketAddrs { + iter: LookupIpIntoIter, } impl Iterator for SocketAddrs { @@ -184,30 +218,18 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { - let resolver = self.state.get_or_try_init(|| self.new_resolver())?; - let resolve_fut = tokio::time::timeout(self.overall_dns_timeout, resolver.lookup_ip(name)); - - // try the primary DNS resolver that we set up (DoH or DoT or whatever) - let lookup = match resolve_fut.await { - Ok(Ok(res)) => res, - Err(_) => return Err(ResolveError::Timeout), - Ok(Err(e)) => { - if let Some(ref fallback) = self.fallback { - // on failure use the fall back system configured DNS resolver - if !e.is_no_records_found() { - warn!("primary DNS failed w/ error {e}: using system fallback"); - } - - let resolver = fallback.get_or_try_init(|| self.new_resolver_system())?; - resolver.lookup_ip(name).await? - } else { - return Err(e.into()); - } - } - }; - - Ok(lookup) + pub async fn resolve_str(&self, name: &str) -> Result { + let n = + Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?; + resolve( + n, + self.state.clone(), + self.fallback.clone(), + self.static_base.clone(), + self.dont_use_shared, + self.overall_dns_timeout, + ) + .await } /// Create a (lazy-initialized) resolver that is not shared across threads. @@ -218,16 +240,20 @@ impl HickoryDnsResolver { } } - fn new_resolver(&self) -> Result { - if self.dont_use_shared { + fn new_resolver(dont_use_shared: bool) -> Result { + // using a closure here is slightly gross, but this makes sure that if the + // lazy-init returns an error it can be handled by the client + if dont_use_shared { new_resolver() } else { Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone()) } } - fn new_resolver_system(&self) -> Result { - if self.dont_use_shared || SHARED_RESOLVER.fallback.is_none() { + fn new_resolver_system(dont_use_shared: bool) -> Result { + // using a closure here is slightly gross, but this makes sure that if the + // lazy-init returns an error it can be handled by the client + if dont_use_shared || SHARED_RESOLVER.fallback.is_none() { new_resolver_system() } else { Ok(SHARED_RESOLVER @@ -239,6 +265,16 @@ impl HickoryDnsResolver { } } + fn new_static_fallback(dont_use_shared: bool) -> StaticResolver { + if !dont_use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base { + shared_resolver + .get_or_init(new_default_static_fallback) + .clone() + } else { + new_default_static_fallback() + } + } + /// Enable fallback to the system default resolver if the primary (DoX) resolver fails pub fn enable_system_fallback(&mut self) -> Result<(), ResolveError> { self.fallback = Some(Default::default()); @@ -255,6 +291,20 @@ impl HickoryDnsResolver { pub fn disable_system_fallback(&mut self) { self.fallback = None; } + + /// Get the current map of hostname to address in use by the fallback static lookup if one + /// exists. + pub fn get_static_fallbacks(&self) -> Option>> { + Some(self.static_base.as_ref()?.get()?.get_addrs()) + } + + /// Set (or overwrite) the map of addresses used in the fallback static hostname lookup + pub fn set_static_fallbacks(&mut self, addrs: HashMap>) { + let cell = OnceCell::new(); + cell.set(StaticResolver::new(addrs)) + .expect("infallible assign"); + self.static_base = Some(Arc::new(cell)); + } } /// Create a new resolver with a custom DoT based configuration. The options are overridden to look @@ -301,9 +351,15 @@ fn new_resolver_system() -> Result { Ok(resolver_builder.build()) } +fn new_default_static_fallback() -> StaticResolver { + StaticResolver::new(constants::default_static_addrs()) +} + #[cfg(test)] mod test { use super::*; + use itertools::Itertools; + use std::collections::HashMap; #[tokio::test] async fn reqwest_with_custom_dns() { @@ -337,6 +393,31 @@ mod test { Ok(()) } + + #[tokio::test] + async fn static_resolver_as_fallback() -> Result<(), ResolveError> { + let example_domain = "non-existent.nymvpn.com"; + let mut resolver = HickoryDnsResolver { + ..Default::default() + }; + + let result = resolver.resolve_str(example_domain).await; + assert!(result.is_err()); // should be NXDomain + + resolver.static_base = Some(Default::default()); + + let mut addr_map = HashMap::new(); + let example_ip4: IpAddr = "10.10.10.10".parse().unwrap(); + let example_ip6: IpAddr = "dead::beef".parse().unwrap(); + addr_map.insert(example_domain.to_string(), vec![example_ip4, example_ip6]); + + resolver.set_static_fallbacks(addr_map); + + let mut addrs = resolver.resolve_str(example_domain).await?; + assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); + assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + Ok(()) + } } #[cfg(test)] @@ -388,6 +469,7 @@ mod failure_test { let resolver = HickoryDnsResolver { dont_use_shared: true, state: Arc::new(r), + overall_dns_timeout: Duration::from_secs(5), ..Default::default() }; build_broken_resolver()?; @@ -400,4 +482,35 @@ mod failure_test { Ok(()) } + + #[tokio::test] + async fn fallback_to_static() -> Result<(), ResolveError> { + let r = OnceCell::new(); + r.set(build_broken_resolver().expect("failed to build resolver")) + .expect("broken resolver init error"); + + // create a new resolver that won't mess with the shared resolver used by other tests + let resolver = HickoryDnsResolver { + dont_use_shared: true, + state: Arc::new(r), + static_base: Some(Default::default()), + overall_dns_timeout: Duration::from_secs(5), + ..Default::default() + }; + build_broken_resolver()?; + + // successful lookup using fallback to static resolver + let domain = "nymvpn.com"; + let _ = resolver + .resolve_str(domain) + .await + .expect("failed to resolve address in static lookup"); + + // unsuccessful lookup - primary times out, and not in + let domain = "non-existent.nymtech.net"; + let result = resolver.resolve_str(domain).await; + assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout))); + + Ok(()) + } } diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs new file mode 100644 index 0000000000..0672188f9b --- /dev/null +++ b/common/http-api-client/src/dns/constants.rs @@ -0,0 +1,73 @@ +#![allow(missing_docs)] + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +pub const NYM_API_DOMAIN: &str = "validator.nymtech.net"; +pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))]; + +pub const NYM_VPN_API_DOMAIN: &str = "nymvpn.com"; +pub const NYM_VPN_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 21))]; + +pub const NYM_FRONTDOOR_VERCEL_DOMAIN: &str = "nym-frontdoor.vercel.app"; +pub const NYM_FRONTDOOR_VERCEL_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), + IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)), +]; + +pub const NYM_FRONTDOOR_FASTLY_DOMAIN: &str = "nym-frontdoor.global.ssl.fastly.net"; +pub const NYM_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + +pub const NYMVPN_FRONTDOOR_FASTLY_DOMAIN: &str = "nymvpn-frontdoor.global.ssl.fastly.net"; +pub const NYMVPN_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + +pub const VERCEL_APP_DOMAIN: &str = "vercel.app"; +pub const VERCEL_APP_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), + IpAddr::V4(Ipv4Addr::new(216, 198, 79, 195)), +]; + +pub const VERCEL_COM_DOMAIN: &str = "vercel.com"; +pub const VERCEL_COM_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(198, 169, 2, 129)), + IpAddr::V4(Ipv4Addr::new(198, 169, 1, 193)), +]; + +pub const NYM_COM_DOMAIN: &str = "nym.com"; +pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))]; + +pub const NYM_STATS_API_DOMAIN: &str = "nym-statistics-api.nymtech.cc"; +pub const NYM_STATS_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(91, 92, 153, 96))]; + +pub fn default_static_addrs() -> HashMap> { + let mut m = HashMap::new(); + m.insert(NYM_API_DOMAIN.to_string(), NYM_API_IPS.to_vec()); + m.insert(NYM_VPN_API_DOMAIN.to_string(), NYM_VPN_API_IPS.to_vec()); + m.insert( + NYM_FRONTDOOR_VERCEL_DOMAIN.to_string(), + NYM_FRONTDOOR_VERCEL_IPS.to_vec(), + ); + m.insert( + NYM_FRONTDOOR_FASTLY_DOMAIN.to_string(), + NYM_FRONTDOOR_FASTLY_IPS.to_vec(), + ); + m.insert( + NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), + NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), + ); + m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); + m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); + m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec()); + m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec()); + m +} diff --git a/common/http-api-client/src/dns/static_resolver.rs b/common/http-api-client/src/dns/static_resolver.rs new file mode 100644 index 0000000000..030ae4419d --- /dev/null +++ b/common/http-api-client/src/dns/static_resolver.rs @@ -0,0 +1,89 @@ +use crate::dns::ResolveError; + +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex}, +}; + +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use tracing::*; + +#[derive(Debug, Default, Clone)] +pub struct StaticResolver { + static_addr_map: Arc>>>, +} + +impl StaticResolver { + pub fn new(static_entries: HashMap>) -> StaticResolver { + debug!("building static resolver"); + Self { + static_addr_map: Arc::new(Mutex::new(static_entries)), + } + } + + pub fn get_addrs(&self) -> HashMap> { + self.static_addr_map.lock().unwrap().clone() + } +} + +impl Resolve for StaticResolver { + fn resolve(&self, name: Name) -> Resolving { + debug!("looking up {name:?} in static resolver"); + let addr_map = self.static_addr_map.clone(); + Box::pin(async move { + let addr_map = addr_map.lock().unwrap(); + let lookup = match addr_map.get(name.as_str()) { + None => return Err(ResolveError::StaticLookupMiss.into()), + Some(addrs) => addrs, + }; + let addrs: Addrs = Box::new( + lookup + .clone() + .into_iter() + .map(|ip_addr| SocketAddr::new(ip_addr, 0)), + ); + + Ok(addrs) + }) + } +} + +#[cfg(test)] +mod test { + use itertools::Itertools; + + use super::*; + use std::error::Error as StdError; + use std::str::FromStr; + + #[tokio::test] + async fn lookup_using_static_resolver() -> Result<(), Box> { + let example_domain = String::from("static.nymvpn.com"); + + // lookup for domain for which there is no entry + let resolver = StaticResolver::new(HashMap::new()); + + let url = reqwest::dns::Name::from_str(&example_domain).unwrap(); + let result = resolver.resolve(url).await; + assert!(result.is_err()); + match result { + Ok(_) => panic!("lookup with empty map should fail"), + Err(e) => assert_eq!(e.to_string(), ResolveError::StaticLookupMiss.to_string()), + } + + // Successful lookup + let mut addr_map = HashMap::new(); + let example_ip4: IpAddr = "10.10.10.10".parse().unwrap(); + let example_ip6: IpAddr = "dead::beef".parse().unwrap(); + addr_map.insert(example_domain.clone(), vec![example_ip4, example_ip6]); + + let url = reqwest::dns::Name::from_str(&example_domain).unwrap(); + let resolver = StaticResolver::new(addr_map); + let mut addrs = resolver.resolve(url).await?; + assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); + assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + + Ok(()) + } +} From 2f752a6c421ea52b729830c9e7e368652fad9441 Mon Sep 17 00:00:00 2001 From: jmwample Date: Thu, 6 Nov 2025 18:37:50 -0700 Subject: [PATCH 04/23] fix things related to interface changes --- common/client-core/src/init/websockets.rs | 1 - .../gateway-client/src/client/websockets.rs | 1 - common/http-api-client/src/dns.rs | 10 +++++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/common/client-core/src/init/websockets.rs b/common/client-core/src/init/websockets.rs index 6d09a325b0..ba407623a6 100644 --- a/common/client-core/src/init/websockets.rs +++ b/common/client-core/src/init/websockets.rs @@ -30,7 +30,6 @@ pub(crate) async fn connect_async( resolver .resolve_str(domain) .await? - .into_iter() .map(|a| SocketAddr::new(a, port)) .collect() } diff --git a/common/client-libs/gateway-client/src/client/websockets.rs b/common/client-libs/gateway-client/src/client/websockets.rs index 9b04c9e028..2336549d5e 100644 --- a/common/client-libs/gateway-client/src/client/websockets.rs +++ b/common/client-libs/gateway-client/src/client/websockets.rs @@ -39,7 +39,6 @@ pub(crate) async fn connect_async( resolver .resolve_str(domain) .await? - .into_iter() .map(|a| SocketAddr::new(a, port)) .collect() } diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 158a867f4e..216fef032b 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -218,7 +218,10 @@ impl Iterator for SocketAddrs { impl HickoryDnsResolver { /// Attempt to resolve a domain name to a set of ['IpAddr']s - pub async fn resolve_str(&self, name: &str) -> Result { + pub async fn resolve_str( + &self, + name: &str, + ) -> Result + use<>, ResolveError> { let n = Name::from_str(name).map_err(|_| ResolveError::InvalidNameError(name.to_string()))?; resolve( @@ -230,6 +233,7 @@ impl HickoryDnsResolver { self.overall_dns_timeout, ) .await + .map(|addrs| addrs.map(|socket_addr| socket_addr.ip())) } /// Create a (lazy-initialized) resolver that is not shared across threads. @@ -414,8 +418,8 @@ mod test { resolver.set_static_fallbacks(addr_map); let mut addrs = resolver.resolve_str(example_domain).await?; - assert!(addrs.contains(&SocketAddr::new(example_ip4, 0))); - assert!(addrs.contains(&SocketAddr::new(example_ip6, 0))); + assert!(addrs.contains(&example_ip4)); + assert!(addrs.contains(&example_ip6)); Ok(()) } } From 5acce42c642666076739cbf060245bae3e34f066 Mon Sep 17 00:00:00 2001 From: jmwample Date: Tue, 11 Nov 2025 09:14:26 -0700 Subject: [PATCH 05/23] add some staic hosts and switch server strategy --- common/http-api-client/src/dns.rs | 1 - common/http-api-client/src/dns/constants.rs | 25 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 216fef032b..d211c3e221 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -338,7 +338,6 @@ fn configure_and_build_resolver( TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - resolver_builder.options_mut().server_ordering_strategy = ServerOrderingStrategy::RoundRobin; // Cache successful responses for queries received by this resolver for 30 min minimum. resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800)); diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs index 0672188f9b..d879d653a4 100644 --- a/common/http-api-client/src/dns/constants.rs +++ b/common/http-api-client/src/dns/constants.rs @@ -31,6 +31,14 @@ pub const NYMVPN_FRONTDOOR_FASTLY_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), ]; +pub const YELP_FASTLY_DOMAIN: &str = "yelp.global.ssl.fastly.net"; +pub const YELP_FASTLY_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(151, 101, 193, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 129, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 1, 194)), + IpAddr::V4(Ipv4Addr::new(151, 101, 65, 194)), +]; + pub const VERCEL_APP_DOMAIN: &str = "vercel.app"; pub const VERCEL_APP_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(64, 29, 17, 195)), @@ -49,6 +57,18 @@ pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))]; pub const NYM_STATS_API_DOMAIN: &str = "nym-statistics-api.nymtech.cc"; pub const NYM_STATS_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(91, 92, 153, 96))]; +pub const NYM_RPC_DOMAIN: &str = "rpc.nymtech.net"; +pub const NYM_RPC_IPS: &[IpAddr] = &[ + IpAddr::V4(Ipv4Addr::new(194, 182, 169, 49)), + IpAddr::V4(Ipv4Addr::new(91, 92, 200, 116)), + IpAddr::V4(Ipv6Addr::new( + 0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483, + )), + IpAddr::V4(Ipv6Addr::new( + 0x2a04, 0xc46, 0xe00, 0x6f28, 0x4b3, 0x68ff, 0xfe00, 0x460, + )), +]; + pub fn default_static_addrs() -> HashMap> { let mut m = HashMap::new(); m.insert(NYM_API_DOMAIN.to_string(), NYM_API_IPS.to_vec()); @@ -65,9 +85,14 @@ pub fn default_static_addrs() -> HashMap> { NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), ); + m.insert( + YELP_FRONTDOOR_FASTLY_DOMAIN.to_string(), + YELP_FRONTDOOR_FASTLY_IPS.to_vec(), + ); m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec()); m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec()); + m.insert(NYM_RPC_DOMAIN.to_string(), NYM_RPC_IPS.to_vec()); m } From 872c25bfcc4645a0a7234c2b095e3b5fa15b67c2 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Tue, 11 Nov 2025 16:22:41 +0000 Subject: [PATCH 06/23] Use hardcoded devrel gw for examples to get around CSP (#6187) * Use hardcoded devrel gw for examples to get around CSP * remove comment --- documentation/docs/components/mix-fetch.tsx | 5 +++-- .../components/outputs/api-scraping-outputs/time-now.md | 2 +- .../outputs/command-outputs/node-api-check-query-help.md | 2 +- documentation/docs/components/traffic.tsx | 2 ++ documentation/docs/next.config.js | 2 +- .../docs/pages/developers/typescript/examples/mix-fetch.mdx | 6 ++---- .../pages/developers/typescript/playground/mixfetch.mdx | 5 +++++ .../docs/pages/developers/typescript/playground/traffic.mdx | 4 ++++ 8 files changed, 19 insertions(+), 9 deletions(-) diff --git a/documentation/docs/components/mix-fetch.tsx b/documentation/docs/components/mix-fetch.tsx index 6726b74998..11aeaedeb3 100644 --- a/documentation/docs/components/mix-fetch.tsx +++ b/documentation/docs/components/mix-fetch.tsx @@ -9,11 +9,12 @@ import Stack from "@mui/material/Stack"; import Paper from "@mui/material/Paper"; import type { SetupMixFetchOps } from "@nymproject/mix-fetch-full-fat"; -const defaultUrl = "https://nymtech.net/.wellknown/network-requester/exit-policy.txt"; +const defaultUrl = + "https://nymtech.net/.wellknown/network-requester/exit-policy.txt"; const args = { mode: "unsafe-ignore-cors" }; const mixFetchOptions: SetupMixFetchOps = { - preferredGateway: "2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW", // with WSS + preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", // with WSS // preferredNetworkRequester: // "CTDxrcXgrZHWyCWnuCgjpJPghQUcEVz1HkhUr5mGdFnT.3UAww1YWNyVNYNWFQL1LaHYouQtDiXBGK5GiDZgpXkTK@2RFtU5BwxvJJXagAWAEuaPgb5ZVPRoy2542TT93Edw6v", mixFetchOverride: { diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 3351fa154b..81f9be9b3c 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Monday, November 10th 2025, 13:30:27 UTC +Tuesday, November 11th 2025, 13:59:52 UTC diff --git a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md index bfa068ddb0..999f3d4a6f 100644 --- a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md +++ b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md @@ -11,7 +11,7 @@ options: --no_routing_history Display node stats without routing history --no_verloc_metrics Display node stats without verloc metrics -m, --markdown Display results in markdown format - -o [OUTPUT], --output [OUTPUT] + -o, --output [OUTPUT] Save results to file (in current dir or supply with path without filename) ``` diff --git a/documentation/docs/components/traffic.tsx b/documentation/docs/components/traffic.tsx index 0d298ffff6..120c2ffc2f 100644 --- a/documentation/docs/components/traffic.tsx +++ b/documentation/docs/components/traffic.tsx @@ -13,6 +13,7 @@ import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; const nymApiUrl = "https://validator.nymtech.net/api"; +const preferredGateway = "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1"; export const Traffic = () => { const [nym, setNym] = useState(); @@ -31,6 +32,7 @@ export const Traffic = () => { clientId: crypto.randomUUID(), nymApiUrl, forceTls: true, // force WSS + preferredGateway, }); // check when is connected and set the self address diff --git a/documentation/docs/next.config.js b/documentation/docs/next.config.js index 5f13a40c23..59613ca1e6 100644 --- a/documentation/docs/next.config.js +++ b/documentation/docs/next.config.js @@ -1108,7 +1108,7 @@ const config = { form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; - connect-src 'self' wss: https://github.com *.vercel.app *.nymtech.net *.nymvpn.com *.nymte.ch *.nyx.network *.nym.com https://nym.com nymvpn.com https://nymvpn.com *.nymtech.cc; + connect-src 'self' wss://nym-node-cli.devrel.nymte.ch:9001 https://github.com *.vercel.app *.nymtech.net *.nymvpn.com *.nymte.ch *.nyx.network *.nym.com https://nym.com nymvpn.com https://nymvpn.com *.nymtech.cc; frame-src 'self' https://vercel.live *.vercel.app *.nym.com https://nym.com; worker-src 'self' blob: https://vercel.live *.vercel.app *.nym.com https://nym.com; `; diff --git a/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx b/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx index e1c4dfe56e..4bc35df9a1 100644 --- a/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx +++ b/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx @@ -18,7 +18,7 @@ Sounds great, are there any catches? Well, there are a few (for now): Right now Gateways are not required to run a Secure Websocket (WSS) listener, so only a subset of nodes running in Gateway mode have configured their nodes to do so. -For the moment you have to select a Gateway that has WSS enabled and Network Requester from [Harbourmaster Gateways for mixFetch list](https://harbourmaster.nymtech.net/). +For the moment you have to select a Gateway that has WSS from [Harbourmaster Gateways for mixFetch list](https://harbourmaster.nymtech.net/). ``` curl -X 'GET' \ @@ -33,9 +33,7 @@ curl -X 'GET' \ import type { SetupMixFetchOps } from '@nymproject/mix-fetch'; const mixFetchOptions: SetupMixFetchOps = { - preferredGateway: '23A7CSaBSA2L67PWuFTPXUnYrCdyVcB7ATYsjUsfdftb', // with WSS - preferredNetworkRequester: - 'HuNL1pFprNSKW6jdqppibXP5KNKCNJxDh7ivpYcoULN9.C62NahRTUf6kqpNtDVHXoVriQr6yyaU5LtxdgpbsGrtA@23A7CSaBSA2L67PWuFTPXUnYrCdyVcB7ATYsjUsfdftb', + preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", // with WSS mixFetchOverride: { requestTimeoutMs: 60_000, }, diff --git a/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx b/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx index ff6801a49a..307dd35bd4 100644 --- a/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx +++ b/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx @@ -10,4 +10,9 @@ import { Callout } from 'nextra/components' + + + Open your browser's console to see the connection and send/receive logging for this example. + + diff --git a/documentation/docs/pages/developers/typescript/playground/traffic.mdx b/documentation/docs/pages/developers/typescript/playground/traffic.mdx index 81af690fed..d3852de9e3 100644 --- a/documentation/docs/pages/developers/typescript/playground/traffic.mdx +++ b/documentation/docs/pages/developers/typescript/playground/traffic.mdx @@ -7,5 +7,9 @@ import FormattedTrafficExampleCode from '../../../../code-examples/sdk/typescrip Use this tool to experiment with the mixnet: send and receive messages! + + Open your browser's console to see the connection and send/receive logging for this example. + + From 10951d4cd33efbec98dd2e4700085f18bc73c450 Mon Sep 17 00:00:00 2001 From: jmwample Date: Tue, 11 Nov 2025 10:40:25 -0700 Subject: [PATCH 07/23] clippy, fmt, minor fix --- common/http-api-client/src/dns.rs | 2 +- common/http-api-client/src/dns/constants.rs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index d211c3e221..8687cf7f27 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -39,7 +39,7 @@ use std::{ use hickory_resolver::{ TokioResolver, - config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ServerOrderingStrategy}, + config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig}, lookup_ip::LookupIpIntoIter, name_server::TokioConnectionProvider, }; diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs index d879d653a4..b25dc43a1c 100644 --- a/common/http-api-client/src/dns/constants.rs +++ b/common/http-api-client/src/dns/constants.rs @@ -1,7 +1,7 @@ #![allow(missing_docs)] use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; pub const NYM_API_DOMAIN: &str = "validator.nymtech.net"; pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))]; @@ -61,10 +61,10 @@ pub const NYM_RPC_DOMAIN: &str = "rpc.nymtech.net"; pub const NYM_RPC_IPS: &[IpAddr] = &[ IpAddr::V4(Ipv4Addr::new(194, 182, 169, 49)), IpAddr::V4(Ipv4Addr::new(91, 92, 200, 116)), - IpAddr::V4(Ipv6Addr::new( + IpAddr::V6(Ipv6Addr::new( 0x2a04, 0xc43, 0xe00, 0x6f28, 0x400, 0xd8ff, 0xfe00, 0x1483, )), - IpAddr::V4(Ipv6Addr::new( + IpAddr::V6(Ipv6Addr::new( 0x2a04, 0xc46, 0xe00, 0x6f28, 0x4b3, 0x68ff, 0xfe00, 0x460, )), ]; @@ -85,10 +85,7 @@ pub fn default_static_addrs() -> HashMap> { NYMVPN_FRONTDOOR_FASTLY_DOMAIN.to_string(), NYMVPN_FRONTDOOR_FASTLY_IPS.to_vec(), ); - m.insert( - YELP_FRONTDOOR_FASTLY_DOMAIN.to_string(), - YELP_FRONTDOOR_FASTLY_IPS.to_vec(), - ); + m.insert(YELP_FASTLY_DOMAIN.to_string(), YELP_FASTLY_IPS.to_vec()); m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec()); m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec()); m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec()); From babf113fe5d396fa8a84fa939ad4b1b5b4d38b83 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Wed, 12 Nov 2025 08:39:48 +0100 Subject: [PATCH 08/23] update changelog --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc13213494..20653fe313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// ## [Unreleased] +## [2025.20-leerdammer] (2025-11-12) + +- Max/tweak ts sdk actions ([#6185]) +- chore: resolve clippy 1.91 warnings ([#6168]) +- [chore] Remove unused dependencies ([#6151]) +- Use typed-builder for registration client builder config ([#6150]) +- tommy is too quick ([#6149]) +- configurable mixnet client startup timeout ([#6148]) +- [Feature/operators]: QUIC bridge deployment script v2 ([#6145]) +- Bugfix: Add circuit breaker ([#6143]) +- bugfix: update internal owner address in transferred share ([#6139]) +- Update quic_bridge_deployment.sh for IPv4 and .deb package ([#6138]) +- feat: expose more explicit new_with_fronted_urls builder for http API client ([#6136]) +- bugfix: update stored epoch share when changing ownership ([#6135]) +- Domain fronting ([#6134]) +- bugfix: update stored epoch share when changing announce address ([#6131]) + +[#6185]: https://github.com/nymtech/nym/pull/6185 +[#6168]: https://github.com/nymtech/nym/pull/6168 +[#6151]: https://github.com/nymtech/nym/pull/6151 +[#6150]: https://github.com/nymtech/nym/pull/6150 +[#6149]: https://github.com/nymtech/nym/pull/6149 +[#6148]: https://github.com/nymtech/nym/pull/6148 +[#6145]: https://github.com/nymtech/nym/pull/6145 +[#6143]: https://github.com/nymtech/nym/pull/6143 +[#6139]: https://github.com/nymtech/nym/pull/6139 +[#6138]: https://github.com/nymtech/nym/pull/6138 +[#6136]: https://github.com/nymtech/nym/pull/6136 +[#6135]: https://github.com/nymtech/nym/pull/6135 +[#6134]: https://github.com/nymtech/nym/pull/6134 +[#6131]: https://github.com/nymtech/nym/pull/6131 + ## [2025.19-kase] (2025-10-30) - update ns agent workflow ([#6154]) From d07f9c8fad959fa9a7751fc174f141cada02978b Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Wed, 12 Nov 2025 11:03:28 +0000 Subject: [PATCH 09/23] Max/docs new structure (#6188) * rework of sdk docs * update integration docs + bit of overall restructure * remove debug logger from tool --- .../old-dev-docs/binaries.md.bak} | 0 .../old-dev-docs/clients.mdx.bak} | 0 .../old-dev-docs}/credentials.mdx | 0 .../old-dev-docs}/development-status.mdx | 0 .../old-dev-docs/integrations.mdx | 40 ++++++++ .../old-dev-docs}/required-architecture.md | 2 + documentation/docs/components/mix-fetch.tsx | 4 +- .../outputs/api-scraping-outputs/time-now.md | 2 +- documentation/docs/pages/_meta.json | 4 +- .../docs/pages/developers/_meta.json | 27 ++++-- .../docs/pages/developers/browsers.mdx | 40 ++++++++ .../docs/pages/developers/clients/_meta.json | 5 +- .../docs/pages/developers/concepts/_meta.json | 6 -- .../{message-queue.md => message-queue.mdx} | 6 ++ .../pages/developers/concepts/messages.mdx | 31 ------- documentation/docs/pages/developers/index.mdx | 5 - .../docs/pages/developers/integrations.mdx | 46 +++------- .../docs/pages/developers/native.mdx | 86 ++++++++++++++++++ documentation/docs/pages/developers/rust.mdx | 15 +-- .../docs/pages/developers/rust/_meta.json | 5 +- .../pages/developers/rust/client-pool.mdx | 9 -- .../docs/pages/developers/rust/ffi.mdx | 13 +-- .../docs/pages/developers/rust/importing.mdx | 8 -- .../docs/pages/developers/rust/mixnet.mdx | 8 -- .../rust/mixnet/examples/builders.mdx | 7 -- .../builders/builder-with-storage.mdx | 7 -- .../rust/mixnet/examples/builders/builder.mdx | 7 -- .../rust/mixnet/examples/custom-topology.mdx | 8 -- .../custom-topology/custom-provider.mdx | 9 +- .../manually-overwrite-topology.mdx | 7 -- .../rust/mixnet/examples/simple.mdx | 7 -- .../developers/rust/mixnet/examples/socks.mdx | 7 -- .../rust/mixnet/examples/split-send.mdx | 7 -- .../rust/mixnet/examples/storage.mdx | 7 -- .../developers/rust/mixnet/examples/surbs.mdx | 7 -- .../rust/mixnet/examples/testnet.mdx | 6 -- .../docs/pages/developers/rust/tcpproxy.mdx | 9 +- .../developers/rust/tcpproxy/architecture.mdx | 8 -- .../rust/tcpproxy/examples/multiconn.mdx | 6 -- .../rust/tcpproxy/examples/singleconn.mdx | 6 -- .../rust/tcpproxy/troubleshooting.mdx | 7 -- documentation/docs/pages/developers/tools.mdx | 4 +- .../developers/nym-arch-client-to-client.png | Bin 0 -> 80461 bytes .../images/developers/nym-arch-ip-routing.png | Bin 0 -> 119841 bytes .../images/developers/nym-browser-arch.png | Bin 0 -> 110596 bytes 45 files changed, 217 insertions(+), 271 deletions(-) rename documentation/{docs/pages/developers/binaries.md => backup-pages/old-dev-docs/binaries.md.bak} (100%) rename documentation/{docs/pages/developers/clients.mdx => backup-pages/old-dev-docs/clients.mdx.bak} (100%) rename documentation/{docs/pages/developers/concepts => backup-pages/old-dev-docs}/credentials.mdx (100%) rename documentation/{docs/pages/developers/rust => backup-pages/old-dev-docs}/development-status.mdx (100%) create mode 100644 documentation/backup-pages/old-dev-docs/integrations.mdx rename documentation/{docs/pages/developers/concepts => backup-pages/old-dev-docs}/required-architecture.md (98%) create mode 100644 documentation/docs/pages/developers/browsers.mdx delete mode 100644 documentation/docs/pages/developers/concepts/_meta.json rename documentation/docs/pages/developers/concepts/{message-queue.md => message-queue.mdx} (88%) delete mode 100644 documentation/docs/pages/developers/concepts/messages.mdx create mode 100644 documentation/docs/pages/developers/native.mdx create mode 100644 documentation/docs/public/images/developers/nym-arch-client-to-client.png create mode 100644 documentation/docs/public/images/developers/nym-arch-ip-routing.png create mode 100644 documentation/docs/public/images/developers/nym-browser-arch.png diff --git a/documentation/docs/pages/developers/binaries.md b/documentation/backup-pages/old-dev-docs/binaries.md.bak similarity index 100% rename from documentation/docs/pages/developers/binaries.md rename to documentation/backup-pages/old-dev-docs/binaries.md.bak diff --git a/documentation/docs/pages/developers/clients.mdx b/documentation/backup-pages/old-dev-docs/clients.mdx.bak similarity index 100% rename from documentation/docs/pages/developers/clients.mdx rename to documentation/backup-pages/old-dev-docs/clients.mdx.bak diff --git a/documentation/docs/pages/developers/concepts/credentials.mdx b/documentation/backup-pages/old-dev-docs/credentials.mdx similarity index 100% rename from documentation/docs/pages/developers/concepts/credentials.mdx rename to documentation/backup-pages/old-dev-docs/credentials.mdx diff --git a/documentation/docs/pages/developers/rust/development-status.mdx b/documentation/backup-pages/old-dev-docs/development-status.mdx similarity index 100% rename from documentation/docs/pages/developers/rust/development-status.mdx rename to documentation/backup-pages/old-dev-docs/development-status.mdx diff --git a/documentation/backup-pages/old-dev-docs/integrations.mdx b/documentation/backup-pages/old-dev-docs/integrations.mdx new file mode 100644 index 0000000000..6937290ae5 --- /dev/null +++ b/documentation/backup-pages/old-dev-docs/integrations.mdx @@ -0,0 +1,40 @@ +import Box from '@mui/material/Box'; +import { Steps } from 'nextra/components' +import { Tabs } from 'nextra/components' +import { GitHubRepoSearch } from '../../code-snippets/mixfetchurl'; + +# Integration Options +Developers might want to either integrate a Mixnet client or just to interact with the blockchain. See the relevant section below. + +## Integrating Mixnet Functionality +There are several options available to developers wanting to embed a Nym client in their application code. + + + + <> + + Rust developers can rely on our Rust SDK to import Nym client functionality into their code. This can either be in the form of a standard message-based client, the `socks5` client, or the `TcpProxy` modules. + + We aim to expose at least the majority of this functionality via FFI to Go and C/C++. This is detailed alongside the Rust SDK components in the [Rust SDK docs](./rust). + + + + + <> + + Typescript and Javascript developers have several options avaliable to them: + - [`mixfetch`](./typescript/examples/mix-fetch) is an almost-dropin replacement for the `fetch` library. The best way to integrate Nym's `mixFetch` into your application will be where external network calls and RPC happens, for example, something in the lines of `sendRawTransaction` if you have an ETH-compatible wallet or `JsonRpcClient` if you use CosmJS. Although you can simply search for any JS `fetch` calls in your code (using our tool below) that are easily replaceable with `mixFetch`, keep in mind that `fetch` is not the only way to make `JSONRPC` or `XHR` calls. We advise to approach the integration process in a semantic way, searching for a module that is the common denominator for external communication in the codebase. Usually these are API controllers, middlewares or repositories. + + + + - Otherwise, a well-modularized JS/TS codebase should permit the integration of one of our other SDK components, which will allow more flexibility and control (or if your codebase is not using something that can be covered by `fetch` for networking). Read more about our different SDK components in the [TS SDK overview page](./typescript/overview). + + + + If your app is not written in any of the supported languages, you might still be able to send traffic through a standalone [socks5 client](./clients/socks5) but will have to think about packaging and bundling the client binary with e.g. a `systemd` file for autostart to run the client as a daemon. If you want to discuss FFI options reach out to us via our public dev channel. + + +## Interacting with Nyx +If instead of relying on the Mixnet you wish to interact with the Nyx chain, either as a payment processor or to get on-chain events, see [interacting with the chain](./chain). + +> Note that depending on your setup, you might already be able to combine interacting with the chain with using the Mixnet: check the options above for more. diff --git a/documentation/docs/pages/developers/concepts/required-architecture.md b/documentation/backup-pages/old-dev-docs/required-architecture.md similarity index 98% rename from documentation/docs/pages/developers/concepts/required-architecture.md rename to documentation/backup-pages/old-dev-docs/required-architecture.md index b12a791a9f..5be11e9a41 100644 --- a/documentation/docs/pages/developers/concepts/required-architecture.md +++ b/documentation/backup-pages/old-dev-docs/required-architecture.md @@ -1,5 +1,7 @@ # Required Application Architecture +**TODO once you;ve reworked you can probably just remove this** + Due to the fact that there are a lot of components that make up the Nym network (the Mixnet, Blockchain, etc) there is often confusion / misunderstandings about what is required to run application traffic through the Mixnet and take advantage of its various privacy properties. ## What do I need? diff --git a/documentation/docs/components/mix-fetch.tsx b/documentation/docs/components/mix-fetch.tsx index 11aeaedeb3..a7b51c3b9d 100644 --- a/documentation/docs/components/mix-fetch.tsx +++ b/documentation/docs/components/mix-fetch.tsx @@ -14,9 +14,7 @@ const defaultUrl = const args = { mode: "unsafe-ignore-cors" }; const mixFetchOptions: SetupMixFetchOps = { - preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", // with WSS - // preferredNetworkRequester: - // "CTDxrcXgrZHWyCWnuCgjpJPghQUcEVz1HkhUr5mGdFnT.3UAww1YWNyVNYNWFQL1LaHYouQtDiXBGK5GiDZgpXkTK@2RFtU5BwxvJJXagAWAEuaPgb5ZVPRoy2542TT93Edw6v", + preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", mixFetchOverride: { requestTimeoutMs: 60_000, }, diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 81f9be9b3c..bb07d073b5 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Tuesday, November 11th 2025, 13:59:52 UTC +Tuesday, November 11th 2025, 16:42:11 UTC diff --git a/documentation/docs/pages/_meta.json b/documentation/docs/pages/_meta.json index 15f1b5e394..b3b6f66d1d 100644 --- a/documentation/docs/pages/_meta.json +++ b/documentation/docs/pages/_meta.json @@ -11,8 +11,8 @@ "type": "menu", "items": { "developers": { - "title": "Developer Overview", - "href": "/developers/" + "title": "Integrations", + "href": "/developers/integrations" }, "rust": { "title": "Rust SDK", diff --git a/documentation/docs/pages/developers/_meta.json b/documentation/docs/pages/developers/_meta.json index b3e5134275..d89ffe6bd5 100644 --- a/documentation/docs/pages/developers/_meta.json +++ b/documentation/docs/pages/developers/_meta.json @@ -1,18 +1,27 @@ { - "index": "Introduction", - "concepts": "Core Concepts", - "binaries": "Binaries", - "integrations": "Integration Options", - "clients": "Clients", - "tools": "Tools", - "nymvpncli": "Nym VPN CLI", - "chain": "Interacting with Nyx", "--": { - "type": "separator" + "type": "separator", + "title": "Mixnet Integrations" + }, + "integrations": "Overview - Start Here!", + "native": "Native Apps", + "browsers": "Browser Apps", + "concepts": "Nym-Specific Concepts for Integrations", + "-": { + "type": "separator", + "title": "SDKs" }, "rust": "Rust SDK", "typescript": "Typescript SDK", "---": { + "type": "separator", + "title": "Misc" + }, + "chain": "Interacting with Nyx Blockchain", + "tools": "Tools", + "nymvpncli": "Nym VPN CLI", + "clients": "Standlone Clients", + "----": { "type": "separator" }, "archive": "Archive", diff --git a/documentation/docs/pages/developers/browsers.mdx b/documentation/docs/pages/developers/browsers.mdx new file mode 100644 index 0000000000..b1b07f4edc --- /dev/null +++ b/documentation/docs/pages/developers/browsers.mdx @@ -0,0 +1,40 @@ +import { Callout } from 'nextra/components'; + +# Browser-Based Apps +Browsers are a very restricted environment to work in, with limited options for external communications (websockets, Web Transport API, WebRTC), mixed content restrictions (HTTPS-only), and no access to the file system or any syscalls. These aside, the main issue when trying to capture traffic and send it via a different transport - such as the Mixnet - is the lack of access to browser TLS negotiation from JS or the CA certificate store. + +This means that the functionality offered by our current browser-based solutions are quite restricted / specific. There are currently two options for interacting with the Mixnet from the browser: `mixFetch`, and the WASM SDK. + +![](/images/developers/nym-browser-arch.png) + +Both `mixFetch` and the WASM client are delivered to the client bundled into a web application. + +## mixFetch +Drop-in replacement for browser's `fetch` API that makes HTTP(S) requests via Exit Gateways using the SOCKS Network Requester. + +Uses an embedded CA certificate store to establish TLS session between `mixFetch` and the remote host, creating a client-host secure channel from the browser to the host over the Mixnet. + +Internally it uses the WASM client. + +- [docs](./typescript/start#mixfetch) +- [example](./typescript/playground/mixfetch) + + + + ### Current Limitations of `mixFetch` + + It cannot currently handle concurrent requests, and comes with a pre-bundled CA store. `mixFetchv2` - which will act more like a general-purpose userspace IP stack - is currently in development. + + + +## WASM Client +Makes Sphinx packets and cover traffic using WASM and sent over a Websocket to the Entry Gateway and receive responses. + +This only works in messaging mode (i.e. messages sent either as text or binary data), and currently doesn’t support making IP packets that are routed to the Internet by an Exit Gateway IPR, nor does it currently expose any stream-like API. If you want to send HTTP(S) requests, use `mixFetch`. + +Note that the limitations of CSPs and Mixed Content restrictions (i.e HTTPS only) apply to the Websocket connection as normal in browsers or embedded WebViews. + +Runs in a web worker to leave UI thread free for the user. + +- [docs](./typescript/start#mixnet-client) +- [example](./typescript/playground/traffic) diff --git a/documentation/docs/pages/developers/clients/_meta.json b/documentation/docs/pages/developers/clients/_meta.json index 4a9fdefe51..56e270ddad 100644 --- a/documentation/docs/pages/developers/clients/_meta.json +++ b/documentation/docs/pages/developers/clients/_meta.json @@ -1,5 +1,4 @@ { - "socks5": "Socks5 (standalone)", - "webassembly-client": "Webassembly Client", - "websocket": "Websocket (standalone)" + "socks5": "SOCKS Proxy", + "websocket": "Websocket" } diff --git a/documentation/docs/pages/developers/concepts/_meta.json b/documentation/docs/pages/developers/concepts/_meta.json deleted file mode 100644 index a2140d915b..0000000000 --- a/documentation/docs/pages/developers/concepts/_meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "required-architecture": "Required Architecture", - "messages": "Message Based Paradigm", - "message-queue": "Message Queue", - "credentials": "Credentials" -} diff --git a/documentation/docs/pages/developers/concepts/message-queue.md b/documentation/docs/pages/developers/concepts/message-queue.mdx similarity index 88% rename from documentation/docs/pages/developers/concepts/message-queue.md rename to documentation/docs/pages/developers/concepts/message-queue.mdx index 3fba96b77f..b5df5325d8 100644 --- a/documentation/docs/pages/developers/concepts/message-queue.md +++ b/documentation/docs/pages/developers/concepts/message-queue.mdx @@ -1,5 +1,11 @@ +import { Callout } from 'nextra/components' + # Message Queue + + Although good to understand how the Nym Client works under the hood, this information is only of practical use if you're using the `Mixnet` module of the RustSDK and interacting with the SDK Mixnet Client at a low level. This is all mostly abstracted away with the `MixSocket`, `MixStream`, and `IpMixStream` abstractions. + + ## Sphinx Packet Streams Clients, once connected to the Mixnet, **are always sending traffic into the Mixnet**; as well as the packets that you as a developer are sending from your application logic, they send [cover traffic](../../network/concepts/cover-traffic) at a constant rate defined by a Poisson process. This is part of the network's mitigation of timing attacks. diff --git a/documentation/docs/pages/developers/concepts/messages.mdx b/documentation/docs/pages/developers/concepts/messages.mdx deleted file mode 100644 index 5a019593c9..0000000000 --- a/documentation/docs/pages/developers/concepts/messages.mdx +++ /dev/null @@ -1,31 +0,0 @@ -# Message-based Paradigm - -## Message Format -For the moment, Mixnet clients work assuming they will be piped atomic messages looking something like this: - -``` -MixnetMessage { - Message: Message_Bytes, - To: Nym_Address, - Attached_SURBS: Number_Of_Surbs -} -``` - -That the client will then encrypt as Sphinx packets and send through the Mixnet. - -Likewise, they assume that once they have received and decrypted a Sphinx packet, they will kick back a reconstructed message to the rest of your app logic that look something like: - -``` -ReconstructedMessage { - Message: Message_Bytes, - From: SURB_Sender_Tag -} -``` - -This is obviously quite different to e.g. simply being able to read/write from a stream returned from a function call to create a TCP connection, but there are several approaches that developers can take to dealing with this right now. - -## Message Abstractions -- Rust/Go (and soon C++) developers can use the `TcpProxy` [stream abstraction](../rust/tcpproxy). -- Developers who are using Typescript/Javascript can also avoid having to deal directly with messages via using [MixFetch](../typescript/examples/mix-fetch). -- As can developers who are bundling and running the standalone [socks5 client](../clients/socks5) using some form of init script. -- There is a seperate pair of binaries which other developers can use to run as a persistent secondary proxy process built using the `TcpProxy` abstraction. These simply expose a `localhost` socket port to pipe traffic to and from in the same way as you would a TCP connection and can be found [here](https://github.com/nymtech/standalone-tcp-proxies). diff --git a/documentation/docs/pages/developers/index.mdx b/documentation/docs/pages/developers/index.mdx index 9f27706c0d..f1cdd88680 100644 --- a/documentation/docs/pages/developers/index.mdx +++ b/documentation/docs/pages/developers/index.mdx @@ -1,7 +1,2 @@ # Introduction Nym's developer documentation covering core concepts of integrating with the Mixnet, interacting with the Nyx blockchain, an overview of the avaliable tools, and our SDK docs. - -## Where to Start? -If you are completely new to Nym, it is suggested to start with the top sections covering the core concepts, integration options, and the clients and tools available to you. - -If you feel like starting with more practical examples, check out the relevent SDK docs in the sidebar to the left. diff --git a/documentation/docs/pages/developers/integrations.mdx b/documentation/docs/pages/developers/integrations.mdx index 6937290ae5..8568f7285a 100644 --- a/documentation/docs/pages/developers/integrations.mdx +++ b/documentation/docs/pages/developers/integrations.mdx @@ -1,40 +1,16 @@ -import Box from '@mui/material/Box'; -import { Steps } from 'nextra/components' -import { Tabs } from 'nextra/components' -import { GitHubRepoSearch } from '../../code-snippets/mixfetchurl'; +import { Callout } from 'nextra/components'; -# Integration Options -Developers might want to either integrate a Mixnet client or just to interact with the blockchain. See the relevant section below. +# Integrating With Nym +Any application that wants to integrate with Nym involves sending its application traffic through the Mixnet using one of the available Nym Clients. There is no single solution for this, as different environments offer different access and transport options (e.g. if operating in a web browser, you do not have access to syscalls or sockets, have to deal with content security policies, etc). -## Integrating Mixnet Functionality -There are several options available to developers wanting to embed a Nym client in their application code. +As such, we have several solutions available for developers to choose from depending on the **environment** their application is expected to run in: native apps which are running on a desktop, or webapps running in a browser. - - - <> + + The list of current options available to developers to do not cover all environments and setups - we are working on expanding this list and approaching more general solutions, but there is no one-size-fits-all approach when dealing with rerouting network traffic. + - Rust developers can rely on our Rust SDK to import Nym client functionality into their code. This can either be in the form of a standard message-based client, the `socks5` client, or the `TcpProxy` modules. +Integration options are then further subdivided by app **architecture**; whether the application interacts with remote hosts on the public internet running independently of the app (e.g. public blockchain RPC endpoints, third-party APIs) or whether app developers have some control over the versions of the software being run on both sides of an interaction (e.g. peer to peer apps running the same software version, or client-server architectures which are running software written by the same team). - We aim to expose at least the majority of this functionality via FFI to Go and C/C++. This is detailed alongside the Rust SDK components in the [Rust SDK docs](./rust). - - - - - <> - - Typescript and Javascript developers have several options avaliable to them: - - [`mixfetch`](./typescript/examples/mix-fetch) is an almost-dropin replacement for the `fetch` library. The best way to integrate Nym's `mixFetch` into your application will be where external network calls and RPC happens, for example, something in the lines of `sendRawTransaction` if you have an ETH-compatible wallet or `JsonRpcClient` if you use CosmJS. Although you can simply search for any JS `fetch` calls in your code (using our tool below) that are easily replaceable with `mixFetch`, keep in mind that `fetch` is not the only way to make `JSONRPC` or `XHR` calls. We advise to approach the integration process in a semantic way, searching for a module that is the common denominator for external communication in the codebase. Usually these are API controllers, middlewares or repositories. - - - - - Otherwise, a well-modularized JS/TS codebase should permit the integration of one of our other SDK components, which will allow more flexibility and control (or if your codebase is not using something that can be covered by `fetch` for networking). Read more about our different SDK components in the [TS SDK overview page](./typescript/overview). - - - - If your app is not written in any of the supported languages, you might still be able to send traffic through a standalone [socks5 client](./clients/socks5) but will have to think about packaging and bundling the client binary with e.g. a `systemd` file for autostart to run the client as a daemon. If you want to discuss FFI options reach out to us via our public dev channel. - - -## Interacting with Nyx -If instead of relying on the Mixnet you wish to interact with the Nyx chain, either as a payment processor or to get on-chain events, see [interacting with the chain](./chain). - -> Note that depending on your setup, you might already be able to combine interacting with the chain with using the Mixnet: check the options above for more. + + This is because of the different security considerations each option offers. These are detailed in the following pages. + diff --git a/documentation/docs/pages/developers/native.mdx b/documentation/docs/pages/developers/native.mdx new file mode 100644 index 0000000000..8fee54371f --- /dev/null +++ b/documentation/docs/pages/developers/native.mdx @@ -0,0 +1,86 @@ +import { Callout } from 'nextra/components'; + +# Native / Desktop Apps + +Developers wanting to integrate into desktop apps & CLIs can use our Rust SDK. There are two broad approaches to using the Mixnet (E2E or as a proxy), with different modules suited for each, each with their own specific usecase and limitations. + +## Option 1: Mixnet End-To-End +You might want to embed Nym Clients in both sides of your app, and have them send all of your app network traffic through the Mixnet: maybe two clients in a peer to peer setup, or a client and a server where it is possible for you as a developer to release both the client and server side code, and have some ability to make sure that it is being run. + +![](/images/developers/nym-arch-client-to-client.png) + +There are several options available: + +{ /* ### Stream Wrapper Module +Exposes `MixSocket`/`MixStream` abstractions that can be split a reader/writer halves that consumes bytes, with an interface inspired by `std::net::TcpStream`. For developers who just want to read/write bytes to/from the Mixnet working with something socket-like. + +- docs TODO LINK +- example TODO LINK */ } + +### Mixnet & Client Pool Modules +The Mixnet module of the SDK exposes low level connection functionality and the Mixnet Client. The Client Pool is one answer to concurrency, and allows developers to run several Nym Clients at once which can be quickly used. + +{ /* This approach might be useful if you want to build custom connection logic, but **`MixSocket`/`MixStream` will probably be sufficient for the majority of usecases** where developers just want to send and receive traffic as streams. */ } + +This approach might be useful if you want to build custom connection logic, but the TcpProxy Module will probably be sufficient for the majority of usecases where developers just want to send and receive traffic as streams. + +- [docs](./rust/mixnet) +- [examples](./rust/mixnet/examples) + +### TcpProxy Module +{ /* + This module has been superseded by the Stream Wrapper module, and will soon be deprecated. **New features will not be added to it.** The main drawback of this (which is fixed with `MixSocket`/`MixStream`) is that TLS is impossible, as it exposes a localhost port for the consuming process to communicate with. + */ } +A pair of abstractions built for use in a client-server setup, which both expose a `localhost` TCP Socket which apps can read/write bytes to/from. + +- [docs](./rust/tcpproxy) +- [examples](./rust/tcpproxy/examples/singleconn) + + + There is a new abstraction coming soon mirroring the interface and use of a TCP Socket, making it easier for developers to use the Mixnet, and also perform TLS through a Mixnet connection. Stay tuned. + + +## Option 2: Mixnet-As-Proxy +For developers who are only able to control the client-side code, and/or need to communicate with a 3rd party service, such as a public blockchain RPC or a remote host they do not control. + +![](/images/developers/nym-arch-ip-routing.png) + + + + ### Security Considerations + + Since traffic is only packaged as Sphinx until it gets to the Exit Gateway, where it is unwrapped into either HTTPS packets (by a Network Requester) or IP packets (by an IP Packet Router), the last hop between the Gateway and the remote host **travels as normal internet traffic**. + + As such, this option has fewer protections than the E2E option against a global passive adversary, but still grants you timing obfuscation and sender-receiver unlinkability between your client software and whatever service it is interacting with. + + +### SOCKS Client +Developers with apps that support SOCKS4,4a, or 5 can use the Socks Client exposed by the Mixnet module. This uses the Network Requester service of the chosen Exit Gateway to interact with the remote host via the chosen SOCKS proxy protocol. The Network Requester uses SURBs to anonymously reply to the original sender with whatever response it gets from the remote host. + +- [docs](./rust/mixnet/examples/socks) +- [example](./rust/mixnet/examples/socks) + + + There is a new abstraction coming soon that will allow the SDK to send IP packets, the beginning of a longer project to make a native Rust version of [`mixFetch`](./typescript/start#mixfetch). Stay tuned. + + + +{ /* ### `IPMixStream` +This is a version of the `MixSocket` that consumes IP packets before wrapping them in Sphinx and forwarding through the Mixnet to the IP Packet Router of the chosen Exit Gateway, where they are unwrapped and treated as normal IP packets. The IPR uses SURBs to anonymously reply to the original sender with whatever response it gets from the remote host. + + + Currently only consumes IP packets - those who do not want to work with IP packets directly should check `mixtcp` below for doing something with HTTP(S). + + +- docs TODO LINK +- examples TODO LINK + +### `MixTCP` +A proof of concept TCP/IP crate containing a [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/index.html) `device` that uses the Mixnet for transport. Examples of using this to make a TLS handshake and perform an HTTPS request can be found linked below. + + + This crate is currently a proof of concept which is in active development. This crate will become the basis of a general-purpose HTTP-through-Mixnet crate in the near future. + + +- docs TODO LINK +- example TODO LINK */ } diff --git a/documentation/docs/pages/developers/rust.mdx b/documentation/docs/pages/developers/rust.mdx index db5aa11b61..d39707bb44 100644 --- a/documentation/docs/pages/developers/rust.mdx +++ b/documentation/docs/pages/developers/rust.mdx @@ -1,16 +1,3 @@ # Introduction - -import { Callout } from 'nextra/components'; - - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - -The Rust SDK allows developers building applications in Rust to import and interact with Nym clients as they would any other dependency, instead of running the client as a separate process on their machine. - -Check the [development status](./rust/development-status) page to see the various modules that make up the SDK, and the [FFI](./rust/ffi) page for Go/C++ developers. +The Rust SDK allows exposes a few different modules, some more plug and play than others. Each of which handles exposes a Nym Client, which handles finding and using a route for packets through the Mixnet, encryption, and cover traffic, all under the hood. diff --git a/documentation/docs/pages/developers/rust/_meta.json b/documentation/docs/pages/developers/rust/_meta.json index dda7e68e20..5ce667aa28 100644 --- a/documentation/docs/pages/developers/rust/_meta.json +++ b/documentation/docs/pages/developers/rust/_meta.json @@ -1,8 +1,7 @@ { "importing": "Importing", - "development-status": "Development Status", - "mixnet": "Mixnet Module", "tcpproxy": "TcpProxy Module", - "client-pool": "Client Pool", + "mixnet": "Mixnet Module", + "client-pool": "Client Pool Module", "ffi": "FFI" } diff --git a/documentation/docs/pages/developers/rust/client-pool.mdx b/documentation/docs/pages/developers/rust/client-pool.mdx index 6c444ad9e8..70c3f54ca1 100644 --- a/documentation/docs/pages/developers/rust/client-pool.mdx +++ b/documentation/docs/pages/developers/rust/client-pool.mdx @@ -2,15 +2,6 @@ import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - We have a configurable-size Client Pool for processes that require multiple clients in quick succession (this is used by default by the [`TcpProxyClient`](./tcpproxy) for instance) This will be useful for developers looking to build connection logic, or just are using raw SDK clients in a sitatuation where there are multiple connections with a lot of churn. diff --git a/documentation/docs/pages/developers/rust/ffi.mdx b/documentation/docs/pages/developers/rust/ffi.mdx index 69ccfe3b75..349b298bc8 100644 --- a/documentation/docs/pages/developers/rust/ffi.mdx +++ b/documentation/docs/pages/developers/rust/ffi.mdx @@ -2,17 +2,6 @@ # FFI Bindings import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - - We are working on the intitial versions of the FFI code to allow developers to experiment and get feedback. Please get in touch if you think the FFI bindings are lacking certain functionality. - - We currently have FFI bindings for Go and C/C++. See the table below to check the coverage of functionality we expect devs would like to see. The [`nym/sdk/ffi`](https://github.com/nymtech/nym/tree/master/sdk/ffi) directory has the following structure: @@ -50,7 +39,7 @@ At the time of writing the following functionality is not exposed to the shared - `Socks5::new()`: creation and use of the [socks5/4a/4 proxy client](./mixnet/examples/socks). ## TcpProxy Module -A connection abstraction which exposes a local TCP socket which developers are able to interact with basically as expected, being able to read/write to/from a bytestream, without really having to take into account the workings of the Mixnet/Sphinx/the [message-based](../concepts/messages) format of the underlying client. +A connection abstraction which exposes a local TCP socket which developers are able to interact with basically as expected, being able to read/write to/from a bytestream, without really having to take into account the workings of the Mixnet/Sphinx/the message-based format of the underlying client. At the time of writing this functionality is **only** exposed to Go. C/C++ bindings will follow in the future in a larger update to the C FFI. diff --git a/documentation/docs/pages/developers/rust/importing.mdx b/documentation/docs/pages/developers/rust/importing.mdx index fb487d9048..255b45f466 100644 --- a/documentation/docs/pages/developers/rust/importing.mdx +++ b/documentation/docs/pages/developers/rust/importing.mdx @@ -1,14 +1,6 @@ # Installation import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - The `nym-sdk` crate is **not yet available via [crates.io](https://crates.io)**. As such, in order to import the crate you must specify the Nym monorepo in your `Cargo.toml` file. Since the `HEAD` of `master` is always the most recent release, we recommend developers use that for their imports, unless they have a reason to pull in a specific historic version of the code. ```toml diff --git a/documentation/docs/pages/developers/rust/mixnet.mdx b/documentation/docs/pages/developers/rust/mixnet.mdx index c6173329f1..0524619874 100644 --- a/documentation/docs/pages/developers/rust/mixnet.mdx +++ b/documentation/docs/pages/developers/rust/mixnet.mdx @@ -1,14 +1,6 @@ # Mixnet Module import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - This module exposes the logic of creating and interacting with clients and Mixnet messages. This is recommended for those wanting to either start playing around with the Mixnet and how it works, or build connection logic. > For developers wanting something more 'plug and play' we recommend the [`TcpProxy` module](./tcpproxy). diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/builders.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/builders.mdx index fed758990b..3975a5aaac 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/builders.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/builders.mdx @@ -1,11 +1,4 @@ # Builder Patterns import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - Since there are two ways of creating an SDK client - ephemeral and with-storage - then there are two ways of applying the Builder Pattern to client creation. diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder-with-storage.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder-with-storage.mdx index 0d86bea5dc..07966916bf 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder-with-storage.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder-with-storage.mdx @@ -1,13 +1,6 @@ # Mixnet Client Builder with Storage import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - The previous example involves ephemeral keys - if we want to create and then maintain a client identity over time, our code becomes a little more complex as we need to create, store, and conditionally load these keys. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/builder_with_storage.rs). diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder.mdx index d94dfae86a..1b9f63517c 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/builders/builder.mdx @@ -1,13 +1,6 @@ # Mixnet Client Builder import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - You can spin up an ephemeral client like so. This client will not have a persistent identity and its keys will be dropped on restart. Since there is currently no way of reconnecting a client that has been disconnected after use, then treat disconnecting a client the same as dropping its keys entirely. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/builder.rs). diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology.mdx index e26b346976..1985900b06 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology.mdx @@ -2,14 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - These examples are **not** the same as using a configurable network: these functions define a subset of nodes to use on a given network, whereas the [testnet](./testnet) example is an example of switching to use a different network entirely. The two can be combined, but if you are looking for how to connect your client to a testnet, see the `testnet` file. diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/custom-provider.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/custom-provider.mdx index be32aecb2d..32218f1c91 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/custom-provider.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/custom-provider.mdx @@ -1,13 +1,6 @@ # Custom Topology Provider import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you are also running a Validator and Nym API for your network, you can specify that endpoint as such and interact with it as clients usually do (under the hood). > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/custom_topology_provider.rs) @@ -33,7 +26,7 @@ impl MyTopologyProvider { .expect("Failed to create API client builder") .build::() .expect("Failed to build API client"); - + MyTopologyProvider { validator_client, } diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/manually-overwrite-topology.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/manually-overwrite-topology.mdx index e3c6d1fec2..61b4b3fdbc 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/manually-overwrite-topology.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/custom-topology/manually-overwrite-topology.mdx @@ -1,13 +1,6 @@ # Manually Overwrite Topology import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you aren't running a Validator and Nym API, and just want to import a specific sub-set of mix nodes, you can simply overwrite the grabbed topology manually. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs) diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/simple.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/simple.mdx index c6065d6231..730d98608a 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/simple.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/simple.mdx @@ -2,13 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - Lets look at a very simple example of how you can import and use the websocket client in a piece of Rust code. Simply importing the `nym_sdk` crate into your project allows you to create a client and send traffic through the mixnet. diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/socks.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/socks.mdx index 03089679b7..9dff137fd5 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/socks.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/socks.mdx @@ -2,13 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you are looking at implementing Nym as a transport layer for a crypto wallet or desktop app, this is probably the best place to start if they can speak SOCKS5, 4a, or 4. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/socks5.rs) diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/split-send.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/split-send.mdx index 6648e1956f..88cd9cc059 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/split-send.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/split-send.mdx @@ -2,13 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you need to split the different actions of your client across different tasks, you can do so like this. You can think of this analogously to spliting a Tcp Stream into read/write. This functionality is also useful for embedding a sending and receiving client into different tasks. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/parallel_sending_and_receiving.rs) diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/storage.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/storage.mdx index 3a103c78f0..230e8eced5 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/storage.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/storage.mdx @@ -2,13 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you're integrating mixnet functionality into an existing app and want to integrate saving client configs and keys into your existing storage logic, you can manually perform these actions. > You can find this code [here](https://github.com/nymtech/nym/blob/master/sdk/rust/nym-sdk/examples/manually_handle_storage.rs) diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/surbs.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/surbs.mdx index 1543102a63..d9b68f4848 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/surbs.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/surbs.mdx @@ -2,13 +2,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - Both functions used to send messages through the mixnet (`send_message` and `send_plain_message`) send a pre-determined number of SURBs along with their messages by default. You can read more about how SURBs function under the hood [here](../../../../network/traffic/anonymous-replies). diff --git a/documentation/docs/pages/developers/rust/mixnet/examples/testnet.mdx b/documentation/docs/pages/developers/rust/mixnet/examples/testnet.mdx index 606fcc8ead..a6fc4b4b3e 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples/testnet.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples/testnet.mdx @@ -1,13 +1,7 @@ # Configurable Network import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - If you want to connect your Mixnet client to a different network than Mainnet, simply pull in a file from [`nym/envs`](https://github.com/nymtech/nym/tree/master/envs) as such: ```rust diff --git a/documentation/docs/pages/developers/rust/tcpproxy.mdx b/documentation/docs/pages/developers/rust/tcpproxy.mdx index ee7836d175..d34d4f0a37 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy.mdx @@ -1,15 +1,8 @@ # TcpProxy Module import { Callout } from 'nextra/components'; - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - This module exposes the `TcpProxyClient` and the `TcpProxyServer` which can be used to proxy traffic through the Mixnet in a way that is more familiar to developers than the methods exposed by the [`Mixnet` module](./mixnet). -Both `Client` and `Server` are intended to be initialised and then run in a background thread, exposing a configurable `localhost` socket which developers can read/write/stream to without having to worry about the [message-based](../concepts/messages) nature of sending and receiving traffic to/from the Mixnet. +Both `Client` and `Server` are intended to be initialised and then run in a background thread, exposing a configurable `localhost` socket which developers can read/write/stream to without having to worry about the message-based nature of sending and receiving traffic to/from the Mixnet. > Non-Rust/Go developers who want to experiment with this module can start with the [standalone binaries](../tools/standalone-tcpproxy). diff --git a/documentation/docs/pages/developers/rust/tcpproxy/architecture.mdx b/documentation/docs/pages/developers/rust/tcpproxy/architecture.mdx index b9be09ecdc..7a7965673b 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy/architecture.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy/architecture.mdx @@ -3,14 +3,6 @@ import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - - This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - - It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - - ## Motivations The motivation behind the creation of the `TcpProxy` module is to allow developers to interact with the Mixnet in a way that is far more familiar to them: simply setting up a connection with a transport, being returned a socket, and then being able to stream data to/from it, similar to something like the Tor [`arti`](https://gitlab.torproject.org/tpo/core/arti/-/tree/main/crates/arti-client) client. diff --git a/documentation/docs/pages/developers/rust/tcpproxy/examples/multiconn.mdx b/documentation/docs/pages/developers/rust/tcpproxy/examples/multiconn.mdx index 6f3bcffd38..daeb38069e 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy/examples/multiconn.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy/examples/multiconn.mdx @@ -1,13 +1,7 @@ # Multi Connection Example import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - This example starts off several Tcp connections on a loop to a remote endpoint: in this case the `TcpListener` behind the `NymProxyServer` instance on the echo server found in [`nym/tools/echo-server/`](https://github.com/nymtech/nym/tree/develop/tools/echo-server). It pipes a few messages to it, logs the replies, and keeps track of the number of replies received per connection. diff --git a/documentation/docs/pages/developers/rust/tcpproxy/examples/singleconn.mdx b/documentation/docs/pages/developers/rust/tcpproxy/examples/singleconn.mdx index a0c1a73cfe..47f341a893 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy/examples/singleconn.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy/examples/singleconn.mdx @@ -1,13 +1,7 @@ # Single Connection Example import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - This is a basic example which opens a single TCP connection and writes a bunch of messages between a client and some 'echo server' logic, so only uses a single session under the hood and doesn't really show off the message ordering capabilities; this is mainly just a quick introductory illustration on how: - the mixnet does message ordering - the NymProxyClient and NymProxyServer can be hooked into and used to communicate between two otherwise pretty vanilla TcpStreams diff --git a/documentation/docs/pages/developers/rust/tcpproxy/troubleshooting.mdx b/documentation/docs/pages/developers/rust/tcpproxy/troubleshooting.mdx index 7634e94ac4..8688ae63f4 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy/troubleshooting.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy/troubleshooting.mdx @@ -1,12 +1,5 @@ # Troubleshooting import { Callout } from 'nextra/components' - - There will be a breaking SDK upgrade in the coming months. This upgrade will make the SDK a lot easier to build with. - -This upgrade will affect the interface of the SDK dramatically, and will be coupled with a protocol change - stay tuned for information on early access to the new protocol testnet. - -It will also be coupled with the documentation of the SDK on [crates.io](https://crates.io/). - ## Lots of `duplicate fragment received` messages You might see a lot of `WARN` level logs about duplicate fragments in your logs, depending on the log level you're using. This occurs when a packet is retransmitted somewhere in the Mixnet, but then the original makes it to the destination client as well. This is not something to do with your client logic, but instead the state of the Mixnet. diff --git a/documentation/docs/pages/developers/tools.mdx b/documentation/docs/pages/developers/tools.mdx index ccd413c5ba..9f0299c333 100644 --- a/documentation/docs/pages/developers/tools.mdx +++ b/documentation/docs/pages/developers/tools.mdx @@ -1,7 +1,7 @@ # Tools -There are a few tools available to developers for chain interaction: the `nym-cli` tool, which operates as an easier-to-use wrapper around `nyxd`, to allow operators to script interactions with their infrastructure (and those who prefer CLI tools ;) ). +There are a few tools available to developers for chain interaction: the `nym-cli` tool, which operates as an easier-to-use wrapper around `nyxd`, to allow operators to script interactions with their infrastructure (and those who prefer CLI tools). There is also a basic echo server tool which app developers can use as a quick endpoint for traffic testing. This will be deployed onto a persistent public server in the future so devs dont have to run it themselves. -Finally, there are a pair of standalone versions of the TcpProxy Rust SDK module for developers to begin experimenting with sending app traffic through them mixnet. +Finally, there are also a pair of standalone versions of the TcpProxy Rust SDK module for developers to begin experimenting with sending app traffic through them mixnet (**this module will soon be deprecated in place of the `MixSocket`/`MixStream` abstractions**). diff --git a/documentation/docs/public/images/developers/nym-arch-client-to-client.png b/documentation/docs/public/images/developers/nym-arch-client-to-client.png new file mode 100644 index 0000000000000000000000000000000000000000..18c0132653ee82baa231ea9ee8a10b00ba4caf0d GIT binary patch literal 80461 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

WR-a@`A6Ycl-l>oigepsSUJoKEs)Pm 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 _5gnQ5p_!!wVisn@Hkj?OzP-{!I= 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

eW-Hes{WBSr~K4hKVMy3o8iFpHQOzFR~ej-FTrA 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 QNO}k@$o!ql$4?`f=TTBZf+=~5;KgM|p=}t9B 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ξEL= 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 BN{Ijf literal 0 HcmV?d00001 From 386e1790dd06ca00c9e7bbb9ef28e54f9bd44cb3 Mon Sep 17 00:00:00 2001 From: import this <97586125+serinko@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:32:13 +0000 Subject: [PATCH 10/23] [DOCs/operators]: Release notes for v2025.20 leerdammer (#6191) * release notes * bump up nym-node docs version * add dev tools * scrape stats and clean --- .../outputs/api-scraping-outputs/time-now.md | 2 +- .../node-api-check-query-help.md | 2 +- .../docs/pages/operators/changelog.mdx | 70 +++++++++++++++++++ .../pages/operators/nodes/nym-node/setup.mdx | 8 +-- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index bb07d073b5..62471ca968 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Tuesday, November 11th 2025, 16:42:11 UTC +Wednesday, November 12th 2025, 11:13:44 UTC \ No newline at end of file diff --git a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md index 999f3d4a6f..bfa068ddb0 100644 --- a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md +++ b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md @@ -11,7 +11,7 @@ options: --no_routing_history Display node stats without routing history --no_verloc_metrics Display node stats without verloc metrics -m, --markdown Display results in markdown format - -o, --output [OUTPUT] + -o [OUTPUT], --output [OUTPUT] Save results to file (in current dir or supply with path without filename) ``` diff --git a/documentation/docs/pages/operators/changelog.mdx b/documentation/docs/pages/operators/changelog.mdx index 0cf99f6a21..aa1fe30dfe 100644 --- a/documentation/docs/pages/operators/changelog.mdx +++ b/documentation/docs/pages/operators/changelog.mdx @@ -49,6 +49,76 @@ This page displays a full list of all the changes during our release cycle from +## `v2025.20-leerdammer` + +- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2025.20-leerdammer) +- [`nym-node`](nodes/nym-node.mdx) version `1.21.0` + +```sh +nym-node +Binary Name: nym-node +Build Timestamp: 2025-11-12T08:19:33.288341371Z +Build Version: 1.21.0 +Commit SHA: babf113fe5d396fa8a84fa939ad4b1b5b4d38b83 +Commit Date: 2025-11-12T08:39:48.000000000+01:00 +Commit Branch: HEAD +rustc Version: 1.88.0 +rustc Channel: stable +cargo Profile: release +``` + +### Operators Updates & Tools + +- [New **Performance Measurement** page](/operators/performance-and-testing) explaining the logic behind node selection for NymVPN application + +- [New **Gateway Probe Details** page](/operators/performance-and-testing/gateway-probe-details) explaining complexity and contradictions when measuring network performance + +### Developer Tools + +- [Typescript SDK 1.4.1](https://github.com/nymtech/nym/pull/6146): This PR is a new release of the Typescript SDK, `mixFetch` and `WASM` client. It also removes the Harbour Master client from `mixFetch`, replacing it with the Nym API's described endpoint for nym-nodes + +- [Overhauled **developer integrations** pages](/developers/integrations) explaining the different restrictions for the different SDK options on offer + +- [Fixed `mixFetch` and `WASM Client` playground + examples](/developers/typescript/start): new versions of the Typescript SDK and `mixFetch` have been published, examples and live playground have been updated accordingly + + +### Features + +- [Tweak ts sdk actions](https://github.com/nymtech/nym/pull/6185): Using `taskset` to limit the number of of CPUs used by `wasm-pack` and `wasm-opt` commands used by ts linting CI + +- [Configurable mixnet client startup timeout](https://github.com/nymtech/nym/pull/6148) + +- [QUIC bridge deployment script v2](https://github.com/nymtech/nym/pull/6145): Script helping operators to install, configure and deploy QUIC bridge as systemd service + +- [Expose more explicit `new_with_fronted_urls` builder for http API client](https://github.com/nymtech/nym/pull/6136) + +- [Domain fronting](https://github.com/nymtech/nym/pull/6134): Enable URL rotation and retries for mixnet gateway [`init`](https://github.com/nymtech/nym/pull/6126) + + +### Bugfix + +- [Add circuit breaker](https://github.com/nymtech/nym/pull/6143): When the mixnet client's `mix_tx` channel closes during network drops, `OutQueueControl` would retry sending packets through the closed channel, flooding logs and hanging the daemon. This affects all clients (VPN, SOCKS5, native clients), not just VPN .. (Read more in the [PR description](https://github.com/nymtech/nym/pull/6143)) + +- [Update internal owner address in transferred share](https://github.com/nymtech/nym/pull/6139) + +- [Update quic_bridge_deployment.sh for IPv4 and .deb package](https://github.com/nymtech/nym/pull/6138): Updated ping commands to explicitly use IPv4 and adjusted file permission checks with sudo. Changed the forward address prompt to specify IPv4 and modified the binary download process to fetch and install the latest `.deb` release URL automatically + +- [Update stored epoch share when changing ownership](https://github.com/nymtech/nym/pull/6135) + +- [Update stored epoch share when changing announce address](https://github.com/nymtech/nym/pull/6131) + +### Refactors & Maintenance + +- [Re-merge: disconnect mixnet client if registration fails](https://github.com/nymtech/nym/pull/6169) ([\#6158](https://github.com/nymtech/nym/pull/6158)) + +- [Resolve `clippy 1.91` warnings](https://github.com/nymtech/nym/pull/6168) + +- [Remove unused dependencies](https://github.com/nymtech/nym/pull/6151): Removing the crates that only shows up on the workspace `Cargo.toml` + +- [Use typed-builder for registration client builder config](https://github.com/nymtech/nym/pull/6150) + +- [tommy is too quick](https://github.com/nymtech/nym/pull/6149) + ## `v2025.19-kase` - [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2025.19-kase) diff --git a/documentation/docs/pages/operators/nodes/nym-node/setup.mdx b/documentation/docs/pages/operators/nodes/nym-node/setup.mdx index afae7fb016..de584513ec 100644 --- a/documentation/docs/pages/operators/nodes/nym-node/setup.mdx +++ b/documentation/docs/pages/operators/nodes/nym-node/setup.mdx @@ -21,10 +21,10 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](. ```sh nym-node Binary Name: nym-node -Build Timestamp: 2025-10-30T12:43:37.933354749Z -Build Version: 1.20.0 -Commit SHA: 75a6d3426bd18dca600ad1cfa39b0a3c4f319c69 -Commit Date: 2025-10-30T11:59:32.000000000+01:00 +Build Timestamp: 2025-11-12T08:19:33.288341371Z +Build Version: 1.21.0 +Commit SHA: babf113fe5d396fa8a84fa939ad4b1b5b4d38b83 +Commit Date: 2025-11-12T08:39:48.000000000+01:00 Commit Branch: HEAD rustc Version: 1.88.0 rustc Channel: stable From fa40acbeca11d4b5bcf905117a73658298c0c034 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Wed, 12 Nov 2025 15:12:38 +0000 Subject: [PATCH 11/23] fixed broken link (#6193) --- documentation/docs/pages/network/architecture/mixnet.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/pages/network/architecture/mixnet.mdx b/documentation/docs/pages/network/architecture/mixnet.mdx index 352db039d8..4843a292a6 100644 --- a/documentation/docs/pages/network/architecture/mixnet.mdx +++ b/documentation/docs/pages/network/architecture/mixnet.mdx @@ -32,7 +32,7 @@ Upcoming: ## Nym Clients - You can read about setting up and using various clients in the [Developer Docs](../../developers/clients). + You can read about setting up and using various clients in the [Developer Docs](../../developers/clients/socks5). A large proportion of the Nym Mixnet's functionality is implemented client-side. @@ -48,4 +48,4 @@ Clients perform the following actions on behalf of users: * Send Sphinx packet [cover traffic](../concepts/cover-traffic) when no real messages are being sent * Retransmit [un-acknowledged packet sends](../traffic/acks) -> At the moment due to the fact that Nym clients are [message-based](../../developers/concepts/messages), using the Mixnet requires another client on the 'other side' of the mixet to send packets to, unless you're using the `nymvpn` client (part of the NymVPN app) or the `socks5` client, which operates as a SOCKS4,4a, or 5 proxy and is able to utilise the client embedded within the `nym-node`'s Exit Gateway functionality (prev. this functionality was a standalone service, the Network Requester). In the future we wish to remove this point of friction and have all Nym clients construct IP packets instead, easing the integration burden and abstracting away the message-based nature of client communication. +> At the moment due to the fact that Nym clients are message-based, using the Mixnet requires another client on the 'other side' of the mixet to send packets to, unless you're using the `nymvpn` client (part of the NymVPN app) or the `socks5` client, which operates as a SOCKS4,4a, or 5 proxy and is able to utilise the client embedded within the `nym-node`'s Exit Gateway functionality (prev. this functionality was a standalone service, the Network Requester). In the future we wish to remove this point of friction and have all Nym clients construct IP packets instead, easing the integration burden and abstracting away the message-based nature of client communication. From ec015618cd0ccfa93574f0de013eac3e9b4e6671 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Thu, 13 Nov 2025 11:02:45 +0000 Subject: [PATCH 12/23] update gw probe to point @ monorepo (#6194) * update gw probe to point @ monorepo * add funded nyx account info --- .../performance-and-testing/gateway-probe.mdx | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/documentation/docs/pages/operators/performance-and-testing/gateway-probe.mdx b/documentation/docs/pages/operators/performance-and-testing/gateway-probe.mdx index 8b1b8f38f2..a9b7199761 100644 --- a/documentation/docs/pages/operators/performance-and-testing/gateway-probe.mdx +++ b/documentation/docs/pages/operators/performance-and-testing/gateway-probe.mdx @@ -9,23 +9,21 @@ Nym Node operators running Gateway functionality are already familiar with the m ## Preparation -We recommend to have installed all [the prerequisites](../binaries/building-nym.md#prerequisites) needed to build `nym-node` from source including latest [Rust Toolchain](https://www.rust-lang.org/tools/install), **and** make sure to have [Go](https://go.dev/doc/install) installed. Go is necessary as the probe uses the `rust2go` FFI library to use `netstack` when making requests. +We recommend to have installed all [the prerequisites](../binaries/building-nym.md#prerequisites) needed to build `nym-node` from source including latest [Rust Toolchain](https://www.rust-lang.org/tools/install), **and** make sure to have [Go](https://go.dev/doc/install) installed. Go is necessary as the probe uses the `rust2go` FFI library to use `netstack` when making requests. ## Installation -`nym-gateway-probe` source code is in [`nym-vpn-client`](https://github.com/nymtech/nym-vpn-client) repository. The client needs to be build from source. +`nym-gateway-probe` source code is in [`nym` monorepo](https://github.com/nymtech/nym). The probe needs to be built from source. 1. Clone the repository: ```sh -git clone https://github.com/nymtech/nym-vpn-client.git +git clone https://github.com/nymtech/nym.git ``` 2. Build `nym-gateway-probe`: ```sh -cd nym-vpn-client/nym-vpn-core - cargo build --release -p nym-gateway-probe ``` @@ -34,65 +32,71 @@ cargo build --release -p nym-gateway-probe To list all commands and options run the binary with `--help` command: ```sh -./target/release/nym-gateway-probe -h +./target/release/nym-gateway-probe -h ``` - Output: ```sh -Usage: nym-gateway-probe [OPTIONS] --mnemonic + Usage: nym-gateway-probe [OPTIONS] [COMMAND] -Options: - -c, --config-env-file - Path pointing to an env file describing the network - -g, --entry-gateway - The specific gateway specified by ID - -n, --node - Identity of the node to test - --min-gateway-mixnet-performance - - --min-gateway-vpn-performance - - --only-wireguard - - -i, --ignore-egress-epoch-role - Disable logging during probe - --no-log - - -a, --amnezia-args - Arguments to be appended to the wireguard config enabling amnezia-wg configuration - --netstack-download-timeout-sec - [default: 180] - --netstack-v4-dns - [default: 1.1.1.1] - --netstack-v6-dns - [default: 2606:4700:4700::1111] - --netstack-num-ping - [default: 5] - --netstack-send-timeout-sec - [default: 3] - --netstack-recv-timeout-sec - [default: 3] - --netstack-ping-hosts-v4 - [default: nymtech.net] - --netstack-ping-ips-v4 - [default: 1.1.1.1] - --netstack-ping-hosts-v6 - [default: ipv6.google.com] - --netstack-ping-ips-v6 - [default: 2001:4860:4860::8888 2606:4700:4700::1111 2620:fe::fe] - --mnemonic - - -h, --help - Print help - -V, --version - Print version + Commands: + run-local Run the probe locally + help Print this message or the help of the given subcommand(s) + + Options: + -c, --config-env-file + Path pointing to an env file describing the network + -g, --entry-gateway + The specific gateway specified by ID + -n, --node + Identity of the node to test + --min-gateway-mixnet-performance + + --only-wireguard + + --ignore-egress-epoch-role + Disable logging during probe + --no-log + + -a, --amnezia-args + Arguments to be appended to the wireguard config enabling amnezia-wg configuration + --netstack-download-timeout-sec + [default: 180] + --metadata-timeout-sec + [default: 30] + --netstack-v4-dns + [default: 1.1.1.1] + --netstack-v6-dns + [default: 2606:4700:4700::1111] + --netstack-num-ping + [default: 5] + --netstack-send-timeout-sec + [default: 3] + --netstack-recv-timeout-sec + [default: 3] + --netstack-ping-hosts-v4 + [default: nym.com] + --netstack-ping-ips-v4 + [default: 1.1.1.1] + --netstack-ping-hosts-v6 + [default: cloudflare.com] + --netstack-ping-ips-v6 + [default: 2001:4860:4860::8888 2606:4700:4700::1111 2620:fe::fe] + --ticket-materials + + --ticket-materials-revision + [default: 1] + -h, --help + Print help + -V, --version + Print version ``` -To run the client, simply add `-n` flag followed by the ID key of the node you wish to test, as well as the mnemonic of a funded Nyx account; this is required to test the ticketbook generation. +To run the client, simply add `-g` flag followed by the ID key of the node you wish to test, as well as the mnemonic of a funded Nyx account; this is required to test the ticketbook generation. **Note that this accout needs to have NYM tokens; you cannot use an account with a NymVPN subscription**. Make sure to have at least a few NYM tokens in there. ```sh -./target/release/nym-gateway-probe -n --mnemonic +./target/release/nym-gateway-probe run-local -g --mnemonic ``` For any `nym-node --mode exit-gateway` the aim is to have this outcome: @@ -125,4 +129,4 @@ For any `nym-node --mode exit-gateway` the aim is to have this outcome: **If your Gateway is blacklisted, the probe will not work.** -If you don't provide a `-n` flag it will pick a random node to test. +If you don't provide a `-g` flag it will pick a random node to test. From 577675bab309b5b1301e1536900fa902494ccc5f Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Thu, 13 Nov 2025 11:38:54 +0000 Subject: [PATCH 13/23] Remove old conceptsoverview page + move index to proper place in sidebar (#6196) --- .../developers/concepts.mdx => backup-pages/concepts.mdx.bak} | 0 .../docs/components/outputs/api-scraping-outputs/time-now.md | 2 +- .../outputs/command-outputs/node-api-check-query-help.md | 2 +- documentation/docs/pages/developers/_meta.json | 1 + 4 files changed, 3 insertions(+), 2 deletions(-) rename documentation/{docs/pages/developers/concepts.mdx => backup-pages/concepts.mdx.bak} (100%) diff --git a/documentation/docs/pages/developers/concepts.mdx b/documentation/backup-pages/concepts.mdx.bak similarity index 100% rename from documentation/docs/pages/developers/concepts.mdx rename to documentation/backup-pages/concepts.mdx.bak diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 62471ca968..bd9b415e4a 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Wednesday, November 12th 2025, 11:13:44 UTC \ No newline at end of file +Thursday, November 13th 2025, 11:23:33 UTC diff --git a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md index bfa068ddb0..999f3d4a6f 100644 --- a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md +++ b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md @@ -11,7 +11,7 @@ options: --no_routing_history Display node stats without routing history --no_verloc_metrics Display node stats without verloc metrics -m, --markdown Display results in markdown format - -o [OUTPUT], --output [OUTPUT] + -o, --output [OUTPUT] Save results to file (in current dir or supply with path without filename) ``` diff --git a/documentation/docs/pages/developers/_meta.json b/documentation/docs/pages/developers/_meta.json index d89ffe6bd5..65cfa18527 100644 --- a/documentation/docs/pages/developers/_meta.json +++ b/documentation/docs/pages/developers/_meta.json @@ -1,4 +1,5 @@ { + "index": "Introduction", "--": { "type": "separator", "title": "Mixnet Integrations" From aac983d9223a93a6476566c6f3b47c62bdd4fed0 Mon Sep 17 00:00:00 2001 From: Drazen Urch Date: Thu, 13 Nov 2025 14:18:29 +0100 Subject: [PATCH 14/23] Remove debug feature from http-macro spec in gateway probe (#6195) --- nym-gateway-probe/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 74f547ee53..5c91e35078 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -56,7 +56,7 @@ nym-ip-packet-requests = { path = "../common/ip-packet-requests" } nym-sdk = { path = "../sdk/rust/nym-sdk" } nym-validator-client = { path = "../common/client-libs/validator-client" } nym-credentials = { path = "../common/credentials" } -nym-http-api-client-macro = { path = "../common/http-api-client-macro", features = ["debug-inventory"] } +nym-http-api-client-macro = { path = "../common/http-api-client-macro" } nym-http-api-client = { path = "../common/http-api-client" } nym-node-status-client = { path = "../nym-node-status-api/nym-node-status-client" } From 17ca0007823f6e5503c7f8a6ceec13ac48916eb0 Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Fri, 14 Nov 2025 00:59:36 -0700 Subject: [PATCH 15/23] HTTP API resilience enable & domain rotation conditions (#6200) * http url fallback conditions * include changes and tests for fronted * Allow for explicit DNS error Handling in HTTP client (#6201) when sending http reqs add manual DNS so we can handle errors directly * Address PR nits --------- Co-authored-by: durch --- common/http-api-client/src/dns.rs | 7 ++ common/http-api-client/src/fronted.rs | 85 +++++++++++++++++++++ common/http-api-client/src/lib.rs | 104 ++++++++++++++++++++++---- common/http-api-client/src/tests.rs | 46 +++++++++--- 4 files changed, 215 insertions(+), 27 deletions(-) diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs index 8687cf7f27..79f7997fbe 100644 --- a/common/http-api-client/src/dns.rs +++ b/common/http-api-client/src/dns.rs @@ -88,6 +88,13 @@ pub enum ResolveError { StaticLookupMiss, } +impl ResolveError { + /// Returns true if the error is a timeout. + pub fn is_timeout(&self) -> bool { + matches!(self, ResolveError::Timeout) + } +} + /// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. /// /// Typical use involves instantiating using the `Default` implementation and then resolving using diff --git a/common/http-api-client/src/fronted.rs b/common/http-api-client/src/fronted.rs index 4b5a9d9eab..be884264cf 100644 --- a/common/http-api-client/src/fronted.rs +++ b/common/http-api-client/src/fronted.rs @@ -121,4 +121,89 @@ mod tests { // println!("{response:?}"); assert_eq!(response.status(), 200); } + + #[tokio::test] + async fn fallback_on_failure() { + let url1 = Url::new( + "https://fake-domain.nymtech.net", + Some(vec![ + "https://fake-front-1.nymtech.net", + "https://fake-front-2.nymtech.net", + ]), + ) + .unwrap(); + let url2 = Url::new( + "https://validator.global.ssl.fastly.net", + Some(vec!["https://yelp.global.ssl.fastly.net"]), + ) + .unwrap(); // fastly + + let client = ClientBuilder::new_with_urls(vec![url1, url2]) + .expect("bad url") + .with_fronting(FrontPolicy::Always) + .build() + .expect("failed to build client"); + + // Check that the initial configuration has the broken domain and front. + assert_eq!( + client.current_url().as_str(), + "https://fake-domain.nymtech.net/", + ); + assert_eq!( + client.current_url().front_str(), + Some("fake-front-1.nymtech.net"), + ); + + let result = client + .send_request::<_, (), &str, &str>( + reqwest::Method::GET, + &["api", "v1", "network", "details"], + NO_PARAMS, + None, + ) + .await; + assert!(result.is_err()); + + // Check that the host configuration updated the front on error. + assert_eq!( + client.current_url().as_str(), + "https://fake-domain.nymtech.net/", + ); + assert_eq!( + client.current_url().front_str(), + Some("fake-front-2.nymtech.net"), + ); + + let result = client + .send_request::<_, (), &str, &str>( + reqwest::Method::GET, + &["api", "v1", "network", "details"], + NO_PARAMS, + None, + ) + .await; + assert!(result.is_err()); + + // Check that the host configuration updated the domain and front on error. + assert_eq!( + client.current_url().as_str(), + "https://validator.global.ssl.fastly.net/", + ); + assert_eq!( + client.current_url().front_str(), + Some("yelp.global.ssl.fastly.net"), + ); + + let response = client + .send_request::<_, (), &str, &str>( + reqwest::Method::GET, + &["api", "v1", "network", "details"], + NO_PARAMS, + None, + ) + .await + .expect("failed get request"); + + assert_eq!(response.status(), 200); + } } diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index f2c4e09742..a74a141fe6 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -395,6 +395,13 @@ pub enum HttpClientError { #[error("failed to resolve request to {url} due to data inconsistency: {details}")] InternalResponseInconsistency { url: ::url::Url, details: String }, + #[cfg(not(target_arch = "wasm32"))] + #[error("encountered dns failure: {inner}")] + DnsLookupFailure { + #[from] + inner: ResolveError, + }, + #[error("Failed to encode bincode: {0}")] Bincode(#[from] bincode::Error), @@ -421,6 +428,8 @@ impl HttpClientError { HttpClientError::ReqwestClientError { source } => source.is_timeout(), HttpClientError::RequestSendFailure { source, .. } => source.0.is_timeout(), HttpClientError::ResponseReadFailure { source, .. } => source.0.is_timeout(), + #[cfg(not(target_arch = "wasm32"))] + HttpClientError::DnsLookupFailure { inner } => inner.is_timeout(), #[cfg(target_arch = "wasm32")] HttpClientError::RequestTimeout => true, _ => false, @@ -775,6 +784,7 @@ impl ClientBuilder { base_urls: self.urls, current_idx: Arc::new(AtomicUsize::new(0)), reqwest_client, + using_secure_dns: self.use_secure_dns, #[cfg(feature = "tunneling")] front: self.front, @@ -795,6 +805,7 @@ pub struct Client { base_urls: Vec, current_idx: Arc, reqwest_client: reqwest::Client, + using_secure_dns: bool, #[cfg(feature = "tunneling")] front: Option, @@ -852,6 +863,7 @@ impl Client { base_urls: vec![new_url], current_idx: Arc::new(Default::default()), reqwest_client: self.reqwest_client.clone(), + using_secure_dns: self.using_secure_dns, #[cfg(feature = "tunneling")] front: self.front.clone(), @@ -883,8 +895,34 @@ impl Client { self.retry_limit = limit; } + fn matches_current_host(&self, url: &Url) -> bool { + if cfg!(feature = "tunneling") { + if let Some(ref front) = self.front + && front.is_enabled() + { + url.host_str() == self.current_url().front_str() + } else { + url.host_str() == self.current_url().host_str() + } + } else { + url.host_str() == self.current_url().host_str() + } + } + /// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error) - fn update_host(&self) { + /// + /// Takes an optional URL argument. If this is none, the current host will be updated automatically. + /// If a url is provided first check that the CURRENT host matches the hostname in the URL before + /// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host + /// multiple times. + fn update_host(&self, maybe_url: Option) { + // If a causal url is provided and it doesn't match the hostname currently in use, skip update. + if let Some(err_url) = maybe_url + && !self.matches_current_host(&err_url) + { + return; + } + #[cfg(feature = "tunneling")] if let Some(ref front) = self.front && front.is_enabled() @@ -1048,8 +1086,28 @@ impl ApiClientCore for Client { .build() .map_err(HttpClientError::reqwest_client_build_error)?; self.apply_hosts_to_req(&mut req); + let url: Url = req.url().clone().into(); + + // try an explicit DNS resolution - if successful then it will be in cache when reqwest + // goes to execute the request. If failure then we get to handle the DNS lookup error. #[cfg(not(target_arch = "wasm32"))] - let url = req.url().clone(); + if self.using_secure_dns + && let Some(hostname) = req.url().domain() + // Default here will use a shared resolver instance + && let Err(err) = HickoryDnsResolver::default().resolve_str(hostname).await + { + // on failure update host, but don't trigger fronting enable. + self.update_host(Some(url.clone())); + + if attempts < self.retry_limit { + attempts += 1; + warn!( + "Retrying request due to dns error on attempt ({attempts}/{}): {err}", + self.retry_limit + ); + continue; + } + } #[cfg(target_arch = "wasm32")] let response: Result = { @@ -1067,25 +1125,39 @@ impl ApiClientCore for Client { match response { Ok(resp) => return Ok(resp), Err(err) => { - // if we have multiple urls, update to the next - self.update_host(); + // only if there was a network issue should we consider updating the host info + // + // note: for now this includes DNS resolution failure, I am not sure how I would go about + // segregating that based on the interface provided by request for errors. + #[cfg(target_arch = "wasm32")] + let is_network_err = err.is_timeout(); + #[cfg(not(target_arch = "wasm32"))] + let is_network_err = err.is_timeout() || err.is_connect(); - #[cfg(feature = "tunneling")] - if let Some(ref front) = self.front { - // If fronting is set to be enabled on error, enable domain fronting as we - // have encountered an error. - let was_enabled = front.is_enabled(); - front.retry_enable(); - if !was_enabled && front.is_enabled() { - tracing::info!( - "Domain fronting activated after connection failure: {err}", - ); + if is_network_err { + // if we have multiple urls, update to the next + self.update_host(Some(url.clone())); + + #[cfg(feature = "tunneling")] + if let Some(ref front) = self.front { + // If fronting is set to be enabled on error, enable domain fronting as we + // have encountered an error. + let was_enabled = front.is_enabled(); + front.retry_enable(); + if !was_enabled && front.is_enabled() { + tracing::info!( + "Domain fronting activated after connection failure: {err}", + ); + } } } if attempts < self.retry_limit { - warn!("Retrying request due to http error: {err}"); attempts += 1; + warn!( + "Retrying request due to http error on attempt ({attempts}/{}): {err}", + self.retry_limit + ); continue; } @@ -1094,7 +1166,7 @@ impl ApiClientCore for Client { if #[cfg(target_arch = "wasm32")] { return Err(err); } else { - return Err(HttpClientError::request_send_error(url, err)); + return Err(HttpClientError::request_send_error(url.into(), err)); } } } diff --git a/common/http-api-client/src/tests.rs b/common/http-api-client/src/tests.rs index a990ef653f..bbfe8df6e0 100644 --- a/common/http-api-client/src/tests.rs +++ b/common/http-api-client/src/tests.rs @@ -91,14 +91,15 @@ fn sanitizing_urls() { #[tokio::test] async fn api_client_retry() -> Result<(), Box> { let client = ClientBuilder::new_with_urls(vec![ - "http://broken.nym.test".parse()?, // This will fail - "https://httpbin.org/status/200".parse()?, // This will succeed + "http://broken.nym.test".parse()?, // This will fail because of DNS (rotate) + "http://127.0.0.1:9".parse()?, // This will fail because of TCP refused (rotate) + "https://httpbin.org/status/200".parse()?, // This should succeed ])? .with_retries(3) .build()?; let req = client.create_get_request(&[], NO_PARAMS).unwrap(); - let resp = client.send(req).await?; + let _resp = client.send(req).await?; // The main test is that we successfully retried and switched to the working URL // We accept any response from the working endpoint since external services can be unreliable @@ -107,7 +108,9 @@ async fn api_client_retry() -> Result<(), Box> { "https://httpbin.org/status/200" ); - println!("Response status: {}", resp.status()); + // // This assert can be unreliable due to factors beyond our control and beyond the scope of + // // this test + // assert_eq!(_resp.status(), StatusCode::OK); Ok(()) } @@ -123,7 +126,7 @@ fn host_updating() { assert_eq!(current_url.front_str(), None); // update the url - client.update_host(); + client.update_host(None); // check that the url is still the same since there is one URL assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); @@ -138,13 +141,13 @@ fn host_updating() { client.change_base_urls(new_urls); assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); - client.update_host(); + client.update_host(None); // check that the url got updated now that there are multiple URLs assert_eq!(client.current_url().as_str(), "http://nym-api2.test/"); assert_eq!(client.current_url().front_str(), None); - client.update_host(); + client.update_host(None); assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); // ======================================= @@ -162,12 +165,33 @@ fn host_updating() { assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); - client.update_host(); + client.update_host(None); // check that the url got updated now that there are multiple URLs assert_eq!(client.current_url().as_str(), "http://nym-api2.test/"); } +#[test] +fn host_updating_url_conditioned() { + let url1 = Url::new("http://nym-api1.test", None).unwrap(); + let url2 = Url::new("http://nym-api2.test", None).unwrap(); + let urls = vec![url1.clone(), url2.clone()]; + let client = ClientBuilder::new_with_urls(urls).unwrap().build().unwrap(); + + assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); + + // Try to update with a URL that does NOT match current - should result in no change + client.update_host(Some(Url::parse("http://example.com").unwrap())); + + // check that the url did NOT get updated + assert_eq!(client.current_url().as_str(), "http://nym-api1.test/"); + assert_eq!(client.current_url().front_str(), None); + + // Try to update with a URL that DOES match current - should result in no change + client.update_host(Some(url1)); + assert_eq!(client.current_url().as_str(), "http://nym-api2.test/"); +} + #[test] #[cfg(feature = "tunneling")] fn fronted_host_updating() { @@ -184,7 +208,7 @@ fn fronted_host_updating() { assert_eq!(current_url.front_str(), Some("cdn1.test")); // update the url - client.update_host(); + client.update_host(None); // check that the url is still the same since there is one URL and one front let current_url = client.current_url(); @@ -209,7 +233,7 @@ fn fronted_host_updating() { assert_eq!(current_url.front_str(), Some("cdn1.test")); // update the url - this should keep the same host but change the front - client.update_host(); + client.update_host(None); let current_url = client.current_url(); // check that the url is still the same since there is one URL @@ -217,7 +241,7 @@ fn fronted_host_updating() { assert_eq!(current_url.front_str(), Some("cdn2.test")); // update the url - this should wrap around to the first front as the second url is not fronted - client.update_host(); + client.update_host(None); let current_url = client.current_url(); assert_eq!(current_url.as_str(), "http://nym-api.test/"); From 350d2440324f7367b2a27128e4af4876c61b770b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Nov 2025 08:19:21 +0000 Subject: [PATCH 16/23] bugfix: fix credential proxy upgrade mode attestation url arg (#6202) this includes bringing over changes introduced in #6174 --- common/credential-proxy/src/error.rs | 8 ++++++ common/network-defaults/src/mainnet.rs | 21 +++++++++++++++ common/network-defaults/src/var_names.rs | 2 ++ .../nym-credential-proxy/src/cli.rs | 8 ++---- .../nym-credential-proxy/src/helpers.rs | 26 ++++++++++++++++++- nym-wallet/Cargo.lock | 1 + 6 files changed, 59 insertions(+), 7 deletions(-) diff --git a/common/credential-proxy/src/error.rs b/common/credential-proxy/src/error.rs index b1e1506763..d6e3e8d28f 100644 --- a/common/credential-proxy/src/error.rs +++ b/common/credential-proxy/src/error.rs @@ -168,6 +168,14 @@ pub enum CredentialProxyError { device_id: String, credential_id: String, }, + + #[error( + "the attestation check url has not been provided through either the CLI nor the default .env config" + )] + AttestationCheckUrlNotSet, + + #[error("the provided attestation check url is malformed: {source}")] + MalformedAttestationCheckUrl { source: url::ParseError }, } impl From for CredentialProxyError { diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 8bace8f485..291f397482 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -54,6 +54,11 @@ pub const NYM_APIS: &[ApiUrlConst] = &[ ]; pub const NYM_VPN_API: &str = "https://nymvpn.com/api/"; + +pub const UPGRADE_MODE_ATTESTATION_URL: &str = "https://nym.com/upgrade-mode/attestation.json"; +pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = + "3bgffBYcfFkTTXc2npNNn9MkddFZ3H2LrPjXDmnJzrqd"; + #[cfg(feature = "network")] pub const NYM_VPN_APIS: &[ApiUrlConst] = &[ ApiUrlConst { @@ -159,6 +164,14 @@ pub fn export_to_env() { set_var_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS); set_var_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL); set_var_to_default(var_names::NYM_VPN_API, NYM_VPN_API); + set_var_to_default( + var_names::UPGRADE_MODE_ATTESTATION_URL, + UPGRADE_MODE_ATTESTATION_URL, + ); + set_var_to_default( + var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, + UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, + ); } #[cfg(all(feature = "env", feature = "network"))] @@ -199,4 +212,12 @@ pub fn export_to_env_if_not_set() { set_var_conditionally_to_default(var_names::NYM_API, NYM_API); set_var_conditionally_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS); set_var_conditionally_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL); + set_var_conditionally_to_default( + var_names::UPGRADE_MODE_ATTESTATION_URL, + UPGRADE_MODE_ATTESTATION_URL, + ); + set_var_conditionally_to_default( + var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, + UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, + ); } diff --git a/common/network-defaults/src/var_names.rs b/common/network-defaults/src/var_names.rs index 5bcebe85eb..897937b0fe 100644 --- a/common/network-defaults/src/var_names.rs +++ b/common/network-defaults/src/var_names.rs @@ -25,6 +25,8 @@ pub const NYXD_WEBSOCKET: &str = "NYXD_WS"; pub const EXIT_POLICY_URL: &str = "EXIT_POLICY"; pub const NYM_VPN_API: &str = "NYM_VPN_API"; pub const CLIENT_STATS_COLLECTION_PROVIDER: &str = "CLIENT_STATS_COLLECTION_PROVIDER"; +pub const UPGRADE_MODE_ATTESTATION_URL: &str = "UPGRADE_MODE_ATTESTATION_URL"; +pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = "UPGRADE_MODE_ATTESTER_ED25519_PUBKEY"; pub const DKG_TIME_CONFIGURATION: &str = "DKG_TIME_CONFIGURATION"; diff --git a/nym-credential-proxy/nym-credential-proxy/src/cli.rs b/nym-credential-proxy/nym-credential-proxy/src/cli.rs index a8de014423..d5c27a7730 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/cli.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/cli.rs @@ -156,12 +156,8 @@ pub struct Cli { #[derive(Args, Debug, Clone)] pub struct UpgradeModeConfig { /// URL for polling for upgrade mode changes. - #[clap( - long, - env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL", - default_value = "5m" - )] - pub(crate) attestation_check_url: Url, + #[clap(long, env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL")] + pub(crate) attestation_check_url: Option, /// Default polling interval of the upgrade mode endpoint. #[clap( diff --git a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs index a353c3b447..cd061d8962 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs @@ -8,6 +8,8 @@ use nym_bin_common::bin_info; use nym_credential_proxy_lib::error::CredentialProxyError; use nym_credential_proxy_lib::storage::CredentialProxyStorage; use nym_credential_proxy_lib::ticketbook_manager::TicketbookManager; +use nym_network_defaults::var_names; +use nym_network_defaults::var_names::CONFIGURED; use tracing::{error, info}; pub async fn wait_for_signal() { @@ -55,6 +57,28 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { let webhook_cfg = cli.webhook; let jwt_signing_keys = cli.jwt_signing_keys.signing_keys()?; + let upgrade_mode_attestation_check_url = match cli.upgrade_mode.attestation_check_url { + Some(url) => url, + None => { + // argument hasn't been provided and env is not configured + if std::env::var(CONFIGURED).is_err() { + return Err(CredentialProxyError::AttestationCheckUrlNotSet); + } + // argument hasn't been provided and the relevant env value hasn't been set + // (technically this shouldn't be possible) + let Ok(env_url) = std::env::var(var_names::UPGRADE_MODE_ATTESTATION_URL) else { + return Err(CredentialProxyError::AttestationCheckUrlNotSet); + }; + + match env_url.parse() { + Ok(url) => url, + Err(err) => { + return Err(CredentialProxyError::MalformedAttestationCheckUrl { source: err }); + } + } + } + }; + let ticketbook_manager = TicketbookManager::new( build_sha_short(), cli.quorum_check_interval, @@ -70,7 +94,7 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { cli.upgrade_mode.attestation_check_regular_polling_interval, cli.upgrade_mode .attestation_check_expedited_polling_interval, - cli.upgrade_mode.attestation_check_url, + upgrade_mode_attestation_check_url, jwt_signing_keys, cli.upgrade_mode.upgrade_mode_jwt_validity, ); diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 546ee6409d..26df1b52e6 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4219,6 +4219,7 @@ dependencies = [ "serde_plain", "serde_yaml", "thiserror 2.0.12", + "tokio", "tracing", "url", "wasmtimer", From 6b2bb3029b2b3356bd48a4fb988ff2c8df4cb068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Nov 2025 13:13:15 +0000 Subject: [PATCH 17/23] feat: merge intermediate upgrade mode changes (#6174) * squashing feat: merge intermediate upgrade mode changes #6174 to more easily resolve merge conflicts during rebasing added additional v2 query for metadata endpoint for requesting upgrade mode recheck added additional message to v6 authenticator to request explicit upgrade mode recheck clippy test fixes due to updated keys updated assertion for upgrading v1 top up request to v2 compare attester public key against the expected value within the credential proxy use pre-generated attestation public keys within nym-nodes remove version deprecation bugfix: default bandwidth response for authenticator expose upgrade mode information in authenticator responses adding tests for new v2 server passing upgrade mode information in metadata endpoint v2 wireguard private metadata bugfix: make sure to immediately poll for attestation after spawning task fix gateway probe and remove code duplication for finalizing registration squashing before rebasing post rebasing fixes AuthenticatorVersion helpers additional nits allow unwraps in mocks fixed linux build clippy integrating upgrade mode into authenticator fixed build after adding wrappers to response types conditionally updating peer handle bandwidth cleanup negotiate initial protocol during registration change auth to use highest protocol handler for JWT message dont meter client bandwidth in upgrade mode handling recheck requests sending information about upgrade_mode on client messages gateway watching for upgrade mode attestation wip: gateways to disable bandwidth metering on upgrade mode * fixed ServerResponse deserialisation * fixed incorrect swagger path for upgrade mode check endpoint * moved upgrade mode endpoint out of bandwidth routes * chore: remove unused error variant * removed re-export of UpgradeModeAttestation from credentials-interface * chore: define single source of truth for minimum bandwidth threshold value * moved type definitions out of traits.rs * updated v6 versioning to point to niolo release instead * fixed incorrect error mapping --- Cargo.lock | 30 +- Cargo.toml | 1 + common/authenticator-requests/Cargo.toml | 7 + .../src/client_message.rs | 476 +++++++++------- common/authenticator-requests/src/error.rs | 11 + common/authenticator-requests/src/lib.rs | 4 +- common/authenticator-requests/src/models.rs | 52 ++ common/authenticator-requests/src/request.rs | 53 +- common/authenticator-requests/src/response.rs | 51 +- common/authenticator-requests/src/traits.rs | 490 +++++++++++++++-- .../src/v1/registration.rs | 2 +- .../authenticator-requests/src/v1/response.rs | 6 +- .../src/v2/conversion.rs | 4 +- .../src/v2/registration.rs | 2 +- .../authenticator-requests/src/v2/response.rs | 6 +- .../src/v3/conversion.rs | 16 +- .../src/v3/registration.rs | 2 +- .../authenticator-requests/src/v3/response.rs | 6 +- .../src/v4/conversion.rs | 16 +- .../src/v4/registration.rs | 2 +- .../authenticator-requests/src/v4/response.rs | 6 +- .../src/v5/conversion.rs | 8 +- .../src/v5/registration.rs | 2 +- .../authenticator-requests/src/v5/response.rs | 6 +- .../src/v6/conversion.rs | 441 +++++++++++++++ common/authenticator-requests/src/v6/mod.rs | 15 + .../src/v6/registration.rs | 287 ++++++++++ .../authenticator-requests/src/v6/request.rs | 135 +++++ .../authenticator-requests/src/v6/response.rs | 153 ++++++ common/authenticator-requests/src/v6/topup.rs | 15 + .../src/v6/upgrade_mode_check.rs | 12 + common/authenticator-requests/src/version.rs | 26 +- common/client-libs/gateway-client/Cargo.toml | 3 + .../gateway-client/src/bandwidth.rs | 78 ++- .../gateway-client/src/client/config.rs | 4 +- .../gateway-client/src/client/mod.rs | 212 +++++--- .../client-libs/gateway-client/src/error.rs | 3 + .../gateway-client/src/packet_router.rs | 2 + .../gateway-client/src/socket_state.rs | 25 +- common/credential-proxy/src/error.rs | 13 +- common/credential-verification/Cargo.toml | 3 +- .../src/bandwidth_storage_manager.rs | 8 +- common/credential-verification/src/error.rs | 19 + common/credential-verification/src/lib.rs | 1 + .../src/upgrade_mode.rs | 284 ++++++++++ common/credentials-interface/Cargo.toml | 2 + common/credentials-interface/src/lib.rs | 29 + common/gateway-requests/Cargo.toml | 3 + common/gateway-requests/src/lib.rs | 66 ++- .../src/registration/handshake/client.rs | 26 +- .../src/registration/handshake/error.rs | 8 + .../src/registration/handshake/gateway.rs | 43 +- .../src/registration/handshake/messages.rs | 11 +- .../src/registration/handshake/mod.rs | 224 ++++++-- .../src/registration/handshake/state.rs | 119 ++-- .../gateway-requests/src/shared_key/legacy.rs | 5 + common/gateway-requests/src/shared_key/mod.rs | 2 + common/gateway-requests/src/types/error.rs | 2 +- .../types/registration_handshake_wrapper.rs | 21 +- .../src/types/text_request/authenticate.rs | 8 +- .../src/types/text_request/mod.rs | 31 +- .../src/types/text_response.rs | 126 ++++- .../src/lib.rs | 11 + common/upgrade-mode-check/src/error.rs | 4 +- common/upgrade-mode-check/src/jwt.rs | 10 +- common/upgrade-mode-check/src/lib.rs | 5 +- .../client/src/lib.rs | 17 + .../server/src/http/router.rs | 9 +- .../server/src/http/state.rs | 124 ++++- .../server/src/network.rs | 64 ++- .../server/src/transceiver.rs | 4 +- .../shared/src/conversion_helpers.rs | 218 ++++++++ .../shared/src/error.rs | 3 + .../shared/src/lib.rs | 3 +- .../shared/src/models/error.rs | 2 +- .../shared/src/models/interface.rs | 186 +++++-- .../shared/src/models/mod.rs | 22 +- .../models/v0/available_bandwidth/request.rs | 71 +-- .../models/v0/available_bandwidth/response.rs | 70 +-- .../shared/src/models/v0/interface.rs | 46 +- .../shared/src/models/v0/mod.rs | 78 +-- .../src/models/v0/topup_bandwidth/request.rs | 72 +-- .../src/models/v0/topup_bandwidth/response.rs | 72 +-- .../models/v1/available_bandwidth/request.rs | 70 +-- .../models/v1/available_bandwidth/response.rs | 70 +-- .../shared/src/models/v1/interface.rs | 78 ++- .../shared/src/models/v1/mod.rs | 88 +-- .../src/models/v1/topup_bandwidth/request.rs | 72 +-- .../src/models/v1/topup_bandwidth/response.rs | 72 +-- .../src/models/v2/available_bandwidth/mod.rs | 5 + .../models/v2/available_bandwidth/request.rs | 50 ++ .../models/v2/available_bandwidth/response.rs | 56 ++ .../src/models/v2/check_upgrade_mode/mod.rs | 5 + .../models/v2/check_upgrade_mode/request.rs | 76 +++ .../models/v2/check_upgrade_mode/response.rs | 52 ++ .../shared/src/models/v2/interface.rs | 304 +++++++++++ .../shared/src/models/v2/mod.rs | 197 +++++++ .../src/models/v2/topup_bandwidth/mod.rs | 5 + .../src/models/v2/topup_bandwidth/request.rs | 61 +++ .../src/models/v2/topup_bandwidth/response.rs | 56 ++ .../shared/src/routes.rs | 2 + .../tests/Cargo.toml | 7 +- .../tests/src/lib.rs | 478 +++++++++++++--- .../tests/src/mock_connect_info.rs | 121 +++++ .../tests/src/v0/app_state.rs | 7 + .../tests/src/v0/interface.rs | 150 ------ .../tests/src/v0/mod.rs | 3 +- .../tests/src/v0/network.rs | 95 ++-- .../tests/src/v0/peer_controller.rs | 9 + .../tests/src/v1/app_state.rs | 33 ++ .../tests/src/v1/mod.rs | 6 + .../tests/src/v1/network.rs | 134 +++++ .../tests/src/v1/peer_controller.rs | 9 + .../tests/src/v2/app_state.rs | 8 + .../tests/src/v2/mod.rs | 6 + .../tests/src/v2/network.rs | 329 +++++++++++ .../tests/src/v2/peer_controller.rs | 177 ++++++ common/wireguard-types/Cargo.toml | 5 +- common/wireguard-types/src/public_key.rs | 13 + common/wireguard/Cargo.toml | 15 +- common/wireguard/src/lib.rs | 12 +- common/wireguard/src/peer_controller.rs | 42 +- common/wireguard/src/peer_handle.rs | 123 ++--- common/wireguard/src/peer_storage_manager.rs | 45 +- envs/canary.env | 2 + envs/mainnet.env | 2 + envs/sandbox.env | 2 + gateway/Cargo.toml | 13 +- gateway/src/config.rs | 29 + .../node/client_handling/active_clients.rs | 9 +- .../client_handling/websocket/common_state.rs | 4 +- .../connection_handler/authenticated.rs | 91 +++- .../websocket/connection_handler/fresh.rs | 144 +++-- .../websocket/connection_handler/mod.rs | 5 +- .../authenticator/error.rs | 22 +- .../authenticator/mixnet_listener.rs | 509 +++++++++++------- .../authenticator/mod.rs | 13 +- .../authenticator/peer_manager.rs | 14 +- gateway/src/node/mod.rs | 71 ++- gateway/src/node/upgrade_mode/mod.rs | 4 + gateway/src/node/upgrade_mode/watcher.rs | 146 +++++ nym-authenticator-client/src/error.rs | 5 +- nym-authenticator-client/src/lib.rs | 285 +++++----- nym-authenticator-client/src/types.rs | 16 + .../nym-credential-proxy-requests/src/lib.rs | 2 - .../src/attestation_watcher.rs | 13 +- .../nym-credential-proxy/src/cli.rs | 4 + .../nym-credential-proxy/src/helpers.rs | 24 + .../src/http/state/nyx_upgrade_mode.rs | 16 +- nym-gateway-probe/src/lib.rs | 66 +-- nym-node/src/cli/commands/run/args.rs | 8 +- nym-node/src/cli/helpers.rs | 18 +- nym-node/src/config/gateway_tasks.rs | 157 +++++- nym-node/src/config/helpers.rs | 13 + nym-node/src/config/mod.rs | 15 +- .../src/config/old_configs/old_config_v10.rs | 12 +- nym-node/src/config/template.rs | 12 + nym-node/src/env.rs | 2 + nym-node/src/node/mod.rs | 29 +- nym-wallet/Cargo.lock | 264 ++++++++- 160 files changed, 7878 insertions(+), 2124 deletions(-) create mode 100644 common/authenticator-requests/src/models.rs create mode 100644 common/authenticator-requests/src/v6/conversion.rs create mode 100644 common/authenticator-requests/src/v6/mod.rs create mode 100644 common/authenticator-requests/src/v6/registration.rs create mode 100644 common/authenticator-requests/src/v6/request.rs create mode 100644 common/authenticator-requests/src/v6/response.rs create mode 100644 common/authenticator-requests/src/v6/topup.rs create mode 100644 common/authenticator-requests/src/v6/upgrade_mode_check.rs create mode 100644 common/credential-verification/src/upgrade_mode.rs create mode 100644 common/wireguard-private-metadata/shared/src/conversion_helpers.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/mod.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/request.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/response.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/mod.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/request.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/response.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/interface.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/mod.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/mod.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/request.rs create mode 100644 common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/response.rs create mode 100644 common/wireguard-private-metadata/tests/src/mock_connect_info.rs create mode 100644 common/wireguard-private-metadata/tests/src/v0/app_state.rs delete mode 100644 common/wireguard-private-metadata/tests/src/v0/interface.rs create mode 100644 common/wireguard-private-metadata/tests/src/v0/peer_controller.rs create mode 100644 common/wireguard-private-metadata/tests/src/v1/app_state.rs create mode 100644 common/wireguard-private-metadata/tests/src/v1/mod.rs create mode 100644 common/wireguard-private-metadata/tests/src/v1/network.rs create mode 100644 common/wireguard-private-metadata/tests/src/v1/peer_controller.rs create mode 100644 common/wireguard-private-metadata/tests/src/v2/app_state.rs create mode 100644 common/wireguard-private-metadata/tests/src/v2/mod.rs create mode 100644 common/wireguard-private-metadata/tests/src/v2/network.rs create mode 100644 common/wireguard-private-metadata/tests/src/v2/peer_controller.rs create mode 100644 gateway/src/node/upgrade_mode/mod.rs create mode 100644 gateway/src/node/upgrade_mode/watcher.rs create mode 100644 nym-authenticator-client/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 6ff6f8bb68..b9bcc7eb58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4988,6 +4988,7 @@ dependencies = [ "nym-network-defaults", "nym-service-provider-requests-common", "nym-sphinx", + "nym-test-utils", "nym-wireguard-types", "rand 0.8.5", "semver 1.0.26", @@ -4995,6 +4996,7 @@ dependencies = [ "sha2 0.10.9", "strum_macros", "thiserror 2.0.12", + "tracing", "x25519-dalek", ] @@ -5605,12 +5607,13 @@ dependencies = [ "nym-api-requests", "nym-credentials", "nym-credentials-interface", + "nym-crypto", "nym-ecash-contract-common", "nym-gateway-requests", "nym-gateway-storage", "nym-task", + "nym-upgrade-mode-check", "nym-validator-client", - "rand 0.8.5", "si-scale", "thiserror 2.0.12", "time", @@ -5650,6 +5653,7 @@ dependencies = [ "nym-compact-ecash", "nym-ecash-time", "nym-network-defaults", + "nym-upgrade-mode-check", "rand 0.8.5", "serde", "strum", @@ -5800,7 +5804,6 @@ dependencies = [ name = "nym-gateway" version = "1.1.36" dependencies = [ - "anyhow", "async-trait", "bincode", "bip39", @@ -5811,7 +5814,6 @@ dependencies = [ "futures", "ipnetwork", "mock_instant", - "nym-api-requests", "nym-authenticator-requests", "nym-client-core", "nym-credential-verification", @@ -5824,7 +5826,6 @@ dependencies = [ "nym-id", "nym-ip-packet-router", "nym-mixnet-client", - "nym-mixnode-common", "nym-network-defaults", "nym-network-requester", "nym-node-metrics", @@ -5834,20 +5835,18 @@ dependencies = [ "nym-statistics-common", "nym-task", "nym-topology", - "nym-types", + "nym-upgrade-mode-check", "nym-validator-client", "nym-wireguard", "nym-wireguard-private-metadata-server", "nym-wireguard-types", "rand 0.8.5", "serde", - "sha2 0.10.9", "thiserror 2.0.12", "time", "tokio", "tokio-stream", "tokio-tungstenite", - "tokio-util", "tracing", "url", "zeroize", @@ -7574,17 +7573,10 @@ dependencies = [ name = "nym-wireguard" version = "0.1.0" dependencies = [ - "async-trait", "base64 0.22.1", - "bincode", - "chrono", - "dashmap", "defguard_wireguard_rs", - "dyn-clone", "futures", "ip_network", - "log", - "nym-authenticator-requests", "nym-credential-verification", "nym-credentials-interface", "nym-crypto", @@ -7595,11 +7587,9 @@ dependencies = [ "nym-task", "nym-wireguard-types", "thiserror 2.0.12", - "time", "tokio", "tokio-stream", "tracing", - "x25519-dalek", ] [[package]] @@ -7651,15 +7641,20 @@ version = "1.0.0" dependencies = [ "async-trait", "axum", + "futures", "nym-credential-verification", "nym-credentials-interface", + "nym-crypto", "nym-http-api-client", "nym-http-api-common", + "nym-upgrade-mode-check", "nym-wireguard", "nym-wireguard-private-metadata-client", "nym-wireguard-private-metadata-server", "nym-wireguard-private-metadata-shared", + "time", "tokio", + "tower 0.5.2", "tower-http 0.5.2", "utoipa", ] @@ -7669,10 +7664,7 @@ name = "nym-wireguard-types" version = "0.1.0" dependencies = [ "base64 0.22.1", - "log", - "nym-config", "nym-crypto", - "nym-network-defaults", "rand 0.8.5", "serde", "thiserror 2.0.12", diff --git a/Cargo.toml b/Cargo.toml index c07db1f4ba..92f5fdbb7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,6 +171,7 @@ members = [ default-members = [ "clients/native", "clients/socks5", + "nym-authenticator-client", "nym-api", "nym-credential-proxy/nym-credential-proxy", "nym-node", diff --git a/common/authenticator-requests/Cargo.toml b/common/authenticator-requests/Cargo.toml index 60ff6826ba..6126a18f80 100644 --- a/common/authenticator-requests/Cargo.toml +++ b/common/authenticator-requests/Cargo.toml @@ -16,6 +16,7 @@ serde = { workspace = true, features = ["derive"] } semver = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } nym-credentials-interface = { path = "../credentials-interface" } nym-crypto = { path = "../crypto", features = ["asymmetric"] } @@ -29,7 +30,13 @@ hmac = { workspace = true, optional = true } sha2 = { workspace = true, optional = true } x25519-dalek = { workspace = true, features = ["static_secrets"] } +[dev-dependencies] +nym-test-utils = { path = "../test-utils" } + [features] default = ["verify"] # this is moved to a separate feature as we really need clients to import it (especially, *cough*, wasm) verify = ["hmac", "sha2"] + +[lints] +workspace = true \ No newline at end of file diff --git a/common/authenticator-requests/src/client_message.rs b/common/authenticator-requests/src/client_message.rs index 06a910b9b9..23bdd13ab0 100644 --- a/common/authenticator-requests/src/client_message.rs +++ b/common/authenticator-requests/src/client_message.rs @@ -6,9 +6,8 @@ use nym_wireguard_types::PeerPublicKey; use crate::{ AuthenticatorVersion, Error, - latest::registration::IpPair, traits::{FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage, Versionable}, - v2, v3, v4, v5, + v2, v3, v4, v5, v6, }; // This is very redundant with AuthenticatorRequest and I reckon they could be smooshed. @@ -21,6 +20,272 @@ pub enum ClientMessage { TopUp(Box), } +pub struct SerialisedRequest { + pub bytes: Vec, + pub request_id: u64, +} + +impl SerialisedRequest { + pub fn new(bytes: Vec, request_id: u64) -> Self { + Self { bytes, request_id } + } +} + +impl ClientMessage { + fn serialise_v1(&self) -> Result { + Err(Error::UnsupportedVersion) + } + + fn serialise_v2(&self, reply_to: Recipient) -> Result { + use v2::{ + registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, + request::AuthenticatorRequest, + }; + match self { + ClientMessage::Initial(init_message) => { + let (req, id) = AuthenticatorRequest::new_initial_request( + InitMessage { + pub_key: init_message.pub_key(), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Final(final_message) => { + let (req, id) = AuthenticatorRequest::new_final_request( + FinalMessage { + gateway_client: GatewayClient { + pub_key: final_message.gateway_client_pub_key(), + private_ip: final_message + .gateway_client_ipv4() + .ok_or(Error::UnsupportedMessage)? + .into(), + mac: ClientMac::new(final_message.gateway_client_mac()), + }, + credential: final_message + .credential() + .and_then(|c| c.credential.into_zk_nym()) + .map(|c| *c), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Query(query_message) => { + let (req, id) = + AuthenticatorRequest::new_query_request(query_message.pub_key(), reply_to); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + _ => Err(Error::UnsupportedMessage), + } + } + + fn serialise_v3(&self, reply_to: Recipient) -> Result { + use v3::{ + registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, + request::AuthenticatorRequest, + topup::TopUpMessage, + }; + match self { + ClientMessage::Initial(init_message) => { + let (req, id) = AuthenticatorRequest::new_initial_request( + InitMessage { + pub_key: init_message.pub_key(), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Final(final_message) => { + let (req, id) = AuthenticatorRequest::new_final_request( + FinalMessage { + gateway_client: GatewayClient { + pub_key: final_message.gateway_client_pub_key(), + private_ip: final_message + .gateway_client_ipv4() + .ok_or(Error::UnsupportedMessage)? + .into(), + mac: ClientMac::new(final_message.gateway_client_mac()), + }, + credential: final_message + .credential() + .and_then(|c| c.credential.into_zk_nym()) + .map(|c| *c), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Query(query_message) => { + let (req, id) = + AuthenticatorRequest::new_query_request(query_message.pub_key(), reply_to); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::TopUp(top_up_message) => { + let (req, id) = AuthenticatorRequest::new_topup_request( + TopUpMessage { + pub_key: top_up_message.pub_key(), + credential: top_up_message.credential(), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + } + } + + fn serialise_v4(&self, reply_to: Recipient) -> Result { + use v4::{ + registration::{ClientMac, FinalMessage, GatewayClient, InitMessage, IpPair}, + request::AuthenticatorRequest, + topup::TopUpMessage, + }; + match self { + ClientMessage::Initial(init_message) => { + let (req, id) = AuthenticatorRequest::new_initial_request( + InitMessage { + pub_key: init_message.pub_key(), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Final(final_message) => { + let (req, id) = AuthenticatorRequest::new_final_request( + FinalMessage { + gateway_client: GatewayClient { + pub_key: final_message.gateway_client_pub_key(), + private_ips: IpPair { + ipv4: final_message + .gateway_client_ipv4() + .ok_or(Error::UnsupportedMessage)?, + ipv6: final_message + .gateway_client_ipv6() + .ok_or(Error::UnsupportedMessage)?, + }, + mac: ClientMac::new(final_message.gateway_client_mac()), + }, + credential: final_message + .credential() + .and_then(|c| c.credential.into_zk_nym()) + .map(|c| *c), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Query(query_message) => { + let (req, id) = + AuthenticatorRequest::new_query_request(query_message.pub_key(), reply_to); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::TopUp(top_up_message) => { + let (req, id) = AuthenticatorRequest::new_topup_request( + TopUpMessage { + pub_key: top_up_message.pub_key(), + credential: top_up_message.credential(), + }, + reply_to, + ); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + } + } + + fn serialise_v5(&self) -> Result { + use v5::{ + registration::{ClientMac, FinalMessage, GatewayClient, InitMessage, IpPair}, + request::AuthenticatorRequest, + topup::TopUpMessage, + }; + match self { + ClientMessage::Initial(init_message) => { + let (req, id) = AuthenticatorRequest::new_initial_request(InitMessage { + pub_key: init_message.pub_key(), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Final(final_message) => { + let (req, id) = AuthenticatorRequest::new_final_request(FinalMessage { + gateway_client: GatewayClient { + pub_key: final_message.gateway_client_pub_key(), + private_ips: IpPair { + ipv4: final_message + .gateway_client_ipv4() + .ok_or(Error::UnsupportedMessage)?, + ipv6: final_message + .gateway_client_ipv6() + .ok_or(Error::UnsupportedMessage)?, + }, + mac: ClientMac::new(final_message.gateway_client_mac()), + }, + credential: final_message + .credential() + .and_then(|c| c.credential.into_zk_nym()) + .map(|c| *c), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Query(query_message) => { + let (req, id) = AuthenticatorRequest::new_query_request(query_message.pub_key()); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::TopUp(top_up_message) => { + let (req, id) = AuthenticatorRequest::new_topup_request(TopUpMessage { + pub_key: top_up_message.pub_key(), + credential: top_up_message.credential(), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + } + } + + fn serialise_v6(&self) -> Result { + use v6::{ + registration::{ClientMac, FinalMessage, GatewayClient, InitMessage, IpPair}, + request::AuthenticatorRequest, + topup::TopUpMessage, + }; + match self { + ClientMessage::Initial(init_message) => { + let (req, id) = AuthenticatorRequest::new_initial_request(InitMessage { + pub_key: init_message.pub_key(), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Final(final_message) => { + let (req, id) = AuthenticatorRequest::new_final_request(FinalMessage { + gateway_client: GatewayClient { + pub_key: final_message.gateway_client_pub_key(), + private_ips: IpPair { + ipv4: final_message + .gateway_client_ipv4() + .ok_or(Error::UnsupportedMessage)?, + ipv6: final_message + .gateway_client_ipv6() + .ok_or(Error::UnsupportedMessage)?, + }, + mac: ClientMac::new(final_message.gateway_client_mac()), + }, + credential: final_message.credential(), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::Query(query_message) => { + let (req, id) = AuthenticatorRequest::new_query_request(query_message.pub_key()); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + ClientMessage::TopUp(top_up_message) => { + let (req, id) = AuthenticatorRequest::new_topup_request(TopUpMessage { + pub_key: top_up_message.pub_key(), + credential: top_up_message.credential(), + }); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } + } + } +} + impl ClientMessage { // check if message is wasteful e.g. contains a credential pub fn is_wasteful(&self) -> bool { @@ -40,205 +305,14 @@ impl ClientMessage { } } - pub fn bytes(&self, reply_to: Recipient) -> Result<(Vec, u64), Error> { + pub fn bytes(&self, reply_to: Recipient) -> Result { match self.version() { - AuthenticatorVersion::V1 => Err(Error::UnsupportedVersion), - AuthenticatorVersion::V2 => { - use v2::{ - registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, - request::AuthenticatorRequest, - }; - match self { - ClientMessage::Initial(init_message) => { - let (req, id) = AuthenticatorRequest::new_initial_request( - InitMessage { - pub_key: init_message.pub_key(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Final(final_message) => { - let (req, id) = AuthenticatorRequest::new_final_request( - FinalMessage { - gateway_client: GatewayClient { - pub_key: final_message.gateway_client_pub_key(), - private_ip: final_message - .gateway_client_ipv4() - .ok_or(Error::UnsupportedMessage)? - .into(), - mac: ClientMac::new(final_message.gateway_client_mac()), - }, - credential: final_message.credential(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Query(query_message) => { - let (req, id) = AuthenticatorRequest::new_query_request( - query_message.pub_key(), - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - _ => Err(Error::UnsupportedMessage), - } - } - AuthenticatorVersion::V3 => { - use v3::{ - registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, - request::AuthenticatorRequest, - topup::TopUpMessage, - }; - match self { - ClientMessage::Initial(init_message) => { - let (req, id) = AuthenticatorRequest::new_initial_request( - InitMessage { - pub_key: init_message.pub_key(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Final(final_message) => { - let (req, id) = AuthenticatorRequest::new_final_request( - FinalMessage { - gateway_client: GatewayClient { - pub_key: final_message.gateway_client_pub_key(), - private_ip: final_message - .gateway_client_ipv4() - .ok_or(Error::UnsupportedMessage)? - .into(), - mac: ClientMac::new(final_message.gateway_client_mac()), - }, - credential: final_message.credential(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Query(query_message) => { - let (req, id) = AuthenticatorRequest::new_query_request( - query_message.pub_key(), - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::TopUp(top_up_message) => { - let (req, id) = AuthenticatorRequest::new_topup_request( - TopUpMessage { - pub_key: top_up_message.pub_key(), - credential: top_up_message.credential(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - } - } - AuthenticatorVersion::V4 => { - use v4::{ - registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, - request::AuthenticatorRequest, - topup::TopUpMessage, - }; - match self { - ClientMessage::Initial(init_message) => { - let (req, id) = AuthenticatorRequest::new_initial_request( - InitMessage { - pub_key: init_message.pub_key(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Final(final_message) => { - let (req, id) = AuthenticatorRequest::new_final_request( - FinalMessage { - gateway_client: GatewayClient { - pub_key: final_message.gateway_client_pub_key(), - private_ips: IpPair { - ipv4: final_message - .gateway_client_ipv4() - .ok_or(Error::UnsupportedMessage)?, - ipv6: final_message - .gateway_client_ipv6() - .ok_or(Error::UnsupportedMessage)?, - } - .into(), - mac: ClientMac::new(final_message.gateway_client_mac()), - }, - credential: final_message.credential(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Query(query_message) => { - let (req, id) = AuthenticatorRequest::new_query_request( - query_message.pub_key(), - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - ClientMessage::TopUp(top_up_message) => { - let (req, id) = AuthenticatorRequest::new_topup_request( - TopUpMessage { - pub_key: top_up_message.pub_key(), - credential: top_up_message.credential(), - }, - reply_to, - ); - Ok((req.to_bytes()?, id)) - } - } - } - AuthenticatorVersion::V5 => { - use v5::{ - registration::{ClientMac, FinalMessage, GatewayClient, InitMessage}, - request::AuthenticatorRequest, - topup::TopUpMessage, - }; - match self { - ClientMessage::Initial(init_message) => { - let (req, id) = AuthenticatorRequest::new_initial_request(InitMessage { - pub_key: init_message.pub_key(), - }); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Final(final_message) => { - let (req, id) = AuthenticatorRequest::new_final_request(FinalMessage { - gateway_client: GatewayClient { - pub_key: final_message.gateway_client_pub_key(), - private_ips: IpPair { - ipv4: final_message - .gateway_client_ipv4() - .ok_or(Error::UnsupportedMessage)?, - ipv6: final_message - .gateway_client_ipv6() - .ok_or(Error::UnsupportedMessage)?, - }, - mac: ClientMac::new(final_message.gateway_client_mac()), - }, - credential: final_message.credential(), - }); - Ok((req.to_bytes()?, id)) - } - ClientMessage::Query(query_message) => { - let (req, id) = - AuthenticatorRequest::new_query_request(query_message.pub_key()); - Ok((req.to_bytes()?, id)) - } - ClientMessage::TopUp(top_up_message) => { - let (req, id) = AuthenticatorRequest::new_topup_request(TopUpMessage { - pub_key: top_up_message.pub_key(), - credential: top_up_message.credential(), - }); - Ok((req.to_bytes()?, id)) - } - } - } + AuthenticatorVersion::V1 => self.serialise_v1(), + AuthenticatorVersion::V2 => self.serialise_v2(reply_to), + AuthenticatorVersion::V3 => self.serialise_v3(reply_to), + AuthenticatorVersion::V4 => self.serialise_v4(reply_to), + AuthenticatorVersion::V5 => self.serialise_v5(), + AuthenticatorVersion::V6 => self.serialise_v6(), AuthenticatorVersion::UNKNOWN => Err(Error::UnknownVersion), } } @@ -247,7 +321,7 @@ impl ClientMessage { use AuthenticatorVersion::*; match self.version() { V1 | V2 | V3 | V4 => false, - V5 => true, + V5 | V6 => true, UNKNOWN => true, } } diff --git a/common/authenticator-requests/src/error.rs b/common/authenticator-requests/src/error.rs index d940bd538d..cbf0cde523 100644 --- a/common/authenticator-requests/src/error.rs +++ b/common/authenticator-requests/src/error.rs @@ -1,6 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use std::fmt::Display; use thiserror::Error; #[derive(Debug, Error)] @@ -37,3 +38,13 @@ pub enum Error { #[error(transparent)] Bincode(#[from] bincode::Error), } + +impl Error { + pub fn conversion(msg: impl Into) -> Self { + Error::Conversion(msg.into()) + } + + pub fn conversion_display(msg: impl Display) -> Self { + Error::Conversion(msg.to_string()) + } +} diff --git a/common/authenticator-requests/src/lib.rs b/common/authenticator-requests/src/lib.rs index b1b0159da3..226c78adee 100644 --- a/common/authenticator-requests/src/lib.rs +++ b/common/authenticator-requests/src/lib.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 pub mod client_message; +pub mod models; pub mod request; pub mod response; pub mod traits; @@ -10,13 +11,14 @@ pub mod v2; pub mod v3; pub mod v4; pub mod v5; +pub mod v6; mod error; mod util; mod version; pub use error::Error; -pub use v5 as latest; +pub use v6 as latest; pub use version::AuthenticatorVersion; pub const CURRENT_VERSION: u8 = latest::VERSION; diff --git a/common/authenticator-requests/src/models.rs b/common/authenticator-requests/src/models.rs new file mode 100644 index 0000000000..dc870df4d5 --- /dev/null +++ b/common/authenticator-requests/src/models.rs @@ -0,0 +1,52 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_credentials_interface::{ + BandwidthCredential, CredentialSpendingData, TicketType, UnknownTicketType, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum CurrentUpgradeModeStatus { + Enabled, + Disabled, + // everything pre-v6 + Unknown, +} + +impl From for CurrentUpgradeModeStatus { + fn from(value: bool) -> Self { + if value { + CurrentUpgradeModeStatus::Enabled + } else { + CurrentUpgradeModeStatus::Disabled + } + } +} + +impl From for Option { + fn from(value: CurrentUpgradeModeStatus) -> Self { + match value { + CurrentUpgradeModeStatus::Enabled => Some(true), + CurrentUpgradeModeStatus::Disabled => Some(false), + CurrentUpgradeModeStatus::Unknown => None, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct BandwidthClaim { + pub credential: BandwidthCredential, + pub kind: TicketType, +} + +impl TryFrom for BandwidthClaim { + type Error = UnknownTicketType; + + fn try_from(credential: CredentialSpendingData) -> Result { + Ok(BandwidthClaim { + kind: TicketType::try_from_encoded(credential.payment.t_type)?, + credential: BandwidthCredential::from(credential), + }) + } +} diff --git a/common/authenticator-requests/src/request.rs b/common/authenticator-requests/src/request.rs index 3d98a8ed5e..c1585b9bb6 100644 --- a/common/authenticator-requests/src/request.rs +++ b/common/authenticator-requests/src/request.rs @@ -4,8 +4,10 @@ use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; use nym_sphinx::addressing::Recipient; -use crate::traits::{FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage}; -use crate::{v1, v2, v3, v4, v5}; +use crate::traits::{ + FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage, UpgradeModeMessage, +}; +use crate::{v1, v2, v3, v4, v5, v6}; #[derive(Debug)] pub enum AuthenticatorRequest { @@ -33,6 +35,11 @@ pub enum AuthenticatorRequest { reply_to: Option, request_id: u64, }, + CheckUpgradeMode { + msg: Box, + protocol: Protocol, + request_id: u64, + }, } impl From for AuthenticatorRequest { @@ -202,3 +209,45 @@ impl From for AuthenticatorRequest { } } } + +impl From for AuthenticatorRequest { + fn from(value: v6::request::AuthenticatorRequest) -> Self { + match value.data { + v6::request::AuthenticatorRequestData::Initial(init_message) => Self::Initial { + msg: Box::new(init_message), + protocol: value.protocol, + reply_to: None, + request_id: value.request_id, + }, + v6::request::AuthenticatorRequestData::Final(final_message) => Self::Final { + msg: final_message, + protocol: value.protocol, + reply_to: None, + request_id: value.request_id, + }, + v6::request::AuthenticatorRequestData::QueryBandwidth(peer_public_key) => { + Self::QueryBandwidth { + msg: Box::new(peer_public_key), + protocol: value.protocol, + reply_to: None, + request_id: value.request_id, + } + } + v6::request::AuthenticatorRequestData::TopUpBandwidth(top_up_message) => { + Self::TopUpBandwidth { + msg: top_up_message, + protocol: value.protocol, + reply_to: None, + request_id: value.request_id, + } + } + v6::request::AuthenticatorRequestData::CheckUpgradeMode(upgrade_mode_check_msg) => { + Self::CheckUpgradeMode { + msg: Box::new(upgrade_mode_check_msg), + protocol: value.protocol, + request_id: value.request_id, + } + } + } + } +} diff --git a/common/authenticator-requests/src/response.rs b/common/authenticator-requests/src/response.rs index 8e196a7b72..84b6ae8526 100644 --- a/common/authenticator-requests/src/response.rs +++ b/common/authenticator-requests/src/response.rs @@ -1,11 +1,12 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::models::CurrentUpgradeModeStatus; use crate::traits::{ Id, PendingRegistrationResponse, RegisteredResponse, RemainingBandwidthResponse, - TopUpBandwidthResponse, + TopUpBandwidthResponse, UpgradeModeStatus, }; -use crate::{v2, v3, v4, v5}; +use crate::{v2, v3, v4, v5, v6}; #[derive(Debug)] pub enum AuthenticatorResponse { @@ -13,6 +14,29 @@ pub enum AuthenticatorResponse { Registered(Box), RemainingBandwidth(Box), TopUpBandwidth(Box), + UpgradeMode(Box), +} + +impl UpgradeModeStatus for AuthenticatorResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + match self { + AuthenticatorResponse::PendingRegistration(pending_registration_response) => { + pending_registration_response.upgrade_mode_status() + } + AuthenticatorResponse::Registered(registered_response) => { + registered_response.upgrade_mode_status() + } + AuthenticatorResponse::RemainingBandwidth(remaining_bandwidth_response) => { + remaining_bandwidth_response.upgrade_mode_status() + } + AuthenticatorResponse::TopUpBandwidth(top_up_bandwidth_response) => { + top_up_bandwidth_response.upgrade_mode_status() + } + AuthenticatorResponse::UpgradeMode(upgrade_mode_response) => { + upgrade_mode_response.upgrade_mode_status() + } + } + } } impl Id for AuthenticatorResponse { @@ -28,6 +52,7 @@ impl Id for AuthenticatorResponse { AuthenticatorResponse::TopUpBandwidth(top_up_bandwidth_response) => { top_up_bandwidth_response.id() } + AuthenticatorResponse::UpgradeMode(upgrade_mode_response) => upgrade_mode_response.id(), } } } @@ -104,3 +129,25 @@ impl From for AuthenticatorResponse { } } } + +impl From for AuthenticatorResponse { + fn from(value: v6::response::AuthenticatorResponse) -> Self { + match value.data { + v6::response::AuthenticatorResponseData::PendingRegistration( + pending_registration_response, + ) => Self::PendingRegistration(Box::new(pending_registration_response)), + v6::response::AuthenticatorResponseData::Registered(registered_response) => { + Self::Registered(Box::new(registered_response)) + } + v6::response::AuthenticatorResponseData::RemainingBandwidth( + remaining_bandwidth_response, + ) => Self::RemainingBandwidth(Box::new(remaining_bandwidth_response)), + v6::response::AuthenticatorResponseData::TopUpBandwidth(top_up_bandwidth_response) => { + Self::TopUpBandwidth(Box::new(top_up_bandwidth_response)) + } + v6::response::AuthenticatorResponseData::UpgradeMode(upgrade_mode_check_response) => { + Self::UpgradeMode(Box::new(upgrade_mode_check_response)) + } + } + } +} diff --git a/common/authenticator-requests/src/traits.rs b/common/authenticator-requests/src/traits.rs index 36e999383d..e16c0d96ba 100644 --- a/common/authenticator-requests/src/traits.rs +++ b/common/authenticator-requests/src/traits.rs @@ -1,15 +1,15 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::latest::registration::IpPair; +use crate::models::{BandwidthClaim, CurrentUpgradeModeStatus}; +use crate::{AuthenticatorVersion, Error, v1, v2, v3, v4, v5, v6}; +use nym_credentials_interface::CredentialSpendingData; +use nym_crypto::asymmetric::x25519; +use nym_wireguard_types::PeerPublicKey; use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr}; - -use nym_credentials_interface::CredentialSpendingData; -use nym_crypto::asymmetric::x25519::PrivateKey; -use nym_wireguard_types::PeerPublicKey; - -use crate::latest::registration::IpPair; -use crate::{AuthenticatorVersion, Error, v1, v2, v3, v4, v5}; +use tracing::error; pub trait Versionable { fn version(&self) -> AuthenticatorVersion; @@ -51,6 +51,12 @@ impl Versionable for v5::registration::InitMessage { } } +impl Versionable for v6::registration::InitMessage { + fn version(&self) -> AuthenticatorVersion { + AuthenticatorVersion::V6 + } +} + impl Versionable for v2::registration::FinalMessage { fn version(&self) -> AuthenticatorVersion { AuthenticatorVersion::V2 @@ -75,6 +81,12 @@ impl Versionable for v5::registration::FinalMessage { } } +impl Versionable for v6::registration::FinalMessage { + fn version(&self) -> AuthenticatorVersion { + AuthenticatorVersion::V6 + } +} + impl Versionable for PeerPublicKey { fn version(&self) -> AuthenticatorVersion { AuthenticatorVersion::V3 @@ -98,6 +110,158 @@ impl Versionable for v5::topup::TopUpMessage { AuthenticatorVersion::V5 } } +impl Versionable for v6::topup::TopUpMessage { + fn version(&self) -> AuthenticatorVersion { + AuthenticatorVersion::V6 + } +} + +impl Versionable for v6::upgrade_mode_check::UpgradeModeCheckRequest { + fn version(&self) -> AuthenticatorVersion { + AuthenticatorVersion::V6 + } +} + +pub trait UpgradeModeStatus: Id + fmt::Debug { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus; +} + +impl UpgradeModeStatus for v1::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v1::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v1::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v2::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v2::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v2::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} +impl UpgradeModeStatus for v3::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v3::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v3::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v3::response::TopUpBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v4::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v4::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v4::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v4::response::TopUpBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v5::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v5::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v5::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v5::response::TopUpBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + CurrentUpgradeModeStatus::Unknown + } +} + +impl UpgradeModeStatus for v6::response::PendingRegistrationResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + self.upgrade_mode_enabled.into() + } +} + +impl UpgradeModeStatus for v6::response::RegisteredResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + self.upgrade_mode_enabled.into() + } +} + +impl UpgradeModeStatus for v6::response::RemainingBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + self.upgrade_mode_enabled.into() + } +} + +impl UpgradeModeStatus for v6::response::TopUpBandwidthResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + self.upgrade_mode_enabled.into() + } +} + +impl UpgradeModeStatus for v6::response::UpgradeModeResponse { + fn upgrade_mode_status(&self) -> CurrentUpgradeModeStatus { + self.upgrade_mode_enabled.into() + } +} pub trait InitMessage: Versionable + fmt::Debug { fn pub_key(&self) -> PeerPublicKey; @@ -133,14 +297,20 @@ impl InitMessage for v5::registration::InitMessage { } } +impl InitMessage for v6::registration::InitMessage { + fn pub_key(&self) -> PeerPublicKey { + self.pub_key + } +} + pub trait FinalMessage: Versionable + fmt::Debug { fn gateway_client_pub_key(&self) -> PeerPublicKey; - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error>; + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error>; fn private_ips(&self) -> IpPair; fn gateway_client_ipv4(&self) -> Option; fn gateway_client_ipv6(&self) -> Option; fn gateway_client_mac(&self) -> Vec; - fn credential(&self) -> Option; + fn credential(&self) -> Option; } impl FinalMessage for v1::GatewayClient { @@ -148,7 +318,7 @@ impl FinalMessage for v1::GatewayClient { self.pub_key } - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { self.verify(private_key, nonce) } @@ -171,7 +341,7 @@ impl FinalMessage for v1::GatewayClient { self.mac.to_vec() } - fn credential(&self) -> Option { + fn credential(&self) -> Option { None } } @@ -181,7 +351,7 @@ impl FinalMessage for v2::registration::FinalMessage { self.gateway_client.pub_key } - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { self.gateway_client.verify(private_key, nonce) } @@ -204,8 +374,12 @@ impl FinalMessage for v2::registration::FinalMessage { self.gateway_client.mac.to_vec() } - fn credential(&self) -> Option { - self.credential.clone() + fn credential(&self) -> Option { + self.credential.clone().and_then(|c| { + c.try_into() + .inspect_err(|err| error!("credential conversion error: {err}")) + .ok() + }) } } @@ -214,7 +388,7 @@ impl FinalMessage for v3::registration::FinalMessage { self.gateway_client.pub_key } - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { self.gateway_client.verify(private_key, nonce) } @@ -237,8 +411,12 @@ impl FinalMessage for v3::registration::FinalMessage { self.gateway_client.mac.to_vec() } - fn credential(&self) -> Option { - self.credential.clone() + fn credential(&self) -> Option { + self.credential.clone().and_then(|c| { + c.try_into() + .inspect_err(|err| error!("credential conversion error: {err}")) + .ok() + }) } } @@ -247,7 +425,42 @@ impl FinalMessage for v4::registration::FinalMessage { self.gateway_client.pub_key } - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { + self.gateway_client.verify(private_key, nonce) + } + + fn private_ips(&self) -> IpPair { + // v4 -> v5 -> v6 + v5::registration::IpPair::from(self.gateway_client.private_ips).into() + } + + fn gateway_client_ipv4(&self) -> Option { + Some(self.gateway_client.private_ips.ipv4) + } + + fn gateway_client_ipv6(&self) -> Option { + Some(self.gateway_client.private_ips.ipv6) + } + + fn gateway_client_mac(&self) -> Vec { + self.gateway_client.mac.to_vec() + } + + fn credential(&self) -> Option { + self.credential.clone().and_then(|c| { + c.try_into() + .inspect_err(|err| error!("credential conversion error: {err}")) + .ok() + }) + } +} + +impl FinalMessage for v5::registration::FinalMessage { + fn gateway_client_pub_key(&self) -> PeerPublicKey { + self.gateway_client.pub_key + } + + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { self.gateway_client.verify(private_key, nonce) } @@ -267,17 +480,21 @@ impl FinalMessage for v4::registration::FinalMessage { self.gateway_client.mac.to_vec() } - fn credential(&self) -> Option { - self.credential.clone() + fn credential(&self) -> Option { + self.credential.clone().and_then(|c| { + c.try_into() + .inspect_err(|err| error!("credential conversion error: {err}")) + .ok() + }) } } -impl FinalMessage for v5::registration::FinalMessage { +impl FinalMessage for v6::registration::FinalMessage { fn gateway_client_pub_key(&self) -> PeerPublicKey { self.gateway_client.pub_key } - fn verify(&self, private_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + fn verify(&self, private_key: &x25519::PrivateKey, nonce: u64) -> Result<(), Error> { self.gateway_client.verify(private_key, nonce) } @@ -297,7 +514,7 @@ impl FinalMessage for v5::registration::FinalMessage { self.gateway_client.mac.to_vec() } - fn credential(&self) -> Option { + fn credential(&self) -> Option { self.credential.clone() } } @@ -347,10 +564,42 @@ impl TopUpMessage for v5::topup::TopUpMessage { } } +impl TopUpMessage for v6::topup::TopUpMessage { + fn pub_key(&self) -> PeerPublicKey { + self.pub_key + } + + fn credential(&self) -> CredentialSpendingData { + self.credential.clone() + } +} + +pub trait UpgradeModeMessage: Versionable + fmt::Debug { + // the idea is to expose different types of emergency credentials here, + // like upgrade mode JWT, emergency threshold credential issued by signers, etc. + fn upgrade_mode_global_attestation_jwt(&self) -> Option; +} + +impl UpgradeModeMessage for v6::upgrade_mode_check::UpgradeModeCheckRequest { + fn upgrade_mode_global_attestation_jwt(&self) -> Option { + use v6::upgrade_mode_check::UpgradeModeCheckRequest; + + match self { + UpgradeModeCheckRequest::UpgradeModeJwt { token } => Some(token.clone()), + } + } +} + pub trait Id { fn id(&self) -> u64; } +impl Id for v1::response::PendingRegistrationResponse { + fn id(&self) -> u64 { + self.request_id + } +} + impl Id for v2::response::PendingRegistrationResponse { fn id(&self) -> u64 { self.request_id @@ -375,6 +624,18 @@ impl Id for v5::response::PendingRegistrationResponse { } } +impl Id for v6::response::PendingRegistrationResponse { + fn id(&self) -> u64 { + self.request_id + } +} + +impl Id for v1::response::RegisteredResponse { + fn id(&self) -> u64 { + self.request_id + } +} + impl Id for v2::response::RegisteredResponse { fn id(&self) -> u64 { self.request_id @@ -399,6 +660,18 @@ impl Id for v5::response::RegisteredResponse { } } +impl Id for v6::response::RegisteredResponse { + fn id(&self) -> u64 { + self.request_id + } +} + +impl Id for v1::response::RemainingBandwidthResponse { + fn id(&self) -> u64 { + self.request_id + } +} + impl Id for v2::response::RemainingBandwidthResponse { fn id(&self) -> u64 { self.request_id @@ -423,6 +696,12 @@ impl Id for v5::response::RemainingBandwidthResponse { } } +impl Id for v6::response::RemainingBandwidthResponse { + fn id(&self) -> u64 { + self.request_id + } +} + impl Id for v3::response::TopUpBandwidthResponse { fn id(&self) -> u64 { self.request_id @@ -441,11 +720,28 @@ impl Id for v5::response::TopUpBandwidthResponse { } } -pub trait PendingRegistrationResponse: Id + fmt::Debug { +impl Id for v6::response::TopUpBandwidthResponse { + fn id(&self) -> u64 { + self.request_id + } +} + +impl Id for v6::response::UpgradeModeResponse { + fn id(&self) -> u64 { + self.request_id + } +} + +pub trait PendingRegistrationResponse: Id + UpgradeModeStatus + fmt::Debug { fn nonce(&self) -> u64; - fn verify(&self, gateway_key: &PrivateKey) -> std::result::Result<(), Error>; + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error>; fn pub_key(&self) -> PeerPublicKey; fn private_ips(&self) -> IpPair; + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box; } impl PendingRegistrationResponse for v2::response::PendingRegistrationResponse { @@ -453,7 +749,7 @@ impl PendingRegistrationResponse for v2::response::PendingRegistrationResponse { self.reply.nonce } - fn verify(&self, gateway_key: &PrivateKey) -> std::result::Result<(), Error> { + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error> { self.reply.gateway_data.verify(gateway_key, self.nonce()) } @@ -464,6 +760,22 @@ impl PendingRegistrationResponse for v2::response::PendingRegistrationResponse { fn private_ips(&self) -> IpPair { self.reply.gateway_data.private_ip.into() } + + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box { + Box::new(v2::registration::FinalMessage { + gateway_client: v2::registration::GatewayClient::new( + private_key, + self.pub_key().inner(), + self.private_ips().ipv4.into(), + self.nonce(), + ), + credential: credential.and_then(|b| b.credential.into_zk_nym().map(|c| *c)), + }) + } } impl PendingRegistrationResponse for v3::response::PendingRegistrationResponse { @@ -471,7 +783,7 @@ impl PendingRegistrationResponse for v3::response::PendingRegistrationResponse { self.reply.nonce } - fn verify(&self, gateway_key: &PrivateKey) -> std::result::Result<(), Error> { + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error> { self.reply.gateway_data.verify(gateway_key, self.nonce()) } @@ -482,6 +794,22 @@ impl PendingRegistrationResponse for v3::response::PendingRegistrationResponse { fn private_ips(&self) -> IpPair { self.reply.gateway_data.private_ip.into() } + + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box { + Box::new(v3::registration::FinalMessage { + gateway_client: v3::registration::GatewayClient::new( + private_key, + self.pub_key().inner(), + self.private_ips().ipv4.into(), + self.nonce(), + ), + credential: credential.and_then(|b| b.credential.into_zk_nym().map(|c| *c)), + }) + } } impl PendingRegistrationResponse for v4::response::PendingRegistrationResponse { @@ -489,7 +817,42 @@ impl PendingRegistrationResponse for v4::response::PendingRegistrationResponse { self.reply.nonce } - fn verify(&self, gateway_key: &PrivateKey) -> std::result::Result<(), Error> { + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error> { + self.reply.gateway_data.verify(gateway_key, self.nonce()) + } + + fn pub_key(&self) -> PeerPublicKey { + self.reply.gateway_data.pub_key + } + + fn private_ips(&self) -> IpPair { + // v4 -> v5 -> v6 + v5::registration::IpPair::from(self.reply.gateway_data.private_ips).into() + } + + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box { + Box::new(v4::registration::FinalMessage { + gateway_client: v4::registration::GatewayClient::new( + private_key, + self.pub_key().inner(), + self.reply.gateway_data.private_ips, + self.nonce(), + ), + credential: credential.and_then(|b| b.credential.into_zk_nym().map(|c| *c)), + }) + } +} + +impl PendingRegistrationResponse for v5::response::PendingRegistrationResponse { + fn nonce(&self) -> u64 { + self.reply.nonce + } + + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error> { self.reply.gateway_data.verify(gateway_key, self.nonce()) } @@ -500,14 +863,30 @@ impl PendingRegistrationResponse for v4::response::PendingRegistrationResponse { fn private_ips(&self) -> IpPair { self.reply.gateway_data.private_ips.into() } + + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box { + Box::new(v5::registration::FinalMessage { + gateway_client: v5::registration::GatewayClient::new( + private_key, + self.pub_key().inner(), + self.reply.gateway_data.private_ips, + self.nonce(), + ), + credential: credential.and_then(|b| b.credential.into_zk_nym().map(|c| *c)), + }) + } } -impl PendingRegistrationResponse for v5::response::PendingRegistrationResponse { +impl PendingRegistrationResponse for v6::response::PendingRegistrationResponse { fn nonce(&self) -> u64 { self.reply.nonce } - fn verify(&self, gateway_key: &PrivateKey) -> std::result::Result<(), Error> { + fn verify(&self, gateway_key: &x25519::PrivateKey) -> Result<(), Error> { self.reply.gateway_data.verify(gateway_key, self.nonce()) } @@ -518,9 +897,25 @@ impl PendingRegistrationResponse for v5::response::PendingRegistrationResponse { fn private_ips(&self) -> IpPair { self.reply.gateway_data.private_ips } + + fn finalise_registration( + &self, + private_key: &x25519::PrivateKey, + credential: Option, + ) -> Box { + Box::new(v6::registration::FinalMessage { + gateway_client: v6::registration::GatewayClient::new( + private_key, + self.pub_key().inner(), + self.reply.gateway_data.private_ips, + self.nonce(), + ), + credential, + }) + } } -pub trait RegisteredResponse: Id + fmt::Debug { +pub trait RegisteredResponse: Id + UpgradeModeStatus + fmt::Debug { fn private_ips(&self) -> IpPair; fn pub_key(&self) -> PeerPublicKey; fn wg_port(&self) -> u16; @@ -555,7 +950,8 @@ impl RegisteredResponse for v3::response::RegisteredResponse { } impl RegisteredResponse for v4::response::RegisteredResponse { fn private_ips(&self) -> IpPair { - self.reply.private_ips.into() + // v4 -> v5 -> v6 + v5::registration::IpPair::from(self.reply.private_ips).into() } fn pub_key(&self) -> PeerPublicKey { @@ -568,6 +964,20 @@ impl RegisteredResponse for v4::response::RegisteredResponse { } impl RegisteredResponse for v5::response::RegisteredResponse { + fn private_ips(&self) -> IpPair { + self.reply.private_ips.into() + } + + fn pub_key(&self) -> PeerPublicKey { + self.reply.pub_key + } + + fn wg_port(&self) -> u16 { + self.reply.wg_port + } +} + +impl RegisteredResponse for v6::response::RegisteredResponse { fn private_ips(&self) -> IpPair { self.reply.private_ips } @@ -581,7 +991,7 @@ impl RegisteredResponse for v5::response::RegisteredResponse { } } -pub trait RemainingBandwidthResponse: Id + fmt::Debug { +pub trait RemainingBandwidthResponse: Id + UpgradeModeStatus + fmt::Debug { fn available_bandwidth(&self) -> Option; } @@ -609,7 +1019,13 @@ impl RemainingBandwidthResponse for v5::response::RemainingBandwidthResponse { } } -pub trait TopUpBandwidthResponse: Id + fmt::Debug { +impl RemainingBandwidthResponse for v6::response::RemainingBandwidthResponse { + fn available_bandwidth(&self) -> Option { + self.reply.as_ref().map(|r| r.available_bandwidth) + } +} + +pub trait TopUpBandwidthResponse: Id + UpgradeModeStatus + fmt::Debug { fn available_bandwidth(&self) -> i64; } @@ -630,3 +1046,9 @@ impl TopUpBandwidthResponse for v5::response::TopUpBandwidthResponse { self.reply.available_bandwidth } } + +impl TopUpBandwidthResponse for v6::response::TopUpBandwidthResponse { + fn available_bandwidth(&self) -> i64 { + self.reply.available_bandwidth + } +} diff --git a/common/authenticator-requests/src/v1/registration.rs b/common/authenticator-requests/src/v1/registration.rs index 6807325921..aa79910972 100644 --- a/common/authenticator-requests/src/v1/registration.rs +++ b/common/authenticator-requests/src/v1/registration.rs @@ -48,7 +48,7 @@ pub struct RegistrationData { } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct RegistredData { +pub struct RegisteredData { pub pub_key: PeerPublicKey, pub private_ip: IpAddr, pub wg_port: u16, diff --git a/common/authenticator-requests/src/v1/response.rs b/common/authenticator-requests/src/v1/response.rs index 4e7ba61eb4..f4ff8f5da3 100644 --- a/common/authenticator-requests/src/v1/response.rs +++ b/common/authenticator-requests/src/v1/response.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::registration::{RegistrationData, RegistredData, RemainingBandwidthData}; +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; use nym_sphinx::addressing::Recipient; use serde::{Deserialize, Serialize}; @@ -34,7 +34,7 @@ impl AuthenticatorResponse { } pub fn new_registered( - registred_data: RegistredData, + registred_data: RegisteredData, reply_to: Recipient, request_id: u64, ) -> Self { @@ -108,7 +108,7 @@ pub struct PendingRegistrationResponse { pub struct RegisteredResponse { pub request_id: u64, pub reply_to: Recipient, - pub reply: RegistredData, + pub reply: RegisteredData, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/common/authenticator-requests/src/v2/conversion.rs b/common/authenticator-requests/src/v2/conversion.rs index b8e16ac4ba..36dceaf3ff 100644 --- a/common/authenticator-requests/src/v2/conversion.rs +++ b/common/authenticator-requests/src/v2/conversion.rs @@ -154,8 +154,8 @@ impl From for v1::registration::Registration } } -impl From for v1::registration::RegistredData { - fn from(value: v2::registration::RegistredData) -> Self { +impl From for v1::registration::RegisteredData { + fn from(value: v2::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ip: value.private_ip, diff --git a/common/authenticator-requests/src/v2/registration.rs b/common/authenticator-requests/src/v2/registration.rs index a8d5f5e089..34d3b7f4e0 100644 --- a/common/authenticator-requests/src/v2/registration.rs +++ b/common/authenticator-requests/src/v2/registration.rs @@ -58,7 +58,7 @@ pub struct RegistrationData { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct RegistredData { +pub struct RegisteredData { pub pub_key: PeerPublicKey, pub private_ip: IpAddr, pub wg_port: u16, diff --git a/common/authenticator-requests/src/v2/response.rs b/common/authenticator-requests/src/v2/response.rs index 1b389de43f..33da1b975d 100644 --- a/common/authenticator-requests/src/v2/response.rs +++ b/common/authenticator-requests/src/v2/response.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::registration::{RegistrationData, RegistredData, RemainingBandwidthData}; +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; use nym_sphinx::addressing::Recipient; use serde::{Deserialize, Serialize}; @@ -38,7 +38,7 @@ impl AuthenticatorResponse { } pub fn new_registered( - registred_data: RegistredData, + registred_data: RegisteredData, reply_to: Recipient, request_id: u64, ) -> Self { @@ -118,7 +118,7 @@ pub struct PendingRegistrationResponse { pub struct RegisteredResponse { pub request_id: u64, pub reply_to: Recipient, - pub reply: RegistredData, + pub reply: RegisteredData, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/common/authenticator-requests/src/v3/conversion.rs b/common/authenticator-requests/src/v3/conversion.rs index 5a49c771cd..d04bb6134f 100644 --- a/common/authenticator-requests/src/v3/conversion.rs +++ b/common/authenticator-requests/src/v3/conversion.rs @@ -299,8 +299,8 @@ impl From for v3::registration::Registration } } -impl From for v2::registration::RegistredData { - fn from(value: v3::registration::RegistredData) -> Self { +impl From for v2::registration::RegisteredData { + fn from(value: v3::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ip: value.private_ip, @@ -309,8 +309,8 @@ impl From for v2::registration::RegistredData { } } -impl From for v3::registration::RegistredData { - fn from(value: v2::registration::RegistredData) -> Self { +impl From for v3::registration::RegisteredData { + fn from(value: v2::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ip: value.private_ip, @@ -674,7 +674,7 @@ mod tests { let pub_key = PeerPublicKey::new(PublicKey::from([0; 32])); let private_ip = IpAddr::from_str("10.10.10.10").unwrap(); let wg_port = 51822; - let registred_data = v2::registration::RegistredData { + let registred_data = v2::registration::RegisteredData { pub_key, private_ip, wg_port, @@ -701,7 +701,7 @@ mod tests { v3::response::AuthenticatorResponseData::Registered(v3::response::RegisteredResponse { request_id, reply_to, - reply: v3::registration::RegistredData { + reply: v3::registration::RegisteredData { wg_port, pub_key, private_ip @@ -715,7 +715,7 @@ mod tests { let pub_key = PeerPublicKey::new(PublicKey::from([0; 32])); let private_ip = IpAddr::from_str("10.10.10.10").unwrap(); let wg_port = 51822; - let registred_data = v3::registration::RegistredData { + let registred_data = v3::registration::RegisteredData { pub_key, private_ip, wg_port, @@ -742,7 +742,7 @@ mod tests { v2::response::AuthenticatorResponseData::Registered(v2::response::RegisteredResponse { request_id, reply_to, - reply: v2::registration::RegistredData { + reply: v2::registration::RegisteredData { wg_port, pub_key, private_ip diff --git a/common/authenticator-requests/src/v3/registration.rs b/common/authenticator-requests/src/v3/registration.rs index 00cb146772..66b02c5ed2 100644 --- a/common/authenticator-requests/src/v3/registration.rs +++ b/common/authenticator-requests/src/v3/registration.rs @@ -58,7 +58,7 @@ pub struct RegistrationData { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct RegistredData { +pub struct RegisteredData { pub pub_key: PeerPublicKey, pub private_ip: IpAddr, pub wg_port: u16, diff --git a/common/authenticator-requests/src/v3/response.rs b/common/authenticator-requests/src/v3/response.rs index ca44fb19f6..4fd0a9729b 100644 --- a/common/authenticator-requests/src/v3/response.rs +++ b/common/authenticator-requests/src/v3/response.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::registration::{RegistrationData, RegistredData, RemainingBandwidthData}; +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; use nym_sphinx::addressing::Recipient; use serde::{Deserialize, Serialize}; @@ -38,7 +38,7 @@ impl AuthenticatorResponse { } pub fn new_registered( - registred_data: RegistredData, + registred_data: RegisteredData, reply_to: Recipient, request_id: u64, ) -> Self { @@ -139,7 +139,7 @@ pub struct PendingRegistrationResponse { pub struct RegisteredResponse { pub request_id: u64, pub reply_to: Recipient, - pub reply: RegistredData, + pub reply: RegisteredData, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/common/authenticator-requests/src/v4/conversion.rs b/common/authenticator-requests/src/v4/conversion.rs index 8731222446..3b2cf9a8f2 100644 --- a/common/authenticator-requests/src/v4/conversion.rs +++ b/common/authenticator-requests/src/v4/conversion.rs @@ -262,8 +262,8 @@ impl From for v3::response::TopUpBandwidth } } -impl From for v4::registration::RegistredData { - fn from(value: v3::registration::RegistredData) -> Self { +impl From for v4::registration::RegisteredData { + fn from(value: v3::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ips: value.private_ip.into(), @@ -272,8 +272,8 @@ impl From for v4::registration::RegistredData { } } -impl From for v3::registration::RegistredData { - fn from(value: v4::registration::RegistredData) -> Self { +impl From for v3::registration::RegisteredData { + fn from(value: v4::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ip: value.private_ips.ipv4.into(), @@ -565,7 +565,7 @@ mod tests { let private_ips = v4::registration::IpPair::new(ipv4, Ipv6Addr::from_str("fc01::a0a").unwrap()); let wg_port = 51822; - let registred_data = v3::registration::RegistredData { + let registred_data = v3::registration::RegisteredData { pub_key, private_ip: ipv4.into(), wg_port, @@ -592,7 +592,7 @@ mod tests { v4::response::AuthenticatorResponseData::Registered(v4::response::RegisteredResponse { request_id, reply_to, - reply: v4::registration::RegistredData { + reply: v4::registration::RegisteredData { wg_port, pub_key, private_ips @@ -608,7 +608,7 @@ mod tests { let private_ips = v4::registration::IpPair::new(ipv4, Ipv6Addr::from_str("fc01::10").unwrap()); let wg_port = 51822; - let registred_data = v4::registration::RegistredData { + let registred_data = v4::registration::RegisteredData { pub_key, private_ips, wg_port, @@ -635,7 +635,7 @@ mod tests { v3::response::AuthenticatorResponseData::Registered(v3::response::RegisteredResponse { request_id, reply_to, - reply: v3::registration::RegistredData { + reply: v3::registration::RegisteredData { wg_port, pub_key, private_ip: ipv4.into() diff --git a/common/authenticator-requests/src/v4/registration.rs b/common/authenticator-requests/src/v4/registration.rs index a383b79beb..b1ee074dfd 100644 --- a/common/authenticator-requests/src/v4/registration.rs +++ b/common/authenticator-requests/src/v4/registration.rs @@ -110,7 +110,7 @@ pub struct RegistrationData { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct RegistredData { +pub struct RegisteredData { pub pub_key: PeerPublicKey, pub private_ips: IpPair, pub wg_port: u16, diff --git a/common/authenticator-requests/src/v4/response.rs b/common/authenticator-requests/src/v4/response.rs index 9743e8db43..1bbf4557e9 100644 --- a/common/authenticator-requests/src/v4/response.rs +++ b/common/authenticator-requests/src/v4/response.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::registration::{RegistrationData, RegistredData, RemainingBandwidthData}; +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; use nym_sphinx::addressing::Recipient; use serde::{Deserialize, Serialize}; @@ -38,7 +38,7 @@ impl AuthenticatorResponse { } pub fn new_registered( - registred_data: RegistredData, + registred_data: RegisteredData, reply_to: Recipient, request_id: u64, ) -> Self { @@ -139,7 +139,7 @@ pub struct PendingRegistrationResponse { pub struct RegisteredResponse { pub request_id: u64, pub reply_to: Recipient, - pub reply: RegistredData, + pub reply: RegisteredData, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/common/authenticator-requests/src/v5/conversion.rs b/common/authenticator-requests/src/v5/conversion.rs index 77ed294323..f2287c03bd 100644 --- a/common/authenticator-requests/src/v5/conversion.rs +++ b/common/authenticator-requests/src/v5/conversion.rs @@ -186,8 +186,8 @@ impl From for v5::response::TopUpBandwidth } } -impl From for v5::registration::RegistredData { - fn from(value: v4::registration::RegistredData) -> Self { +impl From for v5::registration::RegisteredData { + fn from(value: v4::registration::RegisteredData) -> Self { Self { pub_key: value.pub_key, private_ips: value.private_ips.into(), @@ -405,7 +405,7 @@ mod tests { let ipv6 = Ipv6Addr::from_str("fc01::a0a").unwrap(); let private_ips = v4::registration::IpPair::new(ipv4, ipv6); let wg_port = 51822; - let registred_data = v4::registration::RegistredData { + let registred_data = v4::registration::RegisteredData { pub_key, private_ips, wg_port, @@ -431,7 +431,7 @@ mod tests { upgraded_msg.data, v5::response::AuthenticatorResponseData::Registered(v5::response::RegisteredResponse { request_id, - reply: v5::registration::RegistredData { + reply: v5::registration::RegisteredData { wg_port, pub_key, private_ips: v5::registration::IpPair::new(ipv4, ipv6) diff --git a/common/authenticator-requests/src/v5/registration.rs b/common/authenticator-requests/src/v5/registration.rs index 151401da97..5154400f93 100644 --- a/common/authenticator-requests/src/v5/registration.rs +++ b/common/authenticator-requests/src/v5/registration.rs @@ -108,7 +108,7 @@ pub struct RegistrationData { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct RegistredData { +pub struct RegisteredData { pub pub_key: PeerPublicKey, pub private_ips: IpPair, pub wg_port: u16, diff --git a/common/authenticator-requests/src/v5/response.rs b/common/authenticator-requests/src/v5/response.rs index 044b803d0d..b26fcf4627 100644 --- a/common/authenticator-requests/src/v5/response.rs +++ b/common/authenticator-requests/src/v5/response.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::registration::{RegistrationData, RegistredData, RemainingBandwidthData}; +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; use serde::{Deserialize, Serialize}; @@ -32,7 +32,7 @@ impl AuthenticatorResponse { } } - pub fn new_registered(registred_data: RegistredData, request_id: u64) -> Self { + pub fn new_registered(registred_data: RegisteredData, request_id: u64) -> Self { Self { protocol: Protocol { service_provider_type: ServiceProviderType::Authenticator, @@ -116,7 +116,7 @@ pub struct PendingRegistrationResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct RegisteredResponse { pub request_id: u64, - pub reply: RegistredData, + pub reply: RegisteredData, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/common/authenticator-requests/src/v6/conversion.rs b/common/authenticator-requests/src/v6/conversion.rs new file mode 100644 index 0000000000..8bcc204d7c --- /dev/null +++ b/common/authenticator-requests/src/v6/conversion.rs @@ -0,0 +1,441 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{v5, v6}; + +impl TryFrom for v6::request::AuthenticatorRequest { + type Error = crate::Error; + + fn try_from( + authenticator_request: v5::request::AuthenticatorRequest, + ) -> Result { + Ok(Self { + protocol: v6::PROTOCOL, + data: authenticator_request.data.try_into()?, + request_id: authenticator_request.request_id, + }) + } +} + +impl TryFrom for v6::request::AuthenticatorRequestData { + type Error = crate::Error; + + fn try_from( + authenticator_request_data: v5::request::AuthenticatorRequestData, + ) -> Result { + match authenticator_request_data { + v5::request::AuthenticatorRequestData::Initial(init_msg) => Ok( + v6::request::AuthenticatorRequestData::Initial(init_msg.into()), + ), + v5::request::AuthenticatorRequestData::Final(final_msg) => Ok( + v6::request::AuthenticatorRequestData::Final(Box::new((*final_msg).try_into()?)), + ), + v5::request::AuthenticatorRequestData::QueryBandwidth(pub_key) => Ok( + v6::request::AuthenticatorRequestData::QueryBandwidth(pub_key), + ), + v5::request::AuthenticatorRequestData::TopUpBandwidth(top_up_message) => Ok( + v6::request::AuthenticatorRequestData::TopUpBandwidth(top_up_message.into()), + ), + } + } +} + +impl From for v6::registration::InitMessage { + fn from(init_msg: v5::registration::InitMessage) -> Self { + Self { + pub_key: init_msg.pub_key, + } + } +} + +impl TryFrom for v6::registration::FinalMessage { + type Error = crate::Error; + + fn try_from(final_msg: v5::registration::FinalMessage) -> Result { + Ok(Self { + gateway_client: final_msg.gateway_client.into(), + credential: final_msg + .credential + .map(TryInto::try_into) + .transpose() + .map_err(Self::Error::conversion_display)?, + }) + } +} + +impl From for v6::registration::GatewayClient { + fn from(gateway_client: v5::registration::GatewayClient) -> Self { + Self { + pub_key: gateway_client.pub_key, + private_ips: gateway_client.private_ips.into(), + mac: gateway_client.mac.into(), + } + } +} + +impl From for v5::registration::GatewayClient { + fn from(gateway_client: v6::registration::GatewayClient) -> Self { + Self { + pub_key: gateway_client.pub_key, + private_ips: gateway_client.private_ips.into(), + mac: gateway_client.mac.into(), + } + } +} + +impl From for v6::registration::ClientMac { + fn from(client_mac: v5::registration::ClientMac) -> Self { + Self::new((*client_mac).clone()) + } +} + +impl From for v5::registration::ClientMac { + fn from(client_mac: v6::registration::ClientMac) -> Self { + Self::new((*client_mac).clone()) + } +} + +impl From> for Box { + fn from(top_up_message: Box) -> Self { + Box::new(v6::topup::TopUpMessage { + pub_key: top_up_message.pub_key, + credential: top_up_message.credential, + }) + } +} + +impl From for v6::response::AuthenticatorResponse { + fn from(value: v5::response::AuthenticatorResponse) -> Self { + Self { + protocol: v6::PROTOCOL, + data: value.data.into(), + } + } +} + +impl From for v6::response::AuthenticatorResponseData { + fn from(authenticator_response_data: v5::response::AuthenticatorResponseData) -> Self { + match authenticator_response_data { + v5::response::AuthenticatorResponseData::PendingRegistration(pending_response) => { + v6::response::AuthenticatorResponseData::PendingRegistration( + pending_response.into(), + ) + } + v5::response::AuthenticatorResponseData::Registered(registered_response) => { + v6::response::AuthenticatorResponseData::Registered(registered_response.into()) + } + v5::response::AuthenticatorResponseData::RemainingBandwidth( + remaining_bandwidth_response, + ) => v6::response::AuthenticatorResponseData::RemainingBandwidth( + remaining_bandwidth_response.into(), + ), + v5::response::AuthenticatorResponseData::TopUpBandwidth(top_up_response) => { + v6::response::AuthenticatorResponseData::TopUpBandwidth(top_up_response.into()) + } + } + } +} + +impl From for v6::response::RegisteredResponse { + fn from(value: v5::response::RegisteredResponse) -> Self { + Self { + request_id: value.request_id, + reply: value.reply.into(), + upgrade_mode_enabled: false, + } + } +} + +impl From for v6::response::PendingRegistrationResponse { + fn from(value: v5::response::PendingRegistrationResponse) -> Self { + Self { + request_id: value.request_id, + reply: value.reply.into(), + upgrade_mode_enabled: false, + } + } +} + +impl From for v6::registration::RegistrationData { + fn from(value: v5::registration::RegistrationData) -> Self { + Self { + nonce: value.nonce, + gateway_data: value.gateway_data.into(), + wg_port: value.wg_port, + } + } +} + +impl From for v5::registration::RegistrationData { + fn from(value: v6::registration::RegistrationData) -> Self { + Self { + nonce: value.nonce, + gateway_data: value.gateway_data.into(), + wg_port: value.wg_port, + } + } +} + +impl From for v6::response::RemainingBandwidthResponse { + fn from(value: v5::response::RemainingBandwidthResponse) -> Self { + Self { + request_id: value.request_id, + reply: value.reply.map(Into::into), + upgrade_mode_enabled: false, + } + } +} + +impl From for v6::response::TopUpBandwidthResponse { + fn from(value: v5::response::TopUpBandwidthResponse) -> Self { + Self { + request_id: value.request_id, + reply: value.reply.into(), + upgrade_mode_enabled: false, + } + } +} + +impl From for v6::registration::RegisteredData { + fn from(value: v5::registration::RegisteredData) -> Self { + Self { + pub_key: value.pub_key, + private_ips: value.private_ips.into(), + wg_port: value.wg_port, + } + } +} + +impl From for v6::registration::RemainingBandwidthData { + fn from(value: v5::registration::RemainingBandwidthData) -> Self { + Self { + available_bandwidth: value.available_bandwidth, + } + } +} + +impl From for v6::registration::IpPair { + fn from(value: v5::registration::IpPair) -> Self { + Self { + ipv4: value.ipv4, + ipv6: value.ipv6, + } + } +} + +impl From for v5::registration::IpPair { + fn from(value: v6::registration::IpPair) -> Self { + Self { + ipv4: value.ipv4, + ipv6: value.ipv6, + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + net::{Ipv4Addr, Ipv6Addr}, + str::FromStr, + }; + + use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData, TicketType}; + use nym_crypto::asymmetric::x25519::PrivateKey; + use nym_wireguard_types::PeerPublicKey; + use x25519_dalek::PublicKey; + + use super::*; + use crate::models::BandwidthClaim; + use crate::{util::tests::CREDENTIAL_BYTES, v5}; + + #[test] + fn upgrade_initial_req() { + let pub_key = PeerPublicKey::new(PublicKey::from([0; 32])); + + let (msg, _) = v5::request::AuthenticatorRequest::new_initial_request( + v5::registration::InitMessage::new(pub_key), + ); + let upgraded_msg = v6::request::AuthenticatorRequest::try_from(msg).unwrap(); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + assert_eq!( + upgraded_msg.data, + v6::request::AuthenticatorRequestData::Initial(v6::registration::InitMessage { + pub_key + }) + ); + } + + #[test] + fn upgrade_final_req() { + let mut rng = rand::thread_rng(); + + let local_secret = PrivateKey::new(&mut rng); + let remote_secret = x25519_dalek::StaticSecret::random_from_rng(&mut rng); + let ipv4 = Ipv4Addr::from_str("10.10.10.10").unwrap(); + let ipv6 = Ipv6Addr::from_str("fc01::a0a").unwrap(); + let ips = v5::registration::IpPair::new(ipv4, ipv6); + let nonce = 42; + let gateway_client = v5::registration::GatewayClient::new( + &local_secret, + (&remote_secret).into(), + ips, + nonce, + ); + let credential = CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(); + let final_message = v5::registration::FinalMessage { + gateway_client: gateway_client.clone(), + credential: Some(credential.clone()), + }; + + let (msg, _) = v5::request::AuthenticatorRequest::new_final_request(final_message); + let upgraded_msg = v6::request::AuthenticatorRequest::try_from(msg).unwrap(); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + assert_eq!( + upgraded_msg.data, + v6::request::AuthenticatorRequestData::Final(Box::new( + v6::registration::FinalMessage { + gateway_client: v6::registration::GatewayClient::new( + &local_secret, + (&remote_secret).into(), + v6::registration::IpPair::new(ipv4, ipv6), + nonce + ), + credential: Some(BandwidthClaim { + credential: BandwidthCredential::ZkNym(Box::new(credential)), + kind: TicketType::V1MixnetEntry, + }) + } + )) + ); + } + + #[test] + fn upgrade_query_req() { + let pub_key = PeerPublicKey::new(PublicKey::from([0; 32])); + + let (msg, _) = v5::request::AuthenticatorRequest::new_query_request(pub_key); + let upgraded_msg = v6::request::AuthenticatorRequest::try_from(msg).unwrap(); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + assert_eq!( + upgraded_msg.data, + v6::request::AuthenticatorRequestData::QueryBandwidth(pub_key) + ); + } + + #[test] + fn upgrade_pending_reg_resp() { + let mut rng = rand::thread_rng(); + + let local_secret = PrivateKey::new(&mut rng); + let remote_secret = x25519_dalek::StaticSecret::random_from_rng(&mut rng); + let ipv4 = Ipv4Addr::from_str("10.10.10.10").unwrap(); + let ipv6 = Ipv6Addr::from_str("fc01::a0a").unwrap(); + let ips = v5::registration::IpPair::new(ipv4, ipv6); + let nonce = 42; + let wg_port = 51822; + let gateway_data = v5::registration::GatewayClient::new( + &local_secret, + (&remote_secret).into(), + ips, + nonce, + ); + let registration_data = v5::registration::RegistrationData { + nonce, + gateway_data, + wg_port, + }; + let request_id = 123; + + let msg = v5::response::AuthenticatorResponse::new_pending_registration_success( + registration_data, + request_id, + ); + let upgraded_msg = v6::response::AuthenticatorResponse::from(msg); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + + assert_eq!( + upgraded_msg.data, + v6::response::AuthenticatorResponseData::PendingRegistration( + v6::response::PendingRegistrationResponse { + request_id, + reply: v6::registration::RegistrationData { + nonce, + gateway_data: v6::registration::GatewayClient::new( + &local_secret, + (&remote_secret).into(), + v6::registration::IpPair::new(ipv4, ipv6), + nonce + ), + wg_port + }, + upgrade_mode_enabled: false, + } + ) + ); + } + + #[test] + fn upgrade_registered_resp() { + let pub_key = PeerPublicKey::new(PublicKey::from([0; 32])); + let ipv4 = Ipv4Addr::from_str("10.1.10.10").unwrap(); + let ipv6 = Ipv6Addr::from_str("fc01::a0a").unwrap(); + let private_ips = v5::registration::IpPair::new(ipv4, ipv6); + let wg_port = 51822; + let registered_data = v5::registration::RegisteredData { + pub_key, + private_ips, + wg_port, + }; + let request_id = 123; + + let msg = v5::response::AuthenticatorResponse::new_registered(registered_data, request_id); + let upgraded_msg = v6::response::AuthenticatorResponse::from(msg); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + assert_eq!( + upgraded_msg.data, + v6::response::AuthenticatorResponseData::Registered(v6::response::RegisteredResponse { + request_id, + reply: v6::registration::RegisteredData { + wg_port, + pub_key, + private_ips: v6::registration::IpPair::new(ipv4, ipv6) + }, + upgrade_mode_enabled: false, + }) + ); + } + + #[test] + fn upgrade_remaining_bandwidth_resp() { + let available_bandwidth = 42; + let remaining_bandwidth_data = Some(v5::registration::RemainingBandwidthData { + available_bandwidth, + }); + let request_id = 123; + + let msg = v5::response::AuthenticatorResponse::new_remaining_bandwidth( + remaining_bandwidth_data, + request_id, + ); + let upgraded_msg = v6::response::AuthenticatorResponse::from(msg); + + assert_eq!(upgraded_msg.protocol, v6::PROTOCOL); + assert_eq!( + upgraded_msg.data, + v6::response::AuthenticatorResponseData::RemainingBandwidth( + v6::response::RemainingBandwidthResponse { + request_id, + reply: Some(v6::registration::RemainingBandwidthData { + available_bandwidth, + }), + upgrade_mode_enabled: false, + } + ) + ); + } +} diff --git a/common/authenticator-requests/src/v6/mod.rs b/common/authenticator-requests/src/v6/mod.rs new file mode 100644 index 0000000000..6fbc095ae9 --- /dev/null +++ b/common/authenticator-requests/src/v6/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; + +pub mod conversion; +pub mod registration; +pub mod request; +pub mod response; +pub mod topup; +pub mod upgrade_mode_check; + +pub const VERSION: u8 = 6; + +pub const PROTOCOL: Protocol = Protocol::new(VERSION, ServiceProviderType::Authenticator); diff --git a/common/authenticator-requests/src/v6/registration.rs b/common/authenticator-requests/src/v6/registration.rs new file mode 100644 index 0000000000..11fcf34116 --- /dev/null +++ b/common/authenticator-requests/src/v6/registration.rs @@ -0,0 +1,287 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::Error; +use crate::models::BandwidthClaim; +use base64::{Engine, engine::general_purpose}; +use nym_network_defaults::constants::{WG_TUN_DEVICE_IP_ADDRESS_V4, WG_TUN_DEVICE_IP_ADDRESS_V6}; +use nym_wireguard_types::PeerPublicKey; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::time::SystemTime; +use std::{fmt, ops::Deref, str::FromStr}; + +#[cfg(feature = "verify")] +use hmac::{Hmac, Mac}; +#[cfg(feature = "verify")] +use nym_crypto::asymmetric::x25519::{PrivateKey, PublicKey}; +#[cfg(feature = "verify")] +use sha2::Sha256; + +pub type PendingRegistrations = HashMap; +pub type PrivateIPs = HashMap; + +#[cfg(feature = "verify")] +pub type HmacSha256 = Hmac; + +pub type Nonce = u64; +pub type Taken = Option; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct IpPair { + pub ipv4: Ipv4Addr, + pub ipv6: Ipv6Addr, +} + +impl IpPair { + pub fn new(ipv4: Ipv4Addr, ipv6: Ipv6Addr) -> Self { + IpPair { ipv4, ipv6 } + } +} + +impl From<(Ipv4Addr, Ipv6Addr)> for IpPair { + fn from((ipv4, ipv6): (Ipv4Addr, Ipv6Addr)) -> Self { + IpPair { ipv4, ipv6 } + } +} + +impl fmt::Display for IpPair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({}, {})", self.ipv4, self.ipv6) + } +} + +impl From for IpPair { + fn from(value: IpAddr) -> Self { + let (before_last_byte, last_byte) = match value { + IpAddr::V4(ipv4_addr) => (ipv4_addr.octets()[2], ipv4_addr.octets()[3]), + IpAddr::V6(ipv6_addr) => (ipv6_addr.octets()[14], ipv6_addr.octets()[15]), + }; + let last_bytes = ((before_last_byte as u16) << 8) | last_byte as u16; + let ipv4 = Ipv4Addr::new( + WG_TUN_DEVICE_IP_ADDRESS_V4.octets()[0], + WG_TUN_DEVICE_IP_ADDRESS_V4.octets()[1], + before_last_byte, + last_byte, + ); + let ipv6 = Ipv6Addr::new( + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[0], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[1], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[2], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[3], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[4], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[5], + WG_TUN_DEVICE_IP_ADDRESS_V6.segments()[6], + last_bytes, + ); + IpPair::new(ipv4, ipv6) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InitMessage { + /// Base64 encoded x25519 public key + pub pub_key: PeerPublicKey, +} + +impl InitMessage { + pub fn new(pub_key: PeerPublicKey) -> Self { + InitMessage { pub_key } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct FinalMessage { + /// Gateway client data + pub gateway_client: GatewayClient, + + /// Ecash credential + pub credential: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct RegistrationData { + pub nonce: u64, + pub gateway_data: GatewayClient, + pub wg_port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct RegisteredData { + pub pub_key: PeerPublicKey, + pub private_ips: IpPair, + pub wg_port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct RemainingBandwidthData { + pub available_bandwidth: i64, +} + +/// Client that wants to register sends its PublicKey bytes mac digest encrypted with a DH shared secret. +/// Gateway/Nym node can then verify pub_key payload using the same process +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct GatewayClient { + /// Base64 encoded x25519 public key + pub pub_key: PeerPublicKey, + + /// Assigned private IPs (v4 and v6) + pub private_ips: IpPair, + + /// Sha256 hmac on the data (alongside the prior nonce) + pub mac: ClientMac, +} + +impl GatewayClient { + #[cfg(feature = "verify")] + pub fn new( + local_secret: &PrivateKey, + remote_public: x25519_dalek::PublicKey, + private_ips: IpPair, + nonce: u64, + ) -> Self { + let local_public = PublicKey::from(local_secret); + let remote_public = PublicKey::from(remote_public); + + let dh = local_secret.diffie_hellman(&remote_public); + + // TODO: change that to use our nym_crypto::hmac module instead + #[allow(clippy::expect_used)] + let mut mac = HmacSha256::new_from_slice(&dh[..]) + .expect("x25519 shared secret is always 32 bytes long"); + + mac.update(local_public.as_bytes()); + mac.update(private_ips.to_string().as_bytes()); + mac.update(&nonce.to_le_bytes()); + + GatewayClient { + pub_key: PeerPublicKey::new(local_public.into()), + private_ips, + mac: ClientMac(mac.finalize().into_bytes().to_vec()), + } + } + + // Reusable secret should be gateways Wireguard PK + // Client should perform this step when generating its payload, using its own WG PK + #[cfg(feature = "verify")] + pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> { + // use gateways key as a ref to an x25519_dalek key + let dh = gateway_key.inner().diffie_hellman(&self.pub_key); + + // TODO: change that to use our nym_crypto::hmac module instead + #[allow(clippy::expect_used)] + let mut mac = HmacSha256::new_from_slice(dh.as_bytes()) + .expect("x25519 shared secret is always 32 bytes long"); + + mac.update(self.pub_key.as_bytes()); + mac.update(self.private_ips.to_string().as_bytes()); + mac.update(&nonce.to_le_bytes()); + + mac.verify_slice(&self.mac) + .map_err(|source| Error::FailedClientMacVerification { + client: self.pub_key.to_string(), + source, + }) + } + + pub fn pub_key(&self) -> PeerPublicKey { + self.pub_key + } +} + +// TODO: change the inner type into generic array of size HmacSha256::OutputSize +// TODO2: rely on our internal crypto/hmac +#[derive(Debug, Clone, PartialEq)] +pub struct ClientMac(Vec); + +impl fmt::Display for ClientMac { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", general_purpose::STANDARD.encode(&self.0)) + } +} + +impl From> for ClientMac { + fn from(v: Vec) -> Self { + ClientMac(v) + } +} + +impl ClientMac { + #[allow(dead_code)] + pub fn new(mac: Vec) -> Self { + ClientMac(mac) + } +} + +impl Deref for ClientMac { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromStr for ClientMac { + type Err = Error; + + fn from_str(s: &str) -> Result { + let mac_bytes: Vec = + general_purpose::STANDARD + .decode(s) + .map_err(|source| Error::MalformedClientMac { + mac: s.to_string(), + source, + })?; + + Ok(ClientMac(mac_bytes)) + } +} + +impl Serialize for ClientMac { + fn serialize(&self, serializer: S) -> Result { + let encoded_key = general_purpose::STANDARD.encode(self.0.clone()); + serializer.serialize_str(&encoded_key) + } +} + +impl<'de> Deserialize<'de> for ClientMac { + fn deserialize>(deserializer: D) -> Result { + let encoded_key = String::deserialize(deserializer)?; + ClientMac::from_str(&encoded_key).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_crypto::asymmetric::x25519; + use nym_test_utils::helpers::deterministic_rng; + + #[test] + fn create_ip_pair() { + let ipv4: IpAddr = Ipv4Addr::from_str("10.1.10.50").unwrap().into(); + let ipv6: IpAddr = Ipv6Addr::from_str("fc01::0a32").unwrap().into(); + + assert_eq!(IpPair::from(ipv4), IpPair::from(ipv6)); + } + + #[test] + #[cfg(feature = "verify")] + fn client_request_roundtrip() { + let mut rng = deterministic_rng(); + + let gateway_key_pair = x25519::KeyPair::new(&mut rng); + let client_key_pair = x25519::KeyPair::new(&mut rng); + + let nonce = 1234567890; + + let client = GatewayClient::new( + client_key_pair.private_key(), + x25519_dalek::PublicKey::from(gateway_key_pair.public_key().to_bytes()), + IpPair::new("10.0.0.42".parse().unwrap(), "fc00::42".parse().unwrap()), + nonce, + ); + assert!(client.verify(gateway_key_pair.private_key(), nonce).is_ok()) + } +} diff --git a/common/authenticator-requests/src/v6/request.rs b/common/authenticator-requests/src/v6/request.rs new file mode 100644 index 0000000000..3bc8140b74 --- /dev/null +++ b/common/authenticator-requests/src/v6/request.rs @@ -0,0 +1,135 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use super::{ + PROTOCOL, + registration::{FinalMessage, InitMessage}, + topup::TopUpMessage, + upgrade_mode_check::UpgradeModeCheckRequest, +}; +use nym_service_provider_requests_common::Protocol; +use nym_wireguard_types::PeerPublicKey; +use serde::{Deserialize, Serialize}; + +use crate::make_bincode_serializer; + +fn generate_random() -> u64 { + use rand::RngCore; + let mut rng = rand::rngs::OsRng; + rng.next_u64() +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AuthenticatorRequest { + pub protocol: Protocol, + pub data: AuthenticatorRequestData, + pub request_id: u64, +} + +impl AuthenticatorRequest { + pub fn from_reconstructed_message( + message: &nym_sphinx::receiver::ReconstructedMessage, + ) -> Result { + use bincode::Options; + make_bincode_serializer().deserialize(&message.message) + } + + pub fn new_initial_request(init_message: InitMessage) -> (Self, u64) { + let request_id = generate_random(); + ( + Self { + protocol: PROTOCOL, + data: AuthenticatorRequestData::Initial(init_message), + request_id, + }, + request_id, + ) + } + + pub fn new_final_request(final_message: FinalMessage) -> (Self, u64) { + let request_id = generate_random(); + ( + Self { + protocol: PROTOCOL, + data: AuthenticatorRequestData::Final(Box::new(final_message)), + request_id, + }, + request_id, + ) + } + + pub fn new_query_request(peer_public_key: PeerPublicKey) -> (Self, u64) { + let request_id = generate_random(); + ( + Self { + protocol: PROTOCOL, + data: AuthenticatorRequestData::QueryBandwidth(peer_public_key), + request_id, + }, + request_id, + ) + } + + pub fn new_topup_request(top_up_message: TopUpMessage) -> (Self, u64) { + let request_id = generate_random(); + ( + Self { + protocol: PROTOCOL, + data: AuthenticatorRequestData::TopUpBandwidth(Box::new(top_up_message)), + request_id, + }, + request_id, + ) + } + + pub fn new_upgrade_mode_check_request(message: UpgradeModeCheckRequest) -> (Self, u64) { + let request_id = generate_random(); + ( + Self { + protocol: PROTOCOL, + data: AuthenticatorRequestData::CheckUpgradeMode(message), + request_id, + }, + request_id, + ) + } + + pub fn to_bytes(&self) -> Result, bincode::Error> { + use bincode::Options; + make_bincode_serializer().serialize(self) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum AuthenticatorRequestData { + Initial(InitMessage), + Final(Box), + QueryBandwidth(PeerPublicKey), + TopUpBandwidth(Box), + CheckUpgradeMode(UpgradeModeCheckRequest), +} + +#[cfg(test)] +mod tests { + use super::super::VERSION; + use super::*; + use nym_service_provider_requests_common::ServiceProviderType; + use std::str::FromStr; + + #[test] + fn check_first_bytes_protocol() { + let version = VERSION; + let data = AuthenticatorRequest { + protocol: Protocol { + version, + service_provider_type: ServiceProviderType::Authenticator, + }, + data: AuthenticatorRequestData::Initial(InitMessage::new( + PeerPublicKey::from_str("yvNUDpT5l7W/xDhiu6HkqTHDQwbs/B3J5UrLmORl1EQ=").unwrap(), + )), + request_id: 1, + }; + let bytes = *data.to_bytes().unwrap().first_chunk::<2>().unwrap(); + assert_eq!(bytes, [version, ServiceProviderType::Authenticator as u8]); + } +} diff --git a/common/authenticator-requests/src/v6/response.rs b/common/authenticator-requests/src/v6/response.rs new file mode 100644 index 0000000000..c93c34e1c3 --- /dev/null +++ b/common/authenticator-requests/src/v6/response.rs @@ -0,0 +1,153 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use super::registration::{RegisteredData, RegistrationData, RemainingBandwidthData}; +use nym_service_provider_requests_common::Protocol; +use serde::{Deserialize, Serialize}; + +use crate::make_bincode_serializer; + +use super::PROTOCOL; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AuthenticatorResponse { + pub protocol: Protocol, + pub data: AuthenticatorResponseData, +} + +impl AuthenticatorResponse { + pub fn new_pending_registration_success( + registration_data: RegistrationData, + request_id: u64, + upgrade_mode_enabled: bool, + ) -> Self { + Self { + protocol: PROTOCOL, + data: AuthenticatorResponseData::PendingRegistration(PendingRegistrationResponse { + reply: registration_data, + request_id, + upgrade_mode_enabled, + }), + } + } + + pub fn new_registered( + registered_data: RegisteredData, + request_id: u64, + upgrade_mode_enabled: bool, + ) -> Self { + Self { + protocol: PROTOCOL, + data: AuthenticatorResponseData::Registered(RegisteredResponse { + reply: registered_data, + request_id, + upgrade_mode_enabled, + }), + } + } + + pub fn new_remaining_bandwidth( + remaining_bandwidth_data: Option, + request_id: u64, + upgrade_mode_enabled: bool, + ) -> Self { + Self { + protocol: PROTOCOL, + data: AuthenticatorResponseData::RemainingBandwidth(RemainingBandwidthResponse { + reply: remaining_bandwidth_data, + request_id, + upgrade_mode_enabled, + }), + } + } + + pub fn new_topup_bandwidth( + remaining_bandwidth_data: RemainingBandwidthData, + request_id: u64, + upgrade_mode_enabled: bool, + ) -> Self { + Self { + protocol: PROTOCOL, + data: AuthenticatorResponseData::TopUpBandwidth(TopUpBandwidthResponse { + reply: remaining_bandwidth_data, + request_id, + upgrade_mode_enabled, + }), + } + } + + pub fn new_upgrade_mode_check(request_id: u64, upgrade_mode_enabled: bool) -> Self { + Self { + protocol: PROTOCOL, + data: AuthenticatorResponseData::UpgradeMode(UpgradeModeResponse { + request_id, + upgrade_mode_enabled, + }), + } + } + + pub fn to_bytes(&self) -> Result, bincode::Error> { + use bincode::Options; + make_bincode_serializer().serialize(self) + } + + pub fn from_reconstructed_message( + message: &nym_sphinx::receiver::ReconstructedMessage, + ) -> Result { + use bincode::Options; + make_bincode_serializer().deserialize(&message.message) + } + + pub fn id(&self) -> Option { + match &self.data { + AuthenticatorResponseData::PendingRegistration(response) => Some(response.request_id), + AuthenticatorResponseData::Registered(response) => Some(response.request_id), + AuthenticatorResponseData::RemainingBandwidth(response) => Some(response.request_id), + AuthenticatorResponseData::TopUpBandwidth(response) => Some(response.request_id), + AuthenticatorResponseData::UpgradeMode(response) => Some(response.request_id), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum AuthenticatorResponseData { + PendingRegistration(PendingRegistrationResponse), + Registered(RegisteredResponse), + RemainingBandwidth(RemainingBandwidthResponse), + TopUpBandwidth(TopUpBandwidthResponse), + UpgradeMode(UpgradeModeResponse), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct PendingRegistrationResponse { + pub request_id: u64, + pub reply: RegistrationData, + pub upgrade_mode_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct RegisteredResponse { + pub request_id: u64, + pub reply: RegisteredData, + pub upgrade_mode_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct RemainingBandwidthResponse { + pub request_id: u64, + pub reply: Option, + pub upgrade_mode_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TopUpBandwidthResponse { + pub request_id: u64, + pub reply: RemainingBandwidthData, + pub upgrade_mode_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct UpgradeModeResponse { + pub request_id: u64, + pub upgrade_mode_enabled: bool, +} diff --git a/common/authenticator-requests/src/v6/topup.rs b/common/authenticator-requests/src/v6/topup.rs new file mode 100644 index 0000000000..b5d25a9dbf --- /dev/null +++ b/common/authenticator-requests/src/v6/topup.rs @@ -0,0 +1,15 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_credentials_interface::CredentialSpendingData; +use nym_wireguard_types::PeerPublicKey; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct TopUpMessage { + /// Base64 encoded x25519 public key + pub pub_key: PeerPublicKey, + + /// Ecash credential + pub credential: CredentialSpendingData, +} diff --git a/common/authenticator-requests/src/v6/upgrade_mode_check.rs b/common/authenticator-requests/src/v6/upgrade_mode_check.rs new file mode 100644 index 0000000000..ae27af3800 --- /dev/null +++ b/common/authenticator-requests/src/v6/upgrade_mode_check.rs @@ -0,0 +1,12 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum UpgradeModeCheckRequest { + /// Attempt to request upgrade mode recheck via the JWT issued as the result of + /// global attestation.json being published + UpgradeModeJwt { token: String }, +} diff --git a/common/authenticator-requests/src/version.rs b/common/authenticator-requests/src/version.rs index 4bb8b6d591..d0f2bbf217 100644 --- a/common/authenticator-requests/src/version.rs +++ b/common/authenticator-requests/src/version.rs @@ -1,7 +1,7 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::{v1, v2, v3, v4, v5}; +use super::{v1, v2, v3, v4, v5, v6}; use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; #[derive(Copy, Clone, Debug, PartialEq, strum_macros::Display)] @@ -22,11 +22,15 @@ pub enum AuthenticatorVersion { /// introduced in dorina-patched release (1.6.1) V5, + /// introduced in niolo release (1.23.0) + V6, + + /// an unknown, future, variant that can be present if running outdated software UNKNOWN, } impl AuthenticatorVersion { - pub const LATEST: Self = Self::V5; + pub const LATEST: Self = Self::V6; pub const fn release_version(&self) -> semver::Version { match self { @@ -35,6 +39,7 @@ impl AuthenticatorVersion { AuthenticatorVersion::V3 => semver::Version::new(1, 1, 10), AuthenticatorVersion::V4 => semver::Version::new(1, 2, 0), AuthenticatorVersion::V5 => semver::Version::new(1, 6, 1), + AuthenticatorVersion::V6 => semver::Version::new(1, 23, 0), AuthenticatorVersion::UNKNOWN => semver::Version::new(0, 0, 0), } } @@ -54,6 +59,8 @@ impl From for AuthenticatorVersion { AuthenticatorVersion::V4 } else if value.version == v5::VERSION { AuthenticatorVersion::V5 + } else if value.version == v6::VERSION { + AuthenticatorVersion::V6 } else { AuthenticatorVersion::UNKNOWN } @@ -72,6 +79,8 @@ impl From for AuthenticatorVersion { AuthenticatorVersion::V4 } else if value == v5::VERSION { AuthenticatorVersion::V5 + } else if value == v6::VERSION { + AuthenticatorVersion::V6 } else { AuthenticatorVersion::UNKNOWN } @@ -126,11 +135,14 @@ impl From for AuthenticatorVersion { if semver < AuthenticatorVersion::V5.release_version() { return Self::V4; } - // if provided version is higher (or equal) to release version of V5, - // we return the latest (i.e. v5) + if semver < AuthenticatorVersion::V6.release_version() { + return Self::V5; + } + // if provided version is higher (or equal) to release version of V6, + // we return the latest (i.e. v6) debug_assert_eq!( - Self::V5, + Self::V6, Self::LATEST, "a new AuthenticatorVersion variant has been introduced without adjusting the `From` trait" ); @@ -191,5 +203,9 @@ mod tests { assert_eq!(AuthenticatorVersion::V5, "1.7.0".into()); assert_eq!(AuthenticatorVersion::V5, "1.16.11".into()); assert_eq!(AuthenticatorVersion::V5, "1.17.0".into()); + assert_eq!(AuthenticatorVersion::V5, "1.22.0".into()); + assert_eq!(AuthenticatorVersion::V6, "1.23.0".into()); + assert_eq!(AuthenticatorVersion::V6, "1.23.1".into()); + assert_eq!(AuthenticatorVersion::V6, "1.24.0".into()); } } diff --git a/common/client-libs/gateway-client/Cargo.toml b/common/client-libs/gateway-client/Cargo.toml index efc699473a..969d94807e 100644 --- a/common/client-libs/gateway-client/Cargo.toml +++ b/common/client-libs/gateway-client/Cargo.toml @@ -88,3 +88,6 @@ features = ["js"] [features] wasm = [] + +[lints] +workspace = true \ No newline at end of file diff --git a/common/client-libs/gateway-client/src/bandwidth.rs b/common/client-libs/gateway-client/src/bandwidth.rs index 9fd43765bd..304eff6c96 100644 --- a/common/client-libs/gateway-client/src/bandwidth.rs +++ b/common/client-libs/gateway-client/src/bandwidth.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use si_scale::helpers::bibytes2; +use std::fmt::{Display, Formatter}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -26,6 +27,39 @@ pub struct ClientBandwidth { inner: Arc, } +// simple helper for logging purposes to accommodate 'unknown' case +pub(crate) enum UpgradeModeEnabledWrapper { + True, + False, + Unknown, +} + +impl From> for UpgradeModeEnabledWrapper { + fn from(value: Option) -> Self { + match value { + Some(true) => UpgradeModeEnabledWrapper::True, + Some(false) => UpgradeModeEnabledWrapper::False, + None => UpgradeModeEnabledWrapper::Unknown, + } + } +} + +impl From for UpgradeModeEnabledWrapper { + fn from(value: bool) -> Self { + Some(value).into() + } +} + +impl Display for UpgradeModeEnabledWrapper { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + UpgradeModeEnabledWrapper::True => write!(f, "true"), + UpgradeModeEnabledWrapper::False => write!(f, "false"), + UpgradeModeEnabledWrapper::Unknown => write!(f, "unknown"), + } + } +} + struct ClientBandwidthInner { /// the actual bandwidth amount (in bytes) available available: AtomicI64, @@ -71,26 +105,41 @@ impl ClientBandwidth { self.inner.available.load(Ordering::Acquire) } - pub(crate) fn maybe_log_bandwidth(&self, now: Option) { + pub(crate) fn maybe_log_bandwidth( + &self, + now: Option, + upgrade_mode: impl Into, + ) { let last = self.last_logged(); let now = now.unwrap_or_else(OffsetDateTime::now_utc); if last + Duration::from_secs(10) < now { - self.log_bandwidth(Some(now)) + self.log_bandwidth(Some(now), upgrade_mode) } } - pub(crate) fn log_bandwidth(&self, now: Option) { + pub(crate) fn log_bandwidth( + &self, + now: Option, + upgrade_mode: impl Into, + ) { let now = now.unwrap_or_else(OffsetDateTime::now_utc); + let upgrade_mode = upgrade_mode.into(); let remaining = self.remaining(); let remaining_bi2 = bibytes2(remaining as f64); if remaining < 0 { - tracing::warn!("OUT OF BANDWIDTH. remaining: {remaining_bi2}"); + tracing::warn!( + "OUT OF BANDWIDTH. remaining: {remaining_bi2}. in 'upgrade mode': {upgrade_mode}" + ); } else if remaining < 1_000_000 { - tracing::info!("remaining bandwidth: {remaining_bi2}"); + tracing::info!( + "remaining bandwidth: {remaining_bi2}. in 'upgrade mode': {upgrade_mode}" + ); } else { - tracing::debug!("remaining bandwidth: {remaining_bi2}"); + tracing::trace!( + "remaining bandwidth: {remaining_bi2}. in 'upgrade mode': {upgrade_mode}" + ); } self.inner @@ -98,26 +147,35 @@ impl ClientBandwidth { .store(now.unix_timestamp(), Ordering::Relaxed) } - pub(crate) fn update_and_maybe_log(&self, remaining: i64) { + pub(crate) fn update_and_maybe_log( + &self, + remaining: i64, + upgrade_mode: impl Into, + ) { let now = OffsetDateTime::now_utc(); self.inner.available.store(remaining, Ordering::Release); self.inner .last_updated_ts .store(now.unix_timestamp(), Ordering::Relaxed); - self.maybe_log_bandwidth(Some(now)) + self.maybe_log_bandwidth(Some(now), upgrade_mode) } - pub(crate) fn update_and_log(&self, remaining: i64) { + pub(crate) fn update_and_log( + &self, + remaining: i64, + upgrade_mode: impl Into, + ) { let now = OffsetDateTime::now_utc(); self.inner.available.store(remaining, Ordering::Release); self.inner .last_updated_ts .store(now.unix_timestamp(), Ordering::Relaxed); - self.log_bandwidth(Some(now)) + self.log_bandwidth(Some(now), upgrade_mode) } fn last_logged(&self) -> OffsetDateTime { // SAFETY: this value is always populated with valid timestamps + #[allow(clippy::unwrap_used)] OffsetDateTime::from_unix_timestamp(self.inner.last_logged_ts.load(Ordering::Relaxed)) .unwrap() } diff --git a/common/client-libs/gateway-client/src/client/config.rs b/common/client-libs/gateway-client/src/client/config.rs index fd7bfc142d..af24a72b90 100644 --- a/common/client-libs/gateway-client/src/client/config.rs +++ b/common/client-libs/gateway-client/src/client/config.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::error::GatewayClientError; -use nym_network_defaults::TicketTypeRepr::V1MixnetEntry; +use nym_credentials_interface::DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD; use si_scale::helpers::bibytes2; use std::time::Duration; @@ -103,7 +103,7 @@ impl BandwidthTickets { // 20% of entry ticket value pub const DEFAULT_REMAINING_BANDWIDTH_THRESHOLD: i64 = - (V1MixnetEntry.bandwidth_value() / 5) as i64; + DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD; pub const DEFAULT_CUTOFF_REMAINING_BANDWIDTH_THRESHOLD: Option = None; diff --git a/common/client-libs/gateway-client/src/client/mod.rs b/common/client-libs/gateway-client/src/client/mod.rs index 52e5833eb2..e9d91beeb9 100644 --- a/common/client-libs/gateway-client/src/client/mod.rs +++ b/common/client-libs/gateway-client/src/client/mod.rs @@ -20,9 +20,9 @@ use nym_credentials_interface::TicketType; use nym_crypto::asymmetric::ed25519; use nym_gateway_requests::registration::handshake::client_handshake; use nym_gateway_requests::{ - BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersionExt, - GatewayRequestsError, SensitiveServerResponse, ServerResponse, SharedGatewayKey, - SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION, + BandwidthResponse, BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersion, + GatewayProtocolVersionExt, GatewayRequestsError, SensitiveServerResponse, ServerResponse, + SharedGatewayKey, SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, }; use nym_sphinx::forwarding::packet::MixPacket; use nym_statistics_common::clients::connection::ConnectionStatsEvent; @@ -101,8 +101,7 @@ pub struct GatewayClient { bandwidth_controller: Option>, stats_reporter: ClientStatsSender, - // currently unused (but populated) - negotiated_protocol: Option, + negotiated_protocol: Option, // Callback on the fd as soon as the connection has been established #[cfg(unix)] @@ -166,10 +165,12 @@ impl GatewayClient { } #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::unreachable)] async fn _close_connection(&mut self) -> Result<(), GatewayClientError> { match std::mem::replace(&mut self.connection, SocketState::NotConnected) { SocketState::Available(mut socket) => Ok((*socket).close(None).await?), SocketState::PartiallyDelegated(_) => { + // SAFETY: this is only called after the caller has already recovered the connection unreachable!("this branch should have never been reached!") } _ => Ok(()), // no need to do anything in those cases @@ -177,6 +178,7 @@ impl GatewayClient { } #[cfg(target_arch = "wasm32")] + #[allow(clippy::unreachable)] async fn _close_connection(&mut self) -> Result<(), GatewayClientError> { match std::mem::replace(&mut self.connection, SocketState::NotConnected) { SocketState::Available(socket) => { @@ -184,6 +186,7 @@ impl GatewayClient { Ok(()) } SocketState::PartiallyDelegated(_) => { + // SAFETY: this is only called after the caller has already recovered the connection unreachable!("this branch should have never been reached!") } _ => Ok(()), // no need to do anything in those cases @@ -458,43 +461,16 @@ impl GatewayClient { } } - fn check_gateway_protocol( - &self, - gateway_protocol: Option, - ) -> Result<(), GatewayClientError> { - debug!("gateway protocol: {gateway_protocol:?}, ours: {CURRENT_PROTOCOL_VERSION}"); - - // right now there are no failure cases here, but this might change in the future - match gateway_protocol { - None => { - warn!("the gateway we're connected to has not specified its protocol version. It's probably running version < 1.1.X, but that's still fine for now. It will become a hard error in 1.2.0"); - // note: in +1.2.0 we will have to return a hard error here - Ok(()) - } - Some(v) if v > CURRENT_PROTOCOL_VERSION => { - let err = GatewayClientError::IncompatibleProtocol { - gateway: Some(v), - current: CURRENT_PROTOCOL_VERSION, - }; - error!("{err}"); - Err(err) - } - - Some(_) => { - debug!("the gateway is using exactly the same (or older) protocol version as we are. We're good to continue!"); - Ok(()) - } - } - } - async fn register( &mut self, - derive_aes256_gcm_siv_key: bool, + supported_gateway_protocol: Option, ) -> Result<(), GatewayClientError> { if !self.connection.is_established() { return Err(GatewayClientError::ConnectionNotEstablished); } + let derive_aes256_gcm_siv_key = supported_gateway_protocol.supports_aes256_gcm_siv(); + debug_assert!(self.connection.is_available()); log::debug!( "registering with gateway. using legacy key derivation: {}", @@ -505,14 +481,13 @@ impl GatewayClient { // and putting it into the GatewayClient struct would be a hassle let mut rng = OsRng; - let shared_key = match &mut self.connection { + let handshake_result = match &mut self.connection { SocketState::Available(ws_stream) => client_handshake( &mut rng, ws_stream, self.local_identity.as_ref(), self.gateway_identity, - self.cfg.bandwidth.require_tickets, - derive_aes256_gcm_siv_key, + supported_gateway_protocol, #[cfg(not(target_arch = "wasm32"))] self.shutdown_token.clone(), ) @@ -521,26 +496,31 @@ impl GatewayClient { _ => return Err(GatewayClientError::ConnectionInInvalidState), }?; - let (authentication_status, gateway_protocol) = match self.read_control_response().await? { + let authentication_status = match self.read_control_response().await? { ServerResponse::Register { - protocol_version, status, - } => (status, protocol_version), + upgrade_mode, + .. + } => { + if upgrade_mode { + warn!("the system is currently undergoing an upgrade. some of its functionalities might be unstable") + } + status + } ServerResponse::Error { message } => { return Err(GatewayClientError::GatewayError(message)) } other => return Err(GatewayClientError::UnexpectedResponse { name: other.name() }), }; - self.check_gateway_protocol(gateway_protocol)?; self.authenticated = authentication_status; if self.authenticated { - self.shared_key = Some(Arc::new(shared_key)); + self.shared_key = Some(Arc::new(handshake_result.derived_key)); } // populate the negotiated protocol for future uses - self.negotiated_protocol = gateway_protocol; + self.negotiated_protocol = Some(handshake_result.negotiated_protocol); Ok(()) } @@ -623,13 +603,24 @@ impl GatewayClient { protocol_version, status, bandwidth_remaining, + upgrade_mode, } => { - self.check_gateway_protocol(protocol_version)?; + if protocol_version.is_future_version() { + // SAFETY: future version is always defined + #[allow(clippy::unwrap_used)] + let version = protocol_version.unwrap(); + error!("the gateway insists on using v{version} protocol which is not supported by this client"); + return Err(GatewayClientError::AuthenticationFailure); + } self.authenticated = status; - self.bandwidth.update_and_maybe_log(bandwidth_remaining); + self.bandwidth + .update_and_maybe_log(bandwidth_remaining, upgrade_mode); self.negotiated_protocol = protocol_version; log::debug!("authenticated: {status}, bandwidth remaining: {bandwidth_remaining}"); + if upgrade_mode { + warn!("the system is currently undergoing an upgrade. some of its functionalities might be unstable") + } Ok(()) } @@ -650,7 +641,7 @@ impl GatewayClient { .public_key() .derive_destination_address(); - let msg = ClientControlRequest::new_authenticate( + let msg = ClientControlRequest::new_legacy_authenticate( self_address, shared_key, self.cfg.bandwidth.require_tickets, @@ -659,25 +650,40 @@ impl GatewayClient { .await } - async fn authenticate_v2(&mut self) -> Result<(), GatewayClientError> { + async fn authenticate_v2( + &mut self, + requested_protocol_version: GatewayProtocolVersion, + ) -> Result<(), GatewayClientError> { debug!("using v2 authentication"); let Some(shared_key) = self.shared_key.as_ref() else { return Err(GatewayClientError::NoSharedKeyAvailable); }; - let msg = ClientControlRequest::new_authenticate_v2(shared_key, &self.local_identity)?; + let msg = ClientControlRequest::new_authenticate_v2( + shared_key, + &self.local_identity, + requested_protocol_version, + )?; self.send_authenticate_request_and_handle_response(msg) .await } - async fn authenticate(&mut self, use_v2: bool) -> Result<(), GatewayClientError> { + async fn authenticate( + &mut self, + supported_gateway_protocol: Option, + ) -> Result<(), GatewayClientError> { if !self.connection.is_established() { return Err(GatewayClientError::ConnectionNotEstablished); } debug!("authenticating with gateway"); - if use_v2 { - self.authenticate_v2().await + if supported_gateway_protocol.supports_authenticate_v2() { + // use the highest possible protocol version the gateway has announced support for + + // SAFETY: if announced protocol supports auth v2, it means it's properly set + #[allow(clippy::unwrap_used)] + self.authenticate_v2(supported_gateway_protocol.unwrap()) + .await } else { self.authenticate_v1().await } @@ -708,9 +714,12 @@ impl GatewayClient { } }; + debug!("supported gateway protocol: {gw_protocol:?}"); + let supports_aes_gcm_siv = gw_protocol.supports_aes256_gcm_siv(); let supports_auth_v2 = gw_protocol.supports_authenticate_v2(); let supports_key_rotation_info = gw_protocol.supports_key_rotation_packet(); + let supports_upgrade_mode = gw_protocol.supports_upgrade_mode(); if !supports_aes_gcm_siv { warn!("this gateway is on an old version that doesn't support AES256-GCM-SIV"); @@ -721,6 +730,16 @@ impl GatewayClient { if !supports_key_rotation_info { warn!("this gateway is on an old version that doesn't support key rotation packets") } + if !supports_upgrade_mode { + warn!("this gateway is on an old version that doesn't support upgrade mode") + } + + let gw_protocol = if gw_protocol.is_future_version() { + warn!("we're running outdated software as gateway is announcing protocol {gw_protocol:?} whilst we're using {}. we're going to attempt to downgrade", GatewayProtocolVersion::CURRENT); + Some(GatewayProtocolVersion::CURRENT) + } else { + gw_protocol + }; if self.authenticated { debug!("Already authenticated"); @@ -735,10 +754,11 @@ impl GatewayClient { } if self.shared_key.is_some() { - self.authenticate(supports_auth_v2).await?; + self.authenticate(gw_protocol).await?; if self.authenticated { // if we are authenticated it means we MUST have an associated shared_key + #[allow(clippy::unwrap_used)] let shared_key = self.shared_key.as_ref().unwrap(); let requires_key_upgrade = shared_key.is_legacy() && supports_aes_gcm_siv; @@ -751,9 +771,10 @@ impl GatewayClient { Err(GatewayClientError::AuthenticationFailure) } } else { - self.register(supports_aes_gcm_siv).await?; + self.register(gw_protocol).await?; // if registration didn't return an error, we MUST have an associated shared key + #[allow(clippy::unwrap_used)] let shared_key = self.shared_key.as_ref().unwrap(); // we're always registering with the highest supported protocol, @@ -783,51 +804,81 @@ impl GatewayClient { } } - async fn claim_ecash_bandwidth( + async fn wait_for_bandwidth_response( &mut self, - credential: CredentialSpendingData, - ) -> Result<(), GatewayClientError> { - let msg = ClientControlRequest::new_enc_ecash_credential( - credential, - self.shared_key.as_ref().unwrap(), - )?; - let bandwidth_remaining = match self + msg: ClientControlRequest, + ) -> Result { + let response = match self .send_websocket_message_with_non_send_response(msg) .await? { - ServerResponse::Bandwidth { available_total } => Ok(available_total), + ServerResponse::Bandwidth(response) => { + if response.upgrade_mode { + info!("the system is currently undergoing an upgrade. our bandwidth shouldn't have been metered") + } + Ok(response) + } ServerResponse::Error { message } => Err(GatewayClientError::GatewayError(message)), ServerResponse::TypedError { error } => { Err(GatewayClientError::TypedGatewayError(error)) } other => Err(GatewayClientError::UnexpectedResponse { name: other.name() }), }?; + Ok(response) + } + + async fn claim_ecash_bandwidth( + &mut self, + credential: CredentialSpendingData, + ) -> Result<(), GatewayClientError> { + // SAFETY: claiming ecash bandwidth is called as part of `claim_bandwidth` which + // ensures the shared key is defined + #[allow(clippy::unwrap_used)] + let msg = ClientControlRequest::new_enc_ecash_credential( + credential, + self.shared_key.as_ref().unwrap(), + )?; + let response = self.wait_for_bandwidth_response(msg).await?; // TODO: create tracing span info!("managed to claim ecash bandwidth"); - self.bandwidth.update_and_log(bandwidth_remaining); + self.bandwidth + .update_and_log(response.available_total, response.upgrade_mode); + + Ok(()) + } + + pub async fn send_upgrade_mode_jwt(&mut self, token: String) -> Result<(), GatewayClientError> { + let msg = ClientControlRequest::new_upgrade_mode_jwt(token); + let response = self.wait_for_bandwidth_response(msg).await?; + + // if gateway rejected our jwt, we would have returned an error + info!("gateway has accepted our jwt"); + if !response.upgrade_mode { + error!("but we're not in upgrade mode - something is wrong!"); + return Err(GatewayClientError::UnexpectedUpgradeModeState); + } + + self.bandwidth + .update_and_log(response.available_total, response.upgrade_mode); Ok(()) } async fn try_claim_testnet_bandwidth(&mut self) -> Result<(), GatewayClientError> { let msg = ClientControlRequest::ClaimFreeTestnetBandwidth; - let bandwidth_remaining = match self - .send_websocket_message_with_non_send_response(msg) - .await? - { - ServerResponse::Bandwidth { available_total } => Ok(available_total), - ServerResponse::Error { message } => Err(GatewayClientError::GatewayError(message)), - other => Err(GatewayClientError::UnexpectedResponse { name: other.name() }), - }?; + let response = self.wait_for_bandwidth_response(msg).await?; info!("managed to claim testnet bandwidth"); - self.bandwidth.update_and_log(bandwidth_remaining); + self.bandwidth + .update_and_log(response.available_total, response.upgrade_mode); Ok(()) } fn unchecked_bandwidth_controller(&self) -> &BandwidthController { + // this is an unchecked method + #[allow(clippy::unwrap_used)] self.bandwidth_controller.as_ref().unwrap() } @@ -919,6 +970,7 @@ impl GatewayClient { BinaryRequest::ForwardSphinx { packet } }; + #[allow(clippy::expect_used)] req.into_ws_message( self.shared_key .as_ref() @@ -1025,6 +1077,8 @@ impl GatewayClient { self.send_with_reconnection_on_failure(msg).await } + // SAFETY: this method is only called when the connection is in `PartiallyDelegated` state + #[allow(clippy::unreachable)] async fn recover_socket_connection(&mut self) -> Result<(), GatewayClientError> { if self.connection.is_available() { return Ok(()); @@ -1054,6 +1108,7 @@ impl GatewayClient { return Err(GatewayClientError::ConnectionInInvalidState); } + #[allow(clippy::expect_used)] let partially_delegated = match std::mem::replace(&mut self.connection, SocketState::Invalid) { SocketState::Available(conn) => { @@ -1069,7 +1124,13 @@ impl GatewayClient { self.shutdown_token.clone(), ) } - _ => unreachable!(), + other => { + error!( + "attempted to start mixnet listener whilst the connection is in {} state!", + other.name() + ); + return Err(GatewayClientError::ConnectionInInvalidState); + } }; self.connection = SocketState::PartiallyDelegated(partially_delegated); @@ -1082,8 +1143,7 @@ impl GatewayClient { } // if we're reconnecting, because we lost connection, we need to re-authenticate the connection - self.authenticate(self.negotiated_protocol.supports_authenticate_v2()) - .await?; + self.authenticate(self.negotiated_protocol).await?; // this call is NON-blocking self.start_listening_for_mixnet_messages()?; diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index eaea37c586..9f97b7c7f5 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -128,6 +128,9 @@ pub enum GatewayClientError { "this operation couldn't be completed as the program is in the process of shutting down" )] ShutdownInProgress, + + #[error("the system is an unexpected upgrade mode state")] + UnexpectedUpgradeModeState, } impl From for GatewayClientError { diff --git a/common/client-libs/gateway-client/src/packet_router.rs b/common/client-libs/gateway-client/src/packet_router.rs index 7fb863947f..93019564b9 100644 --- a/common/client-libs/gateway-client/src/packet_router.rs +++ b/common/client-libs/gateway-client/src/packet_router.rs @@ -35,6 +35,7 @@ impl PacketRouter { } } + #[allow(clippy::panic)] pub fn route_mixnet_messages( &self, received_messages: Vec>, @@ -54,6 +55,7 @@ impl PacketRouter { Ok(()) } + #[allow(clippy::panic)] pub fn route_acks(&self, received_acks: Vec>) -> Result<(), GatewayClientError> { if let Err(err) = self.ack_sender.unbounded_send(received_acks) { // check if the failure is due to the shutdown being in progress and thus the receiver channel diff --git a/common/client-libs/gateway-client/src/socket_state.rs b/common/client-libs/gateway-client/src/socket_state.rs index 4f3009e389..151ef4842e 100644 --- a/common/client-libs/gateway-client/src/socket_state.rs +++ b/common/client-libs/gateway-client/src/socket_state.rs @@ -10,7 +10,9 @@ use futures::channel::oneshot; use futures::stream::{SplitSink, SplitStream}; use futures::{SinkExt, StreamExt}; use nym_gateway_requests::shared_key::SharedGatewayKey; -use nym_gateway_requests::{SensitiveServerResponse, ServerResponse, SimpleGatewayRequestsError}; +use nym_gateway_requests::{ + SendResponse, SensitiveServerResponse, ServerResponse, SimpleGatewayRequestsError, +}; use nym_task::ShutdownToken; use si_scale::helpers::bibytes2; use std::os::raw::c_int as RawFd; @@ -154,11 +156,12 @@ impl PartiallyDelegatedRouter { fn handle_text_message(&self, text: String) -> Result<(), GatewayClientError> { // if we fail to deserialise the response, return a hard error. we can't handle garbage match ServerResponse::try_from(text).map_err(|_| GatewayClientError::MalformedResponse)? { - ServerResponse::Send { + ServerResponse::Send(SendResponse { remaining_bandwidth, - } => { + upgrade_mode, + }) => { self.client_bandwidth - .update_and_maybe_log(remaining_bandwidth); + .update_and_maybe_log(remaining_bandwidth, upgrade_mode); Ok(()) } ServerResponse::Error { message } => { @@ -174,7 +177,7 @@ impl PartiallyDelegatedRouter { let available_bi2 = bibytes2(available as f64); let required_bi2 = bibytes2(required as f64); warn!("run out of bandwidth when attempting to send the message! we got {available_bi2} available, but needed at least {required_bi2} to send the previous message"); - self.client_bandwidth.update_and_log(available); + self.client_bandwidth.update_and_log(available, None); // UNIMPLEMENTED: we should stop sending messages until we recover bandwidth Ok(()) } @@ -327,6 +330,7 @@ impl PartiallyDelegatedHandle { Ok(self.sink_half.send_all(&mut send_stream).await?) } + #[allow(clippy::panic)] pub(crate) async fn merge(self) -> Result { let (mut stream_receiver, notify) = self.delegated_stream; @@ -355,8 +359,10 @@ impl PartiallyDelegatedHandle { // in receive_res .map_err(|_| GatewayClientError::ConnectionAbruptlyClosed)?; let stream = stream_results?; + // the error is thrown when trying to reunite sink and stream that did not originate // from the same split which is impossible to happen here + #[allow(clippy::unwrap_used)] Ok(self.sink_half.reunite(stream).unwrap()) } } @@ -387,4 +393,13 @@ impl SocketState { SocketState::Available(_) | SocketState::PartiallyDelegated(_) ) } + + pub(crate) fn name(&self) -> &'static str { + match self { + SocketState::Available(_) => "available", + SocketState::PartiallyDelegated(_) => "partially delegated", + SocketState::NotConnected => "not connected", + SocketState::Invalid => "invalid", + } + } } diff --git a/common/credential-proxy/src/error.rs b/common/credential-proxy/src/error.rs index d6e3e8d28f..86219fc5bd 100644 --- a/common/credential-proxy/src/error.rs +++ b/common/credential-proxy/src/error.rs @@ -1,6 +1,7 @@ // Copyright 2025 Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use nym_crypto::asymmetric::ed25519; use nym_ecash_signer_check::SignerCheckError; use nym_validator_client::coconut::EcashApiError; use nym_validator_client::nym_api::{EpochId, error::NymAPIError}; @@ -174,8 +175,18 @@ pub enum CredentialProxyError { )] AttestationCheckUrlNotSet, - #[error("the provided attestation check url is malformed: {source}")] + #[error("the provided attester public key is malformed: {source}")] MalformedAttestationCheckUrl { source: url::ParseError }, + + #[error( + "the attester public key has not been provided through either the CLI nor the default .env config" + )] + AttesterPublicKeyNotSet, + + #[error("the provided attester public key is malformed: {source}")] + MalformedAttesterPublicKey { + source: ed25519::Ed25519RecoveryError, + }, } impl From for CredentialProxyError { diff --git a/common/credential-verification/Cargo.toml b/common/credential-verification/Cargo.toml index e85589eaae..06c45efdc5 100644 --- a/common/credential-verification/Cargo.toml +++ b/common/credential-verification/Cargo.toml @@ -17,7 +17,6 @@ cosmwasm-std = { workspace = true } cw-utils = { workspace = true } dyn-clone = { workspace = true } futures = { workspace = true } -rand = { workspace = true } si-scale = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } @@ -27,8 +26,10 @@ tracing = { workspace = true } nym-api-requests = { path = "../../nym-api/nym-api-requests" } nym-credentials = { path = "../credentials" } nym-credentials-interface = { path = "../credentials-interface" } +nym-crypto = { path = "../crypto", features = ["asymmetric"] } nym-ecash-contract-common = { path = "../cosmwasm-smart-contracts/ecash-contract" } nym-gateway-requests = { path = "../gateway-requests" } nym-gateway-storage = { path = "../gateway-storage" } nym-task = { path = "../task" } nym-validator-client = { path = "../client-libs/validator-client" } +nym-upgrade-mode-check = { path = "../upgrade-mode-check" } diff --git a/common/credential-verification/src/bandwidth_storage_manager.rs b/common/credential-verification/src/bandwidth_storage_manager.rs index 7286229e68..9a78b9f95e 100644 --- a/common/credential-verification/src/bandwidth_storage_manager.rs +++ b/common/credential-verification/src/bandwidth_storage_manager.rs @@ -6,7 +6,6 @@ use crate::ClientBandwidth; use crate::error::*; use nym_credentials::ecash::utils::ecash_today; use nym_credentials_interface::Bandwidth; -use nym_gateway_requests::ServerResponse; use nym_gateway_storage::traits::BandwidthGatewayStorage; use si_scale::helpers::bibytes2; use time::OffsetDateTime; @@ -66,7 +65,7 @@ impl BandwidthStorageManager { Ok(()) } - pub async fn handle_claim_testnet_bandwidth(&mut self) -> Result { + pub async fn handle_claim_testnet_bandwidth(&mut self) -> Result { debug!("handling testnet bandwidth request"); if self.only_coconut_credentials { @@ -76,8 +75,7 @@ impl BandwidthStorageManager { self.increase_bandwidth(FREE_TESTNET_BANDWIDTH_VALUE, ecash_today()) .await?; let available_total = self.client_bandwidth.available().await; - - Ok(ServerResponse::Bandwidth { available_total }) + Ok(available_total) } #[instrument(skip_all)] @@ -96,7 +94,7 @@ impl BandwidthStorageManager { let available_bi2 = bibytes2(available_bandwidth as f64); let required_bi2 = bibytes2(required_bandwidth as f64); - debug!(available = available_bi2, required = required_bi2); + trace!(available = available_bi2, required = required_bi2); self.consume_bandwidth(required_bandwidth).await?; let remaining_bandwidth = self.client_bandwidth.available().await; diff --git a/common/credential-verification/src/error.rs b/common/credential-verification/src/error.rs index a00ce0e268..23fb47d8c0 100644 --- a/common/credential-verification/src/error.rs +++ b/common/credential-verification/src/error.rs @@ -47,6 +47,25 @@ pub enum Error { UnknownTicketType(#[from] nym_credentials_interface::UnknownTicketType), } +impl Error { + pub fn is_out_of_bandwidth(&self) -> bool { + matches!(self, Error::OutOfBandwidth { .. }) + } +} + +pub trait OutOfBandwidthResultExt { + fn is_out_of_bandwidth(&self) -> bool; +} + +impl OutOfBandwidthResultExt for Result { + fn is_out_of_bandwidth(&self) -> bool { + match &self { + Ok(_) => false, + Err(err) => err.is_out_of_bandwidth(), + } + } +} + impl From for Error { fn from(err: EcashTicketError) -> Self { // don't expose storage issue details to the user diff --git a/common/credential-verification/src/lib.rs b/common/credential-verification/src/lib.rs index f306867d98..3f2b77f840 100644 --- a/common/credential-verification/src/lib.rs +++ b/common/credential-verification/src/lib.rs @@ -18,6 +18,7 @@ pub mod bandwidth_storage_manager; mod client_bandwidth; pub mod ecash; pub mod error; +pub mod upgrade_mode; pub struct CredentialVerifier { credential: CredentialSpendingRequest, diff --git a/common/credential-verification/src/upgrade_mode.rs b/common/credential-verification/src/upgrade_mode.rs new file mode 100644 index 0000000000..81385bece1 --- /dev/null +++ b/common/credential-verification/src/upgrade_mode.rs @@ -0,0 +1,284 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; +use nym_crypto::asymmetric::ed25519; +use nym_upgrade_mode_check::{ + CREDENTIAL_PROXY_JWT_ISSUER, UpgradeModeAttestation, validate_upgrade_mode_jwt, +}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::time::Duration; +use thiserror::Error; +use time::OffsetDateTime; +use tokio::sync::{Notify, RwLock}; +use tracing::{debug, error}; + +#[derive(Debug, Error)] +pub enum UpgradeModeEnableError { + #[error("too soon to perform another upgrade mode attestation check")] + TooManyRecheckRequests, + + #[error("provided upgrade mode JWT is invalid: {0}")] + InvalidUpgradeModeJWT(#[from] nym_upgrade_mode_check::UpgradeModeCheckError), + + #[error("the upgrade mode attestation does not appear to have been published")] + AttestationNotPublished, + + #[error("the provided upgrade mode attestation is different from the published one")] + MismatchedUpgradeModeAttestation, +} + +// the idea behind this is as follows: +// it's been relatively a long time since the watcher last performed its checks (since it's in 'regular' mode) +// and some client has just sent a JWT. we have to retrieve most recent information in case upgrade mode +// has just been enabled, and we haven't learned about it yet +#[derive(Clone)] +pub struct UpgradeModeCheckRequestSender(Option>); + +impl UpgradeModeCheckRequestSender { + pub fn new(sender: UnboundedSender) -> Self { + UpgradeModeCheckRequestSender(Some(sender)) + } + + pub fn new_empty() -> Self { + Self(None) + } + + pub(crate) fn send_request(&self, on_done: Arc) { + let Some(ref inner) = self.0 else { + // make sure the caller gets notified so it doesn't wait forever + on_done.notify_waiters(); + return; + }; + + if let Err(not_sent) = inner.unbounded_send(CheckRequest { on_done }) { + debug!("failed to send upgrade mode check request - {not_sent}"); + // make sure the caller gets notified so it doesn't wait forever + not_sent.into_inner().on_done.notify_waiters(); + } + } +} + +pub type UpgradeModeCheckRequestReceiver = UnboundedReceiver; + +pub struct CheckRequest { + on_done: Arc, +} + +impl CheckRequest { + pub fn finalize(self) { + self.on_done.notify_waiters(); + } +} + +#[derive(Clone, Copy)] +pub struct UpgradeModeCheckConfig { + /// The minimum duration since the last explicit check to allow creation of separate request. + pub min_staleness_recheck: Duration, +} + +/// Full upgrade mode information, that apart from boolean flag indicating the state +/// and the attestation information, includes channel connection to relevant +/// attestation watcher to request state rechecks +#[derive(Clone)] +pub struct UpgradeModeDetails { + pub(crate) config: UpgradeModeCheckConfig, + pub(crate) request_checker: UpgradeModeCheckRequestSender, + pub(crate) state: UpgradeModeState, +} + +impl UpgradeModeDetails { + pub fn new( + config: UpgradeModeCheckConfig, + request_checker: UpgradeModeCheckRequestSender, + state: UpgradeModeState, + ) -> Self { + UpgradeModeDetails { + config, + request_checker, + state, + } + } + + pub fn enabled(&self) -> bool { + self.state.upgrade_mode_enabled() + } + + fn since_last_query(&self) -> Duration { + self.state.since_last_query() + } + + pub fn can_request_recheck(&self) -> bool { + self.since_last_query() > self.config.min_staleness_recheck + } + + // explicitly request state update. this is only called when upgrade mode is NOT enabled, + // and client has sent a JWT instead of ticket + async fn request_recheck(&self) -> bool { + // send request + let on_done = Arc::new(Notify::new()); + self.request_checker.send_request(on_done.clone()); + + // wait for response - note, if we fail to send, notification will be sent regardless, + // so that we wouldn't get stuck in here + on_done.notified().await; + + // check the state again + self.enabled() + } + + pub async fn try_enable_via_received_jwt( + &self, + token: String, + ) -> Result<(), UpgradeModeEnableError> { + // see if it's viable to perform another expedited check + if !self.can_request_recheck() { + return Err(UpgradeModeEnableError::TooManyRecheckRequests); + } + + // first validate whether the received JWT is even valid + let attestation = validate_upgrade_mode_jwt(&token, Some(CREDENTIAL_PROXY_JWT_ISSUER))?; + + // send request to revalidate internal state + // this will, among other things, pull fresh attestation from the configured endpoint + // and also verify required signatures (and pubkeys) + self.request_recheck().await; + + // not strictly necessary, but check if provided attestation actually matches the one retrieved + // (if any) + let Some(retrieved_attestation) = self.state.attestation().await else { + return Err(UpgradeModeEnableError::AttestationNotPublished); + }; + if retrieved_attestation != attestation { + return Err(UpgradeModeEnableError::MismatchedUpgradeModeAttestation); + } + + // note: if attestation has been returned, it means we're definitely in upgrade mode + // (otherwise it wouldn't have existed in the state) + + Ok(()) + } +} + +/// Detailed upgrade mode information, that apart from boolean flag, +/// also includes, if applicable, the associated attestation +#[derive(Clone)] +pub struct UpgradeModeState { + inner: Arc, +} + +/// Just a shareable flag to indicate whether upgrade mode is enabled or disabled +#[derive(Clone, Default)] +pub struct UpgradeModeStatus(Arc); + +impl UpgradeModeStatus { + pub fn enabled(&self) -> bool { + self.0.load(Ordering::Acquire) + } + + pub fn enable(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn disable(&self) { + self.0.store(false, Ordering::Release); + } +} + +impl UpgradeModeState { + pub fn new(attester_public_key: ed25519::PublicKey) -> UpgradeModeState { + UpgradeModeState { + inner: Arc::new(UpgradeModeStateInner { + expected_attester_public_key: attester_public_key, + expected_attestation: RwLock::new(None), + last_queried_ts: AtomicI64::new(OffsetDateTime::UNIX_EPOCH.unix_timestamp()), + status: UpgradeModeStatus(Arc::new(AtomicBool::new(false))), + }), + } + } + + pub async fn attestation(&self) -> Option { + self.inner.expected_attestation.read().await.clone() + } + + pub async fn try_set_expected_attestation( + &self, + expected_attestation: Option, + ) { + // make sure to only enable upgrade mode flag AFTER we have written the expected value + // (or still hold the exclusive lock as in this instance) + let mut guard = self.inner.expected_attestation.write().await; + + // ensure that the attestation had been signed with the expected key + if let Some(attestation) = expected_attestation.as_ref() { + if attestation.content.attester_public_key != self.inner.expected_attester_public_key { + self.update_last_queried(OffsetDateTime::now_utc()); + return; + } + + self.enable_upgrade_mode() + } else { + self.disable_upgrade_mode() + } + + self.update_last_queried(OffsetDateTime::now_utc()); + *guard = expected_attestation; + } + + pub fn upgrade_mode_status(&self) -> UpgradeModeStatus { + self.inner.status.clone() + } + + pub fn upgrade_mode_enabled(&self) -> bool { + self.inner.status.enabled() + } + + pub fn enable_upgrade_mode(&self) { + self.inner.status.enable() + } + + pub fn disable_upgrade_mode(&self) { + self.inner.status.disable() + } + + pub fn last_queried(&self) -> OffsetDateTime { + // SAFETY: the stored value here is always a valid unix timestamp + #[allow(clippy::unwrap_used)] + OffsetDateTime::from_unix_timestamp(self.inner.last_queried_ts.load(Ordering::Acquire)) + .unwrap() + } + + pub fn update_last_queried(&self, queried_at: OffsetDateTime) { + self.inner + .last_queried_ts + .store(queried_at.unix_timestamp(), Ordering::Release); + } + + pub fn since_last_query(&self) -> Duration { + (OffsetDateTime::now_utc() - self.last_queried()) + .try_into() + .unwrap_or_else(|_| { + error!("somehow our last query for upgrade mode was in the future!"); + Duration::ZERO + }) + } +} + +struct UpgradeModeStateInner { + /// Expected public key of the entity issuing upgrade mode attestations. + expected_attester_public_key: ed25519::PublicKey, + + /// Contents of the published upgrade mode attestation, as queried by this node + expected_attestation: RwLock>, + + /// timestamp indicating last time this node has queried for the current upgrade mode attestation + /// it is used to determine if an additional expedited query should be made in case client sends a JWT + /// whilst this node is not aware of the upgrade mode + last_queried_ts: AtomicI64, + + /// flag indicating whether upgrade mode is currently enabled. this is to perform cheap checks + /// that avoid having to acquire the lock + // (and dealing with the async consequences of that) + status: UpgradeModeStatus, +} diff --git a/common/credentials-interface/Cargo.toml b/common/credentials-interface/Cargo.toml index d6d4ce842b..57a88ea603 100644 --- a/common/credentials-interface/Cargo.toml +++ b/common/credentials-interface/Cargo.toml @@ -23,3 +23,5 @@ rand = { workspace = true } nym-compact-ecash = { path = "../nym_offline_compact_ecash" } nym-ecash-time = { path = "../ecash-time" } nym-network-defaults = { path = "../network-defaults" } +nym-upgrade-mode-check = { path = "../upgrade-mode-check" } + diff --git a/common/credentials-interface/src/lib.rs b/common/credentials-interface/src/lib.rs index 91e08ee770..807016792b 100644 --- a/common/credentials-interface/src/lib.rs +++ b/common/credentials-interface/src/lib.rs @@ -30,6 +30,35 @@ pub use nym_compact_ecash::{ }; pub use nym_ecash_time::{EcashTime, ecash_today}; pub use nym_network_defaults::TicketTypeRepr; +use nym_network_defaults::TicketTypeRepr::V1MixnetEntry; + +/// Default bandwidth amount under which [mixnet] clients will attempt to send additional zk-nyms +/// to increase their allowance. +// currently defined as 20% of entry ticket value +// clients are, of course, free to override this value +pub const DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD: i64 = + (V1MixnetEntry.bandwidth_value() / 5) as i64; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum BandwidthCredential { + ZkNym(Box), + UpgradeModeJWT { token: String }, +} + +impl BandwidthCredential { + pub fn into_zk_nym(self) -> Option> { + match self { + BandwidthCredential::ZkNym(credential) => Some(credential), + _ => None, + } + } +} + +impl From for BandwidthCredential { + fn from(credential: CredentialSpendingData) -> Self { + Self::ZkNym(Box::new(credential)) + } +} #[derive(Debug, Clone)] pub struct CredentialSigningData { diff --git a/common/gateway-requests/Cargo.toml b/common/gateway-requests/Cargo.toml index 83042ead14..65a5704fdc 100644 --- a/common/gateway-requests/Cargo.toml +++ b/common/gateway-requests/Cargo.toml @@ -51,3 +51,6 @@ anyhow = { workspace = true } nym-compact-ecash = { path = "../nym_offline_compact_ecash" } # we need specific imports in tests nym-test-utils = { path = "../test-utils" } tokio = { workspace = true, features = ["full"] } + +[lints] +workspace = true \ No newline at end of file diff --git a/common/gateway-requests/src/lib.rs b/common/gateway-requests/src/lib.rs index bdb9a30a0f..a0f44b20fc 100644 --- a/common/gateway-requests/src/lib.rs +++ b/common/gateway-requests/src/lib.rs @@ -19,7 +19,9 @@ pub use shared_key::{ SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey, }; -pub const CURRENT_PROTOCOL_VERSION: u8 = EMBEDDED_KEY_ROTATION_INFO_VERSION; +pub type GatewayProtocolVersion = u8; + +pub const CURRENT_PROTOCOL_VERSION: GatewayProtocolVersion = UPGRADE_MODE_VERSION; /// Defines the current version of the communication protocol between gateway and clients. /// It has to be incremented for any breaking change. @@ -29,35 +31,73 @@ pub const CURRENT_PROTOCOL_VERSION: u8 = EMBEDDED_KEY_ROTATION_INFO_VERSION; // 3 - change to AES-GCM-SIV and non-zero IVs // 4 - introduction of v2 authentication protocol to prevent reply attacks // 5 - add key rotation information to the serialised mix packet -pub const INITIAL_PROTOCOL_VERSION: u8 = 1; -pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: u8 = 2; -pub const AES_GCM_SIV_PROTOCOL_VERSION: u8 = 3; -pub const AUTHENTICATE_V2_PROTOCOL_VERSION: u8 = 4; -pub const EMBEDDED_KEY_ROTATION_INFO_VERSION: u8 = 5; +// 6 - support for 'upgrade mode' +pub const INITIAL_PROTOCOL_VERSION: GatewayProtocolVersion = 1; +pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: GatewayProtocolVersion = 2; +pub const AES_GCM_SIV_PROTOCOL_VERSION: GatewayProtocolVersion = 3; +pub const AUTHENTICATE_V2_PROTOCOL_VERSION: GatewayProtocolVersion = 4; +pub const EMBEDDED_KEY_ROTATION_INFO_VERSION: GatewayProtocolVersion = 5; +pub const UPGRADE_MODE_VERSION: GatewayProtocolVersion = 6; // TODO: could using `Mac` trait here for OutputSize backfire? // Should hmac itself be exposed, imported and used instead? pub type LegacyGatewayMacSize = ::OutputSize; pub trait GatewayProtocolVersionExt { + const CURRENT: GatewayProtocolVersion = CURRENT_PROTOCOL_VERSION; + fn supports_aes256_gcm_siv(&self) -> bool; fn supports_authenticate_v2(&self) -> bool; fn supports_key_rotation_packet(&self) -> bool; + fn supports_upgrade_mode(&self) -> bool; + fn is_future_version(&self) -> bool; } -impl GatewayProtocolVersionExt for Option { +impl GatewayProtocolVersionExt for Option { fn supports_aes256_gcm_siv(&self) -> bool { - let Some(protocol) = *self else { return false }; - protocol >= AES_GCM_SIV_PROTOCOL_VERSION + let Some(protocol) = self else { return false }; + protocol.supports_aes256_gcm_siv() } fn supports_authenticate_v2(&self) -> bool { - let Some(protocol) = *self else { return false }; - protocol >= AUTHENTICATE_V2_PROTOCOL_VERSION + let Some(protocol) = self else { return false }; + protocol.supports_authenticate_v2() } fn supports_key_rotation_packet(&self) -> bool { - let Some(protocol) = *self else { return false }; - protocol >= EMBEDDED_KEY_ROTATION_INFO_VERSION + let Some(protocol) = self else { return false }; + protocol.supports_key_rotation_packet() + } + + fn supports_upgrade_mode(&self) -> bool { + let Some(protocol) = self else { return false }; + protocol.supports_upgrade_mode() + } + + fn is_future_version(&self) -> bool { + let Some(protocol) = self else { return false }; + protocol.is_future_version() + } +} + +impl GatewayProtocolVersionExt for GatewayProtocolVersion { + fn supports_aes256_gcm_siv(&self) -> bool { + *self >= AES_GCM_SIV_PROTOCOL_VERSION + } + + fn supports_authenticate_v2(&self) -> bool { + *self >= AUTHENTICATE_V2_PROTOCOL_VERSION + } + + fn supports_key_rotation_packet(&self) -> bool { + *self >= EMBEDDED_KEY_ROTATION_INFO_VERSION + } + + fn supports_upgrade_mode(&self) -> bool { + *self >= UPGRADE_MODE_VERSION + } + + fn is_future_version(&self) -> bool { + *self > CURRENT_PROTOCOL_VERSION } } diff --git a/common/gateway-requests/src/registration/handshake/client.rs b/common/gateway-requests/src/registration/handshake/client.rs index 549cddca39..7f2a3ccb19 100644 --- a/common/gateway-requests/src/registration/handshake/client.rs +++ b/common/gateway-requests/src/registration/handshake/client.rs @@ -3,10 +3,12 @@ use crate::registration::handshake::messages::{Finalization, GatewayMaterialExchange}; use crate::registration::handshake::state::State; -use crate::registration::handshake::SharedGatewayKey; +use crate::registration::handshake::HandshakeResult; use crate::registration::handshake::{error::HandshakeError, WsItem}; +use crate::{GatewayProtocolVersionExt, INITIAL_PROTOCOL_VERSION}; use futures::{Sink, Stream}; use rand::{CryptoRng, RngCore}; +use tracing::info; use tungstenite::Message as WsMessage; impl State<'_, S, R> { @@ -25,10 +27,26 @@ impl State<'_, S, R> { // 2. wait for response with remote x25519 pubkey as well as encrypted signature // <- g^y || AES(k, sig(gate_priv, (g^y || g^x)) || MAYBE_NONCE - let mid_res = self + let (mid_res, gateway_protocol) = self .receive_handshake_message::() .await?; + // NEGOTIATE PROTOCOL + if gateway_protocol.is_future_version() { + // SAFETY: future version means it's greater than CURRENT, which is always a `Some` + #[allow(clippy::unwrap_used)] + return Err(HandshakeError::UnsupportedProtocol { + version: gateway_protocol.unwrap(), + }); + } + let gateway_protocol = gateway_protocol.unwrap_or(INITIAL_PROTOCOL_VERSION); + + // that should never happen, but we're fine with that outcome + if Some(gateway_protocol) != self.proposed_protocol_version() { + info!("the gateway insists on protocol version different from the one we suggested. it wants {gateway_protocol} whilst we wanted {:?}, however, we can support it", self.proposed_protocol_version()); + self.set_protocol_version(gateway_protocol); + } + // 3. derive shared keys locally // hkdf::::(g^xy) self.derive_shared_key(&mid_res.ephemeral_dh, maybe_hkdf_salt.as_deref()); @@ -42,14 +60,14 @@ impl State<'_, S, R> { self.send_handshake_data(materials).await?; // 6. wait for remote confirmation of finalizing the handshake - let finalization = self.receive_handshake_message::().await?; + let (finalization, _) = self.receive_handshake_message::().await?; finalization.ensure_success()?; Ok(()) } pub(crate) async fn perform_client_handshake( mut self, - ) -> Result + ) -> Result where S: Stream + Sink + Unpin, R: CryptoRng + RngCore, diff --git a/common/gateway-requests/src/registration/handshake/error.rs b/common/gateway-requests/src/registration/handshake/error.rs index 8cc9cf1c0d..67dc02d009 100644 --- a/common/gateway-requests/src/registration/handshake/error.rs +++ b/common/gateway-requests/src/registration/handshake/error.rs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use crate::shared_key::SharedKeyUsageError; +use crate::GatewayProtocolVersion; +use crate::GatewayProtocolVersionExt; use thiserror::Error; #[derive(Debug, Error)] @@ -34,4 +36,10 @@ pub enum HandshakeError { #[error("timed out waiting for a handshake message")] Timeout, + + #[error("Connection is in an invalid state - please send a bug report")] + ConnectionInInvalidState, + + #[error("the gateway requests protocol version that's not supported by this client. it wants to use v{version} whilst we only understand up to v{}", GatewayProtocolVersion::CURRENT)] + UnsupportedProtocol { version: GatewayProtocolVersion }, } diff --git a/common/gateway-requests/src/registration/handshake/gateway.rs b/common/gateway-requests/src/registration/handshake/gateway.rs index 5fec717c46..cea8fd67de 100644 --- a/common/gateway-requests/src/registration/handshake/gateway.rs +++ b/common/gateway-requests/src/registration/handshake/gateway.rs @@ -5,9 +5,11 @@ use crate::registration::handshake::messages::{ HandshakeMessage, Initialisation, MaterialExchange, }; use crate::registration::handshake::state::State; -use crate::registration::handshake::SharedGatewayKey; +use crate::registration::handshake::HandshakeResult; use crate::registration::handshake::{error::HandshakeError, WsItem}; +use crate::{GatewayProtocolVersion, GatewayProtocolVersionExt}; use futures::{Sink, Stream}; +use tracing::{debug, warn}; use tungstenite::Message as WsMessage; impl State<'_, S, R> { @@ -18,11 +20,39 @@ impl State<'_, S, R> { where S: Stream + Sink + Unpin, { + // NEGOTIATE PROTOCOL + // old clients were sending protocol version as defined by the following: + /* + fn request_protocol_version(&self) -> u8 { + if self.derive_aes256_gcm_siv_key { + AES_GCM_SIV_PROTOCOL_VERSION + } else if self.expects_credential_usage { + CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION + } else { + INITIAL_PROTOCOL_VERSION + } + } + */ + // meaning the highest possible value they could have sent was `4` (AUTHENTICATE_V2_PROTOCOL_VERSION) + // so if we received anything higher than that, it means they understand negotiation. + // currently not strictly needed as we just blindly accept what they proposed, + // but will be needed in the future. + if self.proposed_protocol_version().is_future_version() { + // this should never happen in a non-malicious client as it should use at most whatever version this gateway has announced + self.set_protocol_version(GatewayProtocolVersion::CURRENT) + } else { + // currently we accept all protocols, i.e. legacy keys, aes128, etc. so we downgrade to whatever + // the client has proposed. this will change in the future + debug!( + "using the protocol version proposed by the client: {:?}", + self.proposed_protocol_version() + ) + } + // 1. receive remote ed25519 pubkey alongside ephemeral x25519 pubkey and maybe a flag indicating non-legacy client // LOCAL_ID_PUBKEY || EPHEMERAL_KEY || MAYBE_NON_LEGACY let init_message = Initialisation::try_from_bytes(&raw_init_message)?; self.update_remote_identity(init_message.identity); - self.set_aes256_gcm_siv_key_derivation(!init_message.is_legacy()); // 2. derive shared keys locally // hkdf::::(g^xy) @@ -39,7 +69,12 @@ impl State<'_, S, R> { self.send_handshake_data(material).await?; // 4. wait for the remote response with their own encrypted signature - let materials = self.receive_handshake_message::().await?; + let (materials, client_protocol) = + self.receive_handshake_message::().await?; + if client_protocol != self.proposed_protocol_version() { + warn!("the client hasn't accepted our proposed protocol version. we suggested {:?} while it returned {client_protocol:?}", self.proposed_protocol_version()); + // TBD what to do here + } // 5. verify the received signature using the locally derived keys self.verify_remote_key_material(&materials, &init_message.ephemeral_dh)?; @@ -54,7 +89,7 @@ impl State<'_, S, R> { pub(crate) async fn perform_gateway_handshake( mut self, raw_init_message: Vec, - ) -> Result + ) -> Result where S: Stream + Sink + Unpin, { diff --git a/common/gateway-requests/src/registration/handshake/messages.rs b/common/gateway-requests/src/registration/handshake/messages.rs index 10dda7edaf..9958c17ba1 100644 --- a/common/gateway-requests/src/registration/handshake/messages.rs +++ b/common/gateway-requests/src/registration/handshake/messages.rs @@ -24,13 +24,6 @@ pub struct Initialisation { pub initiator_salt: Option>, } -impl Initialisation { - #[cfg(not(target_arch = "wasm32"))] - pub fn is_legacy(&self) -> bool { - self.initiator_salt.is_none() - } -} - #[derive(Debug)] pub struct MaterialExchange { pub signature_ciphertext: Vec, @@ -99,8 +92,9 @@ impl HandshakeMessage for Initialisation { let identity = ed25519::PublicKey::from_bytes(&bytes[..ed25519::PUBLIC_KEY_LENGTH]) .map_err(|_| HandshakeError::MalformedRequest)?; - // this can only fail if the provided bytes have len different from encryption::PUBLIC_KEY_SIZE + // SAFETY: this can only fail if the provided bytes have len different from encryption::PUBLIC_KEY_SIZE // which is impossible + #[allow(clippy::unwrap_used)] let ephemeral_dh = x25519::PublicKey::from_bytes(&bytes[ed25519::PUBLIC_KEY_LENGTH..legacy_len]).unwrap(); @@ -194,6 +188,7 @@ impl HandshakeMessage for GatewayMaterialExchange { // this can only fail if the provided bytes have len different from PUBLIC_KEY_SIZE // which is impossible + #[allow(clippy::unwrap_used)] let ephemeral_dh = x25519::PublicKey::from_bytes(&bytes[..x25519::PUBLIC_KEY_SIZE]).unwrap(); let materials = MaterialExchange::try_from_bytes(&bytes[x25519::PUBLIC_KEY_SIZE..])?; diff --git a/common/gateway-requests/src/registration/handshake/mod.rs b/common/gateway-requests/src/registration/handshake/mod.rs index 2daed81b48..8b1972f921 100644 --- a/common/gateway-requests/src/registration/handshake/mod.rs +++ b/common/gateway-requests/src/registration/handshake/mod.rs @@ -3,7 +3,7 @@ use self::error::HandshakeError; use crate::registration::handshake::state::State; -use crate::SharedGatewayKey; +use crate::{GatewayProtocolVersion, SharedGatewayKey}; use futures::future::BoxFuture; use futures::{Sink, Stream}; use nym_crypto::asymmetric::ed25519; @@ -34,24 +34,29 @@ pub const KDF_SALT_LENGTH: usize = 16; // we do not need to worry about that. pub struct GatewayHandshake<'a> { - handshake_future: BoxFuture<'a, Result>, + handshake_future: BoxFuture<'a, Result>, } impl Future for GatewayHandshake<'_> { - type Output = Result; + type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { Pin::new(&mut self.handshake_future).poll(cx) } } +#[derive(Debug, PartialEq)] +pub struct HandshakeResult { + pub negotiated_protocol: GatewayProtocolVersion, + pub derived_key: SharedGatewayKey, +} + pub fn client_handshake<'a, S, R>( rng: &'a mut R, ws_stream: &'a mut S, identity: &'a ed25519::KeyPair, gateway_pubkey: ed25519::PublicKey, - expects_credential_usage: bool, - derive_aes256_gcm_siv_key: bool, + gateway_protocol: Option, #[cfg(not(target_arch = "wasm32"))] shutdown_token: ShutdownToken, ) -> GatewayHandshake<'a> where @@ -63,11 +68,10 @@ where ws_stream, identity, Some(gateway_pubkey), + gateway_protocol, #[cfg(not(target_arch = "wasm32"))] shutdown_token, - ) - .with_credential_usage(expects_credential_usage) - .with_aes256_gcm_siv_key(derive_aes256_gcm_siv_key); + ); GatewayHandshake { handshake_future: Box::pin(state.perform_client_handshake()), @@ -80,13 +84,21 @@ pub fn gateway_handshake<'a, S, R>( ws_stream: &'a mut S, identity: &'a ed25519::KeyPair, received_init_payload: Vec, + requested_client_protocol: Option, shutdown_token: ShutdownToken, ) -> GatewayHandshake<'a> where S: Stream + Sink + Unpin + Send + 'a, R: CryptoRng + RngCore + Send, { - let state = State::new(rng, ws_stream, identity, None, shutdown_token); + let state = State::new( + rng, + ws_stream, + identity, + None, + requested_client_protocol, + shutdown_token, + ); GatewayHandshake { handshake_future: Box::pin(state.perform_gateway_handshake(received_init_payload)), } @@ -113,7 +125,8 @@ DONE(status) #[cfg(test)] mod tests { use super::*; - use crate::ClientControlRequest; + use crate::{ClientControlRequest, CURRENT_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION}; + use anyhow::{bail, Context}; use futures::StreamExt; use nym_test_utils::helpers::u64_seeded_rng; use nym_test_utils::mocks::stream_sink::mock_streams; @@ -121,10 +134,53 @@ mod tests { use tokio::join; use tungstenite::Message; - #[tokio::test] - async fn basic_handshake() -> anyhow::Result<()> { - use anyhow::Context as _; + trait ClientControlRequestExt { + async fn get_handshake_init_data(&mut self) -> anyhow::Result> { + let ClientControlRequest::RegisterHandshakeInitRequest { + protocol_version: _, + data, + } = self.get_control_request().await? + else { + bail!("unexpected ClientControlRequest") + }; + Ok(data) + } + async fn get_control_request(&mut self) -> anyhow::Result; + } + impl ClientControlRequestExt for T + where + T: Stream + Unpin, + { + async fn get_control_request(&mut self) -> anyhow::Result { + let msg = self + .next() + .timeboxed() + .await + .context("timeout")? + .context("no message!")?? + .into_text()? + .parse::()?; + Ok(msg) + } + } + + struct Party { + rng: &'static mut R, + keys: &'static mut ed25519::KeyPair, + socket: &'static mut S, + } + + fn setup() -> ( + Party< + impl CryptoRng + RngCore + Send, + impl Stream + Sink + Unpin, + >, + Party< + impl CryptoRng + RngCore + Send, + impl Stream + Sink + Unpin, + >, + ) { // solve the lifetime issue by just leaking the contents of the boxes // which is perfectly fine in test let client_rng = u64_seeded_rng(42).leak(); @@ -142,51 +198,139 @@ mod tests { let client_ws = client_ws.leak(); let gateway_ws = gateway_ws.leak(); + ( + Party { + rng: client_rng, + keys: client_keys, + socket: client_ws, + }, + Party { + rng: gateway_rng, + keys: gateway_keys, + socket: gateway_ws, + }, + ) + } + + #[tokio::test] + async fn basic_handshake() -> anyhow::Result<()> { + let (client, gateway) = setup(); + let handshake_client = client_handshake( - client_rng, - client_ws, - client_keys, - *gateway_keys.public_key(), - false, - true, + client.rng, + client.socket, + client.keys, + *gateway.keys.public_key(), + Some(CURRENT_PROTOCOL_VERSION), ShutdownToken::default(), ); let client_fut = handshake_client.spawn_timeboxed(); // we need to receive the first message so that it could be propagated to the gateway side of the handshake - let ClientControlRequest::RegisterHandshakeInitRequest { - protocol_version: _, - data, - } = (gateway_ws.next()) - .timeboxed() - .await - .context("timeout")? - .context("no message!")?? - .into_text()? - .parse::()? - else { - panic!("bad message") - }; - - let init_msg = data; + let init_msg = gateway.socket.get_handshake_init_data().await?; let handshake_gateway = gateway_handshake( - gateway_rng, - gateway_ws, - gateway_keys, + gateway.rng, + gateway.socket, + gateway.keys, init_msg, + Some(CURRENT_PROTOCOL_VERSION), ShutdownToken::default(), ); let gateway_fut = handshake_gateway.spawn_timeboxed(); let (client, gateway) = join!(client_fut, gateway_fut); - let client_key = client???; - let gateway_key = gateway???; + let client_res = client???; + let gateway_res = gateway???; // ensure the created keys are the same - assert_eq!(client_key, gateway_key); + assert_eq!(client_res, gateway_res); + assert_eq!(client_res.negotiated_protocol, CURRENT_PROTOCOL_VERSION); + + Ok(()) + } + + #[tokio::test] + async fn protocol_downgrade() -> anyhow::Result<()> { + let (client, gateway) = setup(); + + let handshake_client = client_handshake( + client.rng, + client.socket, + client.keys, + *gateway.keys.public_key(), + Some(CURRENT_PROTOCOL_VERSION + 42), + ShutdownToken::default(), + ); + + let client_fut = handshake_client.spawn_timeboxed(); + // we need to receive the first message so that it could be propagated to the gateway side of the handshake + let init_msg = gateway.socket.get_handshake_init_data().await?; + + let handshake_gateway = gateway_handshake( + gateway.rng, + gateway.socket, + gateway.keys, + init_msg, + Some(CURRENT_PROTOCOL_VERSION + 42), + ShutdownToken::default(), + ); + + let gateway_fut = handshake_gateway.spawn_timeboxed(); + let (client, gateway) = join!(client_fut, gateway_fut); + + let client_res = client???; + let gateway_res = gateway???; + + // ensure the created keys are the same + assert_eq!(client_res, gateway_res); + + // and the protocol got downgraded for both parties + assert_eq!(client_res.negotiated_protocol, CURRENT_PROTOCOL_VERSION); + + Ok(()) + } + + #[tokio::test] + async fn protocol_upgrade() -> anyhow::Result<()> { + let (client, gateway) = setup(); + + let handshake_client = client_handshake( + client.rng, + client.socket, + client.keys, + *gateway.keys.public_key(), + None, + ShutdownToken::default(), + ); + + let client_fut = handshake_client.spawn_timeboxed(); + + // we need to receive the first message so that it could be propagated to the gateway side of the handshake + let init_msg = gateway.socket.get_handshake_init_data().await?; + + let handshake_gateway = gateway_handshake( + gateway.rng, + gateway.socket, + gateway.keys, + init_msg, + None, + ShutdownToken::default(), + ); + + let gateway_fut = handshake_gateway.spawn_timeboxed(); + let (client, gateway) = join!(client_fut, gateway_fut); + + let client_res = client???; + let gateway_res = gateway???; + + // ensure the created keys are the same + assert_eq!(client_res, gateway_res); + + // and the protocol got upgraded to the first known version + assert_eq!(client_res.negotiated_protocol, INITIAL_PROTOCOL_VERSION); Ok(()) } diff --git a/common/gateway-requests/src/registration/handshake/state.rs b/common/gateway-requests/src/registration/handshake/state.rs index 3d7b29e3f9..1e51fda37b 100644 --- a/common/gateway-requests/src/registration/handshake/state.rs +++ b/common/gateway-requests/src/registration/handshake/state.rs @@ -5,11 +5,11 @@ use crate::registration::handshake::error::HandshakeError; use crate::registration::handshake::messages::{ HandshakeMessage, Initialisation, MaterialExchange, }; -use crate::registration::handshake::{SharedGatewayKey, WsItem, KDF_SALT_LENGTH}; +use crate::registration::handshake::{HandshakeResult, SharedGatewayKey, WsItem, KDF_SALT_LENGTH}; use crate::shared_key::SharedKeySize; use crate::{ - types, LegacySharedKeySize, LegacySharedKeys, SharedSymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION, - CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION, + types, GatewayProtocolVersion, GatewayProtocolVersionExt, LegacySharedKeySize, + LegacySharedKeys, SharedSymmetricKey, INITIAL_PROTOCOL_VERSION, }; use futures::{Sink, SinkExt, Stream, StreamExt}; use nym_crypto::asymmetric::{ed25519, x25519}; @@ -54,12 +54,11 @@ pub(crate) struct State<'a, S, R> { /// Ideally it would always be known before the handshake was initiated. remote_pubkey: Option, - // this field is really out of place here, however, we need to propagate this information somehow - // in order to establish correct protocol for backwards compatibility reasons - expects_credential_usage: bool, - - /// Specifies whether the end product should be an AES128Ctr + blake3 HMAC keys (legacy) or AES256-GCM-SIV (current) - derive_aes256_gcm_siv_key: bool, + /// Version of the protocol to use during the handshake that also implicitly specifies + /// additional features such as the type of derived shared keys, i.e. + /// AES128Ctr + blake3 HMAC keys (legacy) or AES256-GCM-SIV (current) + /// the above is decided by whether the specified protocol version supports the new variant or not. + protocol_version: Option, // channel to receive shutdown signal #[cfg(not(target_arch = "wasm32"))] @@ -72,6 +71,7 @@ impl<'a, S, R> State<'a, S, R> { ws_stream: &'a mut S, identity: &'a ed25519::KeyPair, remote_pubkey: Option, + protocol_version: Option, #[cfg(not(target_arch = "wasm32"))] shutdown_token: ShutdownToken, ) -> Self where @@ -84,40 +84,31 @@ impl<'a, S, R> State<'a, S, R> { ephemeral_keypair, identity, remote_pubkey, + protocol_version, derived_shared_keys: None, - // later on this should become the default - expects_credential_usage: false, - derive_aes256_gcm_siv_key: false, #[cfg(not(target_arch = "wasm32"))] shutdown_token, } } - pub(crate) fn with_credential_usage(mut self, expects_credential_usage: bool) -> Self { - self.expects_credential_usage = expects_credential_usage; - self - } - - pub(crate) fn with_aes256_gcm_siv_key(mut self, derive_aes256_gcm_siv_key: bool) -> Self { - self.derive_aes256_gcm_siv_key = derive_aes256_gcm_siv_key; - self - } - - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn set_aes256_gcm_siv_key_derivation(&mut self, derive_aes256_gcm_siv_key: bool) { - self.derive_aes256_gcm_siv_key = derive_aes256_gcm_siv_key; - } - #[cfg(not(target_arch = "wasm32"))] pub(crate) fn local_ephemeral_key(&self) -> &x25519::PublicKey { self.ephemeral_keypair.public_key() } + pub(crate) fn proposed_protocol_version(&self) -> Option { + self.protocol_version + } + + pub(crate) fn set_protocol_version(&mut self, protocol_version: GatewayProtocolVersion) { + self.protocol_version = Some(protocol_version); + } + pub(crate) fn maybe_generate_initiator_salt(&mut self) -> Option> where R: CryptoRng + RngCore, { - if self.derive_aes256_gcm_siv_key { + if self.protocol_version.supports_aes256_gcm_siv() { let mut salt = vec![0u8; KDF_SALT_LENGTH]; self.rng.fill_bytes(&mut salt); Some(salt) @@ -154,13 +145,14 @@ impl<'a, S, R> State<'a, S, R> { .private_key() .diffie_hellman(remote_ephemeral_key); - let key_size = if self.derive_aes256_gcm_siv_key { + let key_size = if self.protocol_version.supports_aes256_gcm_siv() { SharedKeySize::to_usize() } else { LegacySharedKeySize::to_usize() }; - // there is no reason for this to fail as our okm is expected to be only 16 bytes + // SAFETY: there is no reason for this to fail as our okm is expected to be only 16 bytes + #[allow(clippy::expect_used)] let okm = hkdf::extract_then_expand::( initiator_salt, &dh_result, @@ -169,11 +161,14 @@ impl<'a, S, R> State<'a, S, R> { ) .expect("somehow too long okm was provided"); - let shared_key = if self.derive_aes256_gcm_siv_key { + // SAFETY: the okm has been expanded to the length expected by the corresponding keys + let shared_key = if self.protocol_version.supports_aes256_gcm_siv() { + #[allow(clippy::expect_used)] let current_key = SharedSymmetricKey::try_from_bytes(&okm) .expect("okm was expanded to incorrect length!"); SharedGatewayKey::Current(current_key) } else { + #[allow(clippy::expect_used)] let legacy_key = LegacySharedKeys::try_from_bytes(&okm) .expect("okm was expanded to incorrect length!"); SharedGatewayKey::Legacy(legacy_key) @@ -196,7 +191,7 @@ impl<'a, S, R> State<'a, S, R> { .collect(); let signature = self.identity.private_key().sign(plaintext); - let nonce = if self.derive_aes256_gcm_siv_key { + let nonce = if self.protocol_version.supports_aes256_gcm_siv() { let mut rng = thread_rng(); Some(random_nonce::(&mut rng).to_vec()) } else { @@ -204,6 +199,7 @@ impl<'a, S, R> State<'a, S, R> { }; // SAFETY: this function is only called after the local key has already been derived + #[allow(clippy::expect_used)] let signature_ciphertext = self .derived_shared_keys .as_ref() @@ -222,13 +218,14 @@ impl<'a, S, R> State<'a, S, R> { remote_ephemeral_key: &x25519::PublicKey, ) -> Result<(), HandshakeError> { // SAFETY: this function is only called after the local key has already been derived + #[allow(clippy::expect_used)] let derived_shared_key = self .derived_shared_keys .as_ref() .expect("shared key was not derived!"); // if the [client] init message contained non-legacy flag, the associated nonce MUST be present - if self.derive_aes256_gcm_siv_key && remote_response.nonce.is_none() { + if self.protocol_version.supports_aes256_gcm_siv() && remote_response.nonce.is_none() { return Err(HandshakeError::MissingNonceForCurrentKey); } @@ -249,6 +246,7 @@ impl<'a, S, R> State<'a, S, R> { .chain(self.ephemeral_keypair.public_key().to_bytes()) .collect(); + #[allow(clippy::unwrap_used)] self.remote_pubkey .as_ref() .unwrap() @@ -261,7 +259,10 @@ impl<'a, S, R> State<'a, S, R> { self.remote_pubkey = Some(remote_pubkey) } - fn on_wg_msg(msg: Option) -> Result>, HandshakeError> { + #[allow(clippy::complexity)] + fn on_wg_msg( + msg: Option, + ) -> Result, Option)>, HandshakeError> { let Some(msg) = msg else { return Err(HandshakeError::ClosedStream); }; @@ -277,9 +278,10 @@ impl<'a, S, R> State<'a, S, R> { // hehe, that's a bit disgusting that the type system requires we explicitly ignore the // protocol_version field that we actually never attach at this point // yet another reason for the overdue refactor - types::RegistrationHandshake::HandshakePayload { data, .. } => { - Ok(Some(data)) - } + types::RegistrationHandshake::HandshakePayload { + protocol_version, + data, + } => Ok(Some((data, protocol_version))), types::RegistrationHandshake::HandshakeError { message } => { Err(HandshakeError::RemoteError(message)) } @@ -299,7 +301,9 @@ impl<'a, S, R> State<'a, S, R> { } #[cfg(not(target_arch = "wasm32"))] - async fn _receive_handshake_message_bytes(&mut self) -> Result, HandshakeError> + async fn _receive_handshake_message_bytes( + &mut self, + ) -> Result<(Vec, Option), HandshakeError> where S: Stream + Unpin, { @@ -318,7 +322,9 @@ impl<'a, S, R> State<'a, S, R> { } #[cfg(target_arch = "wasm32")] - async fn _receive_handshake_message_bytes(&mut self) -> Result, HandshakeError> + async fn _receive_handshake_message_bytes( + &mut self, + ) -> Result<(Vec, Option), HandshakeError> where S: Stream + Unpin, { @@ -331,20 +337,22 @@ impl<'a, S, R> State<'a, S, R> { } } - pub(crate) async fn receive_handshake_message(&mut self) -> Result + pub(crate) async fn receive_handshake_message( + &mut self, + ) -> Result<(M, Option), HandshakeError> where S: Stream + Unpin, M: HandshakeMessage, { // TODO: make timeout duration configurable - let bytes = timeout( + let (bytes, protocol) = timeout( Duration::from_secs(5), self._receive_handshake_message_bytes(), ) .await .map_err(|_| HandshakeError::Timeout)??; - M::try_from_bytes(&bytes) + M::try_from_bytes(&bytes).map(|msg| (msg, protocol)) } // upon receiving this, the receiver should terminate the handshake @@ -357,21 +365,11 @@ impl<'a, S, R> State<'a, S, R> { { let handshake_message = types::RegistrationHandshake::new_error(message); self.ws_stream - .send(WsMessage::Text(handshake_message.try_into().unwrap())) + .send(WsMessage::Text(handshake_message.into())) .await .map_err(|_| HandshakeError::ClosedStream) } - fn request_protocol_version(&self) -> u8 { - if self.derive_aes256_gcm_siv_key { - AES_GCM_SIV_PROTOCOL_VERSION - } else if self.expects_credential_usage { - CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION - } else { - INITIAL_PROTOCOL_VERSION - } - } - pub(crate) async fn send_handshake_data( &mut self, inner_message: M, @@ -384,18 +382,25 @@ impl<'a, S, R> State<'a, S, R> { let handshake_message = types::RegistrationHandshake::new_payload( inner_message.into_bytes(), - self.request_protocol_version(), + self.protocol_version, ); self.ws_stream - .send(WsMessage::Text(handshake_message.try_into().unwrap())) + .send(WsMessage::Text(handshake_message.into())) .await .map_err(|_| HandshakeError::ClosedStream) } /// Finish the handshake, yielding the derived shared key and implicitly dropping all borrowed /// values. - pub(crate) fn finalize_handshake(self) -> SharedGatewayKey { - self.derived_shared_keys.unwrap() + pub(crate) fn finalize_handshake(self) -> HandshakeResult { + // SAFETY: handshake can't be finalised without deriving the shared keys + #[allow(clippy::unwrap_used)] + HandshakeResult { + negotiated_protocol: self + .proposed_protocol_version() + .unwrap_or(INITIAL_PROTOCOL_VERSION), + derived_key: self.derived_shared_keys.unwrap(), + } } // If any step along the way failed (that are non-network related), diff --git a/common/gateway-requests/src/shared_key/legacy.rs b/common/gateway-requests/src/shared_key/legacy.rs index 8fcf286697..6f40fcd2ff 100644 --- a/common/gateway-requests/src/shared_key/legacy.rs +++ b/common/gateway-requests/src/shared_key/legacy.rs @@ -43,6 +43,7 @@ impl LegacySharedKeys { rng.fill_bytes(&mut salt); let legacy_bytes = Zeroizing::new(self.to_bytes()); + #[allow(clippy::expect_used)] let okm = hkdf::extract_then_expand::( Some(&salt), &legacy_bytes, @@ -51,6 +52,7 @@ impl LegacySharedKeys { ) .expect("somehow too long okm was provided"); + #[allow(clippy::expect_used)] let key = SharedSymmetricKey::try_from_bytes(&okm) .expect("okm was expanded to incorrect length!"); (key, salt) @@ -62,6 +64,7 @@ impl LegacySharedKeys { expected_digest: &[u8], ) -> Option { let legacy_bytes = Zeroizing::new(self.to_bytes()); + #[allow(clippy::expect_used)] let okm = hkdf::extract_then_expand::( Some(salt), &legacy_bytes, @@ -69,6 +72,8 @@ impl LegacySharedKeys { SharedKeySize::to_usize(), ) .expect("somehow too long okm was provided"); + + #[allow(clippy::expect_used)] let key = SharedSymmetricKey::try_from_bytes(&okm) .expect("okm was expanded to incorrect length!"); if key.digest() != expected_digest { diff --git a/common/gateway-requests/src/shared_key/mod.rs b/common/gateway-requests/src/shared_key/mod.rs index b424fd49bf..c0a72135a3 100644 --- a/common/gateway-requests/src/shared_key/mod.rs +++ b/common/gateway-requests/src/shared_key/mod.rs @@ -47,6 +47,8 @@ impl SharedGatewayKey { } } + // it is responsibility of the caller to ensure the correct variant is present + #[allow(clippy::panic)] pub fn unwrap_legacy(&self) -> &LegacySharedKeys { match self { SharedGatewayKey::Current(_) => panic!("expected legacy key"), diff --git a/common/gateway-requests/src/types/error.rs b/common/gateway-requests/src/types/error.rs index edd8e41b22..bdbfe6fbac 100644 --- a/common/gateway-requests/src/types/error.rs +++ b/common/gateway-requests/src/types/error.rs @@ -13,7 +13,7 @@ use thiserror::Error; use time::OffsetDateTime; // specific errors (that should not be nested!!) for clients to match on -#[derive(Debug, Copy, Clone, Error, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, Error, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SimpleGatewayRequestsError { #[error("insufficient bandwidth available to process the request. required: {required}B, available: {available}B")] diff --git a/common/gateway-requests/src/types/registration_handshake_wrapper.rs b/common/gateway-requests/src/types/registration_handshake_wrapper.rs index 0dc6daa567..be75af675b 100644 --- a/common/gateway-requests/src/types/registration_handshake_wrapper.rs +++ b/common/gateway-requests/src/types/registration_handshake_wrapper.rs @@ -1,6 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::GatewayProtocolVersion; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -9,7 +10,7 @@ use std::str::FromStr; pub enum RegistrationHandshake { HandshakePayload { #[serde(default)] - protocol_version: Option, + protocol_version: Option, data: Vec, }, HandshakeError { @@ -18,9 +19,9 @@ pub enum RegistrationHandshake { } impl RegistrationHandshake { - pub fn new_payload(data: Vec, protocol_version: u8) -> Self { + pub fn new_payload(data: Vec, protocol_version: Option) -> Self { RegistrationHandshake::HandshakePayload { - protocol_version: Some(protocol_version), + protocol_version, data, } } @@ -48,11 +49,11 @@ impl TryFrom for RegistrationHandshake { } } -impl TryInto for RegistrationHandshake { - type Error = serde_json::Error; - - fn try_into(self) -> Result { - serde_json::to_string(&self) +impl From for String { + fn from(value: RegistrationHandshake) -> Self { + // SAFETY: we have infallible serde implementation + #[allow(clippy::unwrap_used)] + serde_json::to_string(&value).unwrap() } } @@ -79,7 +80,7 @@ mod tests { assert_eq!(protocol_version, Some(42)); assert_eq!(data, handshake_data) } - _ => unreachable!("this branch shouldn't have been reached!"), + _ => panic!("this branch shouldn't have been reached!"), } let handshake_payload_without_protocol = RegistrationHandshake::HandshakePayload { @@ -97,7 +98,7 @@ mod tests { assert!(protocol_version.is_none()); assert_eq!(data, handshake_data) } - _ => unreachable!("this branch shouldn't have been reached!"), + _ => panic!("this branch shouldn't have been reached!"), } } } diff --git a/common/gateway-requests/src/types/text_request/authenticate.rs b/common/gateway-requests/src/types/text_request/authenticate.rs index 6c4c884957..65dacb3f00 100644 --- a/common/gateway-requests/src/types/text_request/authenticate.rs +++ b/common/gateway-requests/src/types/text_request/authenticate.rs @@ -1,7 +1,9 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::{AuthenticationFailure, GatewayRequestsError, SharedGatewayKey}; +use crate::{ + AuthenticationFailure, GatewayProtocolVersion, GatewayRequestsError, SharedGatewayKey, +}; use nym_crypto::asymmetric::ed25519; use serde::{Deserialize, Serialize}; use std::iter; @@ -20,7 +22,7 @@ pub struct AuthenticateRequest { impl AuthenticateRequest { pub fn new( - protocol_version: u8, + protocol_version: GatewayProtocolVersion, shared_key: &SharedGatewayKey, identity_keys: &ed25519::KeyPair, ) -> Result { @@ -98,7 +100,7 @@ impl AuthenticateRequest { #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct AuthenticateRequestContent { - pub protocol_version: u8, + pub protocol_version: GatewayProtocolVersion, // this is identical to the client's address pub client_identity: ed25519::PublicKey, diff --git a/common/gateway-requests/src/types/text_request/mod.rs b/common/gateway-requests/src/types/text_request/mod.rs index 342bc9adfd..b15a27bf93 100644 --- a/common/gateway-requests/src/types/text_request/mod.rs +++ b/common/gateway-requests/src/types/text_request/mod.rs @@ -4,9 +4,8 @@ use crate::models::CredentialSpendingRequest; use crate::text_request::authenticate::AuthenticateRequest; use crate::{ - GatewayRequestsError, SharedGatewayKey, SymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION, - AUTHENTICATE_V2_PROTOCOL_VERSION, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, - INITIAL_PROTOCOL_VERSION, + GatewayProtocolVersion, GatewayRequestsError, SharedGatewayKey, SymmetricKey, + AES_GCM_SIV_PROTOCOL_VERSION, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION, }; use nym_credentials_interface::CredentialSpendingData; use nym_crypto::asymmetric::ed25519; @@ -46,6 +45,7 @@ impl ClientRequest { // - the schema is self-describing which simplifies deserialisation // SAFETY: the trait has been derived correctly with no weird variants + #[allow(clippy::unwrap_used)] let plaintext = serde_json::to_vec(self).unwrap(); let nonce = key.random_nonce_or_iv(); let ciphertext = key.encrypt(&plaintext, Some(&nonce))?; @@ -72,7 +72,7 @@ pub enum ClientControlRequest { // have the shared key derived? Authenticate { #[serde(default)] - protocol_version: Option, + protocol_version: Option, address: String, enc_address: String, iv: String, @@ -83,7 +83,7 @@ pub enum ClientControlRequest { #[serde(alias = "handshakePayload")] RegisterHandshakeInitRequest { #[serde(default)] - protocol_version: Option, + protocol_version: Option, data: Vec, }, BandwidthCredential { @@ -98,6 +98,10 @@ pub enum ClientControlRequest { enc_credential: Vec, iv: Vec, }, + UpgradeModeJWT { + // no need to encrypt it as it's public anyway + token: String, + }, ClaimFreeTestnetBandwidth, EncryptedRequest { ciphertext: Vec, @@ -108,12 +112,14 @@ pub enum ClientControlRequest { } impl ClientControlRequest { - pub fn new_authenticate( + pub fn new_legacy_authenticate( address: DestinationAddressBytes, shared_key: &SharedGatewayKey, uses_credentials: bool, ) -> Result { // if we're encrypting with non-legacy key, the remote must support AES256-GCM-SIV + // since we are using legacy authentication, the gateway definitely doesn't understand the protocol downgrade, + // so use the lowest possible version we can let protocol_version = if !shared_key.is_legacy() { Some(AES_GCM_SIV_PROTOCOL_VERSION) } else if uses_credentials { @@ -138,10 +144,8 @@ impl ClientControlRequest { pub fn new_authenticate_v2( shared_key: &SharedGatewayKey, identity_keys: &ed25519::KeyPair, + protocol_version: GatewayProtocolVersion, ) -> Result { - // if we're using v2 authentication, we must announce at least that protocol version - let protocol_version = AUTHENTICATE_V2_PROTOCOL_VERSION; - Ok(ClientControlRequest::AuthenticateV2(Box::new( AuthenticateRequest::new(protocol_version, shared_key, identity_keys)?, ))) @@ -159,6 +163,7 @@ impl ClientControlRequest { "BandwidthCredentialV2".to_string() } ClientControlRequest::EcashCredential { .. } => "EcashCredential".to_string(), + ClientControlRequest::UpgradeModeJWT { .. } => "UpgradeModeJWT".to_string(), ClientControlRequest::ClaimFreeTestnetBandwidth => { "ClaimFreeTestnetBandwidth".to_string() } @@ -192,12 +197,16 @@ impl ClientControlRequest { CredentialSpendingRequest::try_from_bytes(credential_bytes.as_slice()) .map_err(|_| GatewayRequestsError::MalformedEncryption) } + + pub fn new_upgrade_mode_jwt(token: String) -> Self { + ClientControlRequest::UpgradeModeJWT { token } + } } impl From for Message { fn from(req: ClientControlRequest) -> Self { - // it should be safe to call `unwrap` here as the message is generated by the server - // so if it fails (and consequently panics) it's a bug that should be resolved + // SAFETY: all of the enum variants have valid (for json) serde impl + #[allow(clippy::unwrap_used)] let str_req = serde_json::to_string(&req).unwrap(); Message::Text(str_req) } diff --git a/common/gateway-requests/src/types/text_response.rs b/common/gateway-requests/src/types/text_response.rs index c3418649cf..140aa26bbd 100644 --- a/common/gateway-requests/src/types/text_response.rs +++ b/common/gateway-requests/src/types/text_response.rs @@ -1,7 +1,9 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::{GatewayRequestsError, SimpleGatewayRequestsError, SymmetricKey}; +use crate::{ + GatewayProtocolVersion, GatewayRequestsError, SimpleGatewayRequestsError, SymmetricKey, +}; use serde::{Deserialize, Serialize}; use tungstenite::Message; @@ -26,6 +28,7 @@ impl SensitiveServerResponse { // - the schema is self-describing which simplifies deserialisation // SAFETY: the trait has been derived correctly with no weird variants + #[allow(clippy::unwrap_used)] let plaintext = serde_json::to_vec(self).unwrap(); let nonce = key.random_nonce_or_iv(); let ciphertext = key.encrypt(&plaintext, Some(&nonce))?; @@ -43,31 +46,57 @@ impl SensitiveServerResponse { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct BandwidthResponse { + pub available_total: i64, + + /// Flag indicating whether the gateway has detected the system is undergoing the upgrade + /// (thus it will not meter bandwidth) + #[serde(default)] + pub upgrade_mode: bool, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SendResponse { + pub remaining_bandwidth: i64, + + /// Flag indicating whether the gateway has detected the system is undergoing the upgrade + /// (thus it will not meter bandwidth) + #[serde(default)] + pub upgrade_mode: bool, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(tag = "type", rename_all = "camelCase")] #[non_exhaustive] pub enum ServerResponse { Authenticate { #[serde(default)] - protocol_version: Option, + protocol_version: Option, status: bool, bandwidth_remaining: i64, + + /// Flag indicating whether the gateway has detected the system is undergoing the upgrade + /// (thus it will not meter bandwidth) + #[serde(default)] + upgrade_mode: bool, }, Register { #[serde(default)] - protocol_version: Option, + protocol_version: Option, status: bool, + + /// Flag indicating whether the gateway has detected the system is undergoing the upgrade + /// (thus it will not meter bandwidth) + #[serde(default)] + upgrade_mode: bool, }, EncryptedResponse { ciphertext: Vec, nonce: Vec, }, - Bandwidth { - available_total: i64, - }, - Send { - remaining_bandwidth: i64, - }, + Bandwidth(BandwidthResponse), + Send(SendResponse), SupportedProtocol { version: u8, }, @@ -122,6 +151,7 @@ impl From for Message { fn from(res: ServerResponse) -> Self { // it should be safe to call `unwrap` here as the message is generated by the server // so if it fails (and consequently panics) it's a bug that should be resolved + #[allow(clippy::unwrap_used)] let str_res = serde_json::to_string(&res).unwrap(); Message::Text(str_res) } @@ -134,3 +164,79 @@ impl TryFrom for ServerResponse { serde_json::from_str(&msg) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_response_serde_compat() { + // make sure new serialisation is identical and compatible + #[derive(Serialize, Deserialize, Debug, PartialEq)] + #[serde(tag = "type", rename_all = "camelCase")] + #[non_exhaustive] + pub enum OldServerResponse { + Bandwidth { available_total: i64 }, + Send { remaining_bandwidth: i64 }, + } + + // OLD => NEW + let old_bandwidth = OldServerResponse::Bandwidth { + available_total: 100, + }; + let old_send = OldServerResponse::Send { + remaining_bandwidth: 100, + }; + + let old_bandwidth_str = serde_json::to_string(&old_bandwidth).unwrap(); + let old_send_str = serde_json::to_string(&old_send).unwrap(); + + let recovered_bandwidth = ServerResponse::try_from(old_bandwidth_str).unwrap(); + assert_eq!( + recovered_bandwidth, + ServerResponse::Bandwidth(BandwidthResponse { + available_total: 100, + upgrade_mode: false + }) + ); + + let recovered_send = ServerResponse::try_from(old_send_str).unwrap(); + assert_eq!( + recovered_send, + ServerResponse::Send(SendResponse { + remaining_bandwidth: 100, + upgrade_mode: false + }) + ); + + // NEW => OLD + let new_bandwidth = ServerResponse::Bandwidth(BandwidthResponse { + available_total: 100, + upgrade_mode: false, + }); + let new_send = ServerResponse::Send(SendResponse { + remaining_bandwidth: 100, + upgrade_mode: false, + }); + + let new_bandwidth_str = serde_json::to_string(&new_bandwidth).unwrap(); + let new_send_str = serde_json::to_string(&new_send).unwrap(); + + let recovered_bandwidth: OldServerResponse = + serde_json::from_str(&new_bandwidth_str).unwrap(); + assert_eq!( + recovered_bandwidth, + OldServerResponse::Bandwidth { + available_total: 100 + } + ); + + let recovered_send: OldServerResponse = serde_json::from_str(&new_send_str).unwrap(); + assert_eq!( + recovered_send, + OldServerResponse::Send { + remaining_bandwidth: 100 + } + ); + } +} diff --git a/common/service-provider-requests-common/src/lib.rs b/common/service-provider-requests-common/src/lib.rs index 9e392dff19..44e54dc2af 100644 --- a/common/service-provider-requests-common/src/lib.rs +++ b/common/service-provider-requests-common/src/lib.rs @@ -82,6 +82,17 @@ pub struct Protocol { pub service_provider_type: ServiceProviderType, } +impl Protocol { + pub const fn new(version: u8, service_provider_type: ServiceProviderType) -> Self { + Self { + version, + service_provider_type, + } + } +} + +// NOTE: this only works under the assumption of using bincode for serialisation +// with the current field layout impl TryFrom<&[u8; 2]> for Protocol { type Error = ProtocolError; diff --git a/common/upgrade-mode-check/src/error.rs b/common/upgrade-mode-check/src/error.rs index 0edf4201e8..3b2f2b5b12 100644 --- a/common/upgrade-mode-check/src/error.rs +++ b/common/upgrade-mode-check/src/error.rs @@ -6,7 +6,7 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum UpgradeModeCheckError { - #[error("failed to decode jwt metadata")] + #[error("failed to decode jwt metadata: {source}")] TokenMetadataDecodeFailure { source: jwt_simple::Error }, #[error("the jwt metadata didn't contain explicit public key")] @@ -21,6 +21,6 @@ pub enum UpgradeModeCheckError { #[error("failed to verify the jwt: {source}")] JwtVerificationFailure { source: jwt_simple::Error }, - #[error("failed to retrieve attestation from {url}:{source}")] + #[error("failed to retrieve attestation from {url}: {source}")] AttestationRetrievalFailure { url: String, source: reqwest::Error }, } diff --git a/common/upgrade-mode-check/src/jwt.rs b/common/upgrade-mode-check/src/jwt.rs index 060546f0bb..64c436b23e 100644 --- a/common/upgrade-mode-check/src/jwt.rs +++ b/common/upgrade-mode-check/src/jwt.rs @@ -10,6 +10,8 @@ use nym_crypto::asymmetric::ed25519; use std::collections::HashSet; use std::time::Duration; +pub const CREDENTIAL_PROXY_JWT_ISSUER: &str = "nym-credential-proxy"; + // for now use static issuer such as "nym-credential-proxy" pub fn generate_jwt_for_upgrade_mode_attestation( attestation: UpgradeModeAttestation, @@ -109,11 +111,11 @@ mod tests { attestation.clone(), Duration::from_secs(60 * 60), &unauthorised_jwt_keys, - Some("nym-credential-proxy"), + Some(CREDENTIAL_PROXY_JWT_ISSUER), ); // we expect 'nym-credential-proxy' issuer - assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("nym-credential-proxy")).is_ok()); + assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some(CREDENTIAL_PROXY_JWT_ISSUER)).is_ok()); // we don't care about issuer assert!(validate_upgrade_mode_jwt(&jwt_issuer, None).is_ok()); @@ -133,7 +135,9 @@ mod tests { None, ); // we expect 'nym-credential-proxy' issuer - assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, Some("nym-credential-proxy")).is_err()); + assert!( + validate_upgrade_mode_jwt(&jwt_no_issuer, Some(CREDENTIAL_PROXY_JWT_ISSUER)).is_err() + ); // we don't care about issuer assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, None).is_ok()); diff --git a/common/upgrade-mode-check/src/lib.rs b/common/upgrade-mode-check/src/lib.rs index 36cbf43c91..c2ab50284b 100644 --- a/common/upgrade-mode-check/src/lib.rs +++ b/common/upgrade-mode-check/src/lib.rs @@ -9,7 +9,10 @@ pub use attestation::{ UpgradeModeAttestation, generate_new_attestation, generate_new_attestation_with_starting_time, }; pub use error::UpgradeModeCheckError; -pub use jwt::{generate_jwt_for_upgrade_mode_attestation, validate_upgrade_mode_jwt}; +pub use jwt::{ + CREDENTIAL_PROXY_JWT_ISSUER, generate_jwt_for_upgrade_mode_attestation, + validate_upgrade_mode_jwt, +}; #[cfg(not(target_arch = "wasm32"))] pub use attestation::attempt_retrieve_attestation; diff --git a/common/wireguard-private-metadata/client/src/lib.rs b/common/wireguard-private-metadata/client/src/lib.rs index 58d78fb6c6..3dd884518c 100644 --- a/common/wireguard-private-metadata/client/src/lib.rs +++ b/common/wireguard-private-metadata/client/src/lib.rs @@ -46,6 +46,23 @@ pub trait WireguardMetadataApiClient: ApiClient { ) .await } + + #[instrument(level = "debug", skip(self, request_body))] + async fn request_upgrade_mode_check( + &self, + request_body: &Request, + ) -> Result { + self.post_json( + &[ + routes::V1_API_VERSION, + routes::NETWORK, + routes::UPGRADE_MODE_CHECK, + ], + NO_PARAMS, + request_body, + ) + .await + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] diff --git a/common/wireguard-private-metadata/server/src/http/router.rs b/common/wireguard-private-metadata/server/src/http/router.rs index 099e1d5724..e76df7d328 100644 --- a/common/wireguard-private-metadata/server/src/http/router.rs +++ b/common/wireguard-private-metadata/server/src/http/router.rs @@ -15,7 +15,7 @@ use utoipa_swagger_ui::SwaggerUi; use crate::http::openapi::ApiDoc; use crate::http::state::AppState; -use crate::network::bandwidth_routes; +use crate::network::{bandwidth_routes, network_routes}; /// Wrapper around `axum::Router` which ensures correct [order of layers][order]. /// Add new routes as if you were working directly with `axum`. @@ -35,7 +35,12 @@ impl RouterBuilder { let default_routes = Router::new() .merge(SwaggerUi::new("/swagger").url("/api-docs/openapi.json", ApiDoc::openapi())) .route("/", get(|| async { Redirect::to("/swagger") })) - .nest("/v1", Router::new().nest("/bandwidth", bandwidth_routes())); + .nest( + "/v1", + Router::new() + .nest("/bandwidth", bandwidth_routes()) + .nest("/network", network_routes()), + ); Self { unfinished_router: default_routes, } diff --git a/common/wireguard-private-metadata/server/src/http/state.rs b/common/wireguard-private-metadata/server/src/http/state.rs index 06916bb35f..3e913d7073 100644 --- a/common/wireguard-private-metadata/server/src/http/state.rs +++ b/common/wireguard-private-metadata/server/src/http/state.rs @@ -1,35 +1,137 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use nym_credential_verification::upgrade_mode::UpgradeModeDetails; +use nym_credentials_interface::BandwidthCredential; +use std::cmp::max; use std::net::IpAddr; -use nym_credentials_interface::CredentialSpendingData; - use crate::transceiver::PeerControllerTransceiver; use nym_wireguard_private_metadata_shared::error::MetadataError; +use nym_wireguard_private_metadata_shared::interface::{ResponseData, UpgradeModeCheckRequestType}; + +// we need to be above MINIMUM_REMAINING_BANDWIDTH (500MB) plus we also have to trick the client +// its depletion is low enough to not require sending new tickets +const DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD: i64 = 1024 * 1024 * 1024; #[derive(Clone, axum::extract::FromRef)] pub struct AppState { transceiver: PeerControllerTransceiver, + #[from_ref(skip)] + upgrade_mode: UpgradeModeDetails, } impl AppState { - pub fn new(transceiver: PeerControllerTransceiver) -> Self { - Self { transceiver } + pub fn new(transceiver: PeerControllerTransceiver, upgrade_mode: UpgradeModeDetails) -> Self { + Self { + transceiver, + upgrade_mode, + } } - pub async fn available_bandwidth(&self, ip: IpAddr) -> Result { - self.transceiver.query_bandwidth(ip).await + fn upgrade_mode_bandwidth(&self, true_bandwidth: i64) -> i64 { + // if we're undergoing upgrade mode, we don't meter bandwidth, + // we simply return MAX of clients current bandwidth and minimum bandwidth before default + // client would have attempted to send new ticket (hopefully) + // the latter is to support older clients that will ignore `upgrade_mode` field in the response + // as they're not aware of its existence + max(DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD, true_bandwidth) + } + + pub async fn available_bandwidth(&self, ip: IpAddr) -> Result { + let upgrade_mode = self.upgrade_mode.enabled(); + + let true_bandwidth = self.transceiver.query_bandwidth(ip).await?; + let available_bandwidth = if upgrade_mode { + self.upgrade_mode_bandwidth(true_bandwidth) + } else { + true_bandwidth + }; + + Ok(ResponseData::AvailableBandwidth { + amount: available_bandwidth, + upgrade_mode, + }) } // Top up with a credential and return the afterwards available bandwidth pub async fn topup_bandwidth( &self, ip: IpAddr, - credential: CredentialSpendingData, - ) -> Result { - self.transceiver - .topup_bandwidth(ip, Box::new(credential)) - .await + claim: Box, + ) -> Result { + match *claim { + BandwidthCredential::ZkNym(zk_nym) => { + // if we got zk-nym, we just try to verify it + let available_bandwidth = self.transceiver.topup_bandwidth(ip, zk_nym).await?; + + // however, we still follow the same upgrade-mode logic, + // so that the client would not attempt to needlessly send more credentials + let upgrade_mode = self.upgrade_mode.enabled(); + let available_bandwidth = if upgrade_mode { + self.upgrade_mode_bandwidth(available_bandwidth) + } else { + available_bandwidth + }; + + Ok(ResponseData::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + }) + } + BandwidthCredential::UpgradeModeJWT { token } => { + // if we're already in the upgrade mode, don't bother validating the token + if self.upgrade_mode.enabled() { + let true_bandwidth = self.transceiver.query_bandwidth(ip).await?; + return Ok(ResponseData::TopUpBandwidth { + available_bandwidth: self.upgrade_mode_bandwidth(true_bandwidth), + upgrade_mode: true, + }); + } + + // if the token is valid, try to check if we're behind + // and have to update our internal state + self.upgrade_mode + .try_enable_via_received_jwt(token) + .await + .map_err(|err| MetadataError::JWTVerification { + message: err.to_string(), + })?; + + // if we didn't return an error, it means token got accepted + // and we have transitioned into the upgrade mode + let true_bandwidth = self.transceiver.query_bandwidth(ip).await?; + + Ok(ResponseData::TopUpBandwidth { + available_bandwidth: self.upgrade_mode_bandwidth(true_bandwidth), + upgrade_mode: true, + }) + } + } + } + + pub async fn upgrade_mode_check( + &self, + request: UpgradeModeCheckRequestType, + ) -> Result { + // if we're already in the upgrade mode - no need to do anything + if self.upgrade_mode.enabled() { + return Ok(ResponseData::UpgradeMode { upgrade_mode: true }); + } + + match request { + UpgradeModeCheckRequestType::UpgradeModeJwt { token } => { + self.upgrade_mode + .try_enable_via_received_jwt(token) + .await + .map_err(|err| MetadataError::JWTVerification { + message: err.to_string(), + })?; + } + } + + // if we didn't return an error, it means token got accepted + // and we have transitioned into the upgrade mode + Ok(ResponseData::UpgradeMode { upgrade_mode: true }) } } diff --git a/common/wireguard-private-metadata/server/src/network.rs b/common/wireguard-private-metadata/server/src/network.rs index 8e27eca5cd..f1ad08a108 100644 --- a/common/wireguard-private-metadata/server/src/network.rs +++ b/common/wireguard-private-metadata/server/src/network.rs @@ -9,8 +9,7 @@ use axum::{ }; use nym_http_api_common::{FormattedResponse, OutputParams}; use nym_wireguard_private_metadata_shared::{ - AxumErrorResponse, AxumResult, Construct, Extract, Request, Response, - interface::{RequestData, ResponseData}, + AxumErrorResponse, AxumResult, Construct, Extract, Request, Response, interface::RequestData, latest, }; use tower_http::compression::CompressionLayer; @@ -25,6 +24,15 @@ pub(crate) fn bandwidth_routes() -> Router { .layer(CompressionLayer::new()) } +pub(crate) fn network_routes() -> Router { + Router::new() + .route( + "/upgrade-mode-check", + axum::routing::post(upgrade_mode_check), + ) + .layer(CompressionLayer::new()) +} + #[utoipa::path( tag = "bandwidth", get, @@ -59,20 +67,17 @@ async fn available_bandwidth( ) -> AxumResult> { let output = output.output.unwrap_or_default(); - let (RequestData::AvailableBandwidth(_), version) = + let (RequestData::AvailableBandwidth, version) = request.extract().map_err(AxumErrorResponse::bad_request)? else { return Err(AxumErrorResponse::bad_request("incorrect request type")); }; - let available_bandwidth = state + let available_bandwidth_response = state .available_bandwidth(addr.ip()) .await .map_err(AxumErrorResponse::bad_request)?; - let response = Response::construct( - ResponseData::AvailableBandwidth(available_bandwidth), - version, - ) - .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct(available_bandwidth_response, version) + .map_err(AxumErrorResponse::bad_request)?; Ok(output.to_response(response)) } @@ -96,16 +101,49 @@ async fn topup_bandwidth( ) -> AxumResult> { let output = output.output.unwrap_or_default(); - let (RequestData::TopUpBandwidth(credential), version) = + let (RequestData::TopUpBandwidth { credential }, version) = request.extract().map_err(AxumErrorResponse::bad_request)? else { return Err(AxumErrorResponse::bad_request("incorrect request type")); }; - let available_bandwidth = state - .topup_bandwidth(addr.ip(), *credential) + let top_up_bandwidth_response = state + .topup_bandwidth(addr.ip(), credential) .await .map_err(AxumErrorResponse::bad_request)?; - let response = Response::construct(ResponseData::TopUpBandwidth(available_bandwidth), version) + let response = Response::construct(top_up_bandwidth_response, version) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) +} + +#[utoipa::path( + tag = "network", + post, + request_body = Request, + path = "/v1/network/upgrade-mode-check", + responses( + (status = 200, content( + (Response = "application/bincode") + )) + ), +)] +async fn upgrade_mode_check( + Query(output): Query, + State(state): State, + Json(request): Json, +) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::UpgradeModeCheck { typ }, version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let upgrade_mode_check_response = state + .upgrade_mode_check(typ) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct(upgrade_mode_check_response, version) .map_err(AxumErrorResponse::bad_request)?; Ok(output.to_response(response)) diff --git a/common/wireguard-private-metadata/server/src/transceiver.rs b/common/wireguard-private-metadata/server/src/transceiver.rs index cbe77126cf..5614e7f8f7 100644 --- a/common/wireguard-private-metadata/server/src/transceiver.rs +++ b/common/wireguard-private-metadata/server/src/transceiver.rs @@ -37,12 +37,12 @@ impl PeerControllerTransceiver { }) } - pub(crate) async fn query_bandwidth(&self, ip: IpAddr) -> Result { + pub async fn query_bandwidth(&self, ip: IpAddr) -> Result { Ok(self.get_client_bandwidth(ip).await?.available().await) } // Top up with a credential and return the afterwards available bandwidth - pub(crate) async fn topup_bandwidth( + pub async fn topup_bandwidth( &self, ip: IpAddr, credential: Box, diff --git a/common/wireguard-private-metadata/shared/src/conversion_helpers.rs b/common/wireguard-private-metadata/shared/src/conversion_helpers.rs new file mode 100644 index 0000000000..117f9afb02 --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/conversion_helpers.rs @@ -0,0 +1,218 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +/// A simple macro that given `TryFrom<&A> for B`, implements `TryFrom for B` +/// using the former implementation +#[macro_export] +macro_rules! impl_tryfrom_ref { + ($src:ty, $dst:ty, $err:ty) => { + impl TryFrom<$src> for $dst { + // can't use type Error = >::Error; + // due to lifetime interference within macros + type Error = $err; + + fn try_from(value: $src) -> Result { + >::try_from(&value) + } + } + }; +} + +/// A simple macro that implements all required variants of `TryFrom` +/// between particular versioned `VersionedRequest` and given request variant +/// using default bincode serializer +#[macro_export] +macro_rules! impl_default_bincode_request_query_conversions { + // limitation of macros - need to pass the same underlying type twice, + // once as pattern and once as expression + ($top_req_type:ty, $inner_req_type:ty, $query_type_pat:pat, $query_type_expr:expr) => { + $crate::impl_query_conversions!( + $crate::Request, + $top_req_type, + $inner_req_type, + $query_type_pat, + $query_type_expr + ); + }; +} + +/// A simple macro that implements all required variants of `TryFrom` +/// between particular versioned `VersionedResponse` and given response variant +/// using default bincode serializer +#[macro_export] +macro_rules! impl_default_bincode_response_query_conversions { + // limitation of macros - need to pass the same underlying type twice, + // once as pattern and once as expression + ($top_resp_type:ty, $inner_resp_type:ty, $query_type_pat:pat, $query_type_expr:expr) => { + $crate::impl_query_conversions!( + $crate::Response, + $top_resp_type, + $inner_resp_type, + $query_type_pat, + $query_type_expr + ); + }; +} + +/// A simple macro that implements all required variants of `TryFrom` +/// between [crate::models::Request] and corresponding versioned `VersionedRequest` +/// using default bincode serializer +#[macro_export] +macro_rules! impl_default_bincode_request_conversions { + ($req_type:ty, $version:expr) => { + $crate::impl_versioned_conversions!($crate::Request, $req_type, $version); + }; +} + +/// A simple macro that implements all required variants of `TryFrom` +/// between [crate::models::Response] and corresponding versioned `VersionedResponse` +/// using default bincode serializer +#[macro_export] +macro_rules! impl_default_bincode_response_conversions { + ($req_type:ty, $version:expr) => { + $crate::impl_versioned_conversions!($crate::Response, $req_type, $version); + }; +} + +#[macro_export] +macro_rules! impl_versioned_conversions { + ( + // is it Request or Response + $main_type_ty:ty, + + // e.g. VersionedResponse + $top_type:ty, + + // request/response version type + $version:expr + ) => { + impl TryFrom<&$top_type> for $main_type_ty { + type Error = $crate::models::error::Error; + + fn try_from(value: &$top_type) -> Result { + use ::bincode::Options; + let data = $crate::make_bincode_serializer().serialize(value)?; + Ok(<$main_type_ty>::new($version, data)) + } + } + + // automatically generate `impl TryFrom<$top_type> for $main_type` + $crate::impl_tryfrom_ref!($top_type, $main_type_ty, $crate::models::error::Error); + + impl TryFrom<&$main_type_ty> for $top_type { + type Error = $crate::models::error::Error; + + fn try_from(value: &$main_type_ty) -> Result { + use ::bincode::Options; + if value.version != $version { + return Err($crate::models::error::Error::InvalidVersion { + source_version: value.version, + target_version: $version, + }); + } + Ok($crate::make_bincode_serializer().deserialize(&value.inner)?) + } + } + + // automatically generate `impl TryFrom<$main_type> for $top_type` + $crate::impl_tryfrom_ref!($main_type_ty, $top_type, $crate::models::error::Error); + }; +} + +#[macro_export] +macro_rules! impl_query_conversions { + // limitation of macros - need to pass the same underlying type twice, + // once as pattern and once as expression + ( + // is it Request or Response + $main_type:ty, + + // e.g. VersionedResponse + $top_type:ty, + + // e.g. InnerTopUpResponse + $inner_type:ty, + + // e.g. QueryType::TopUpBandwidth, + $query_type_pat:pat, + + // e.g. QueryType::TopUpBandwidth, + $query_type_expr:expr + ) => { + // conversion from the versioned type into the particular typ, + // e.g. TryFrom<&VersionedResponse> for InnerTopUpResponse + impl TryFrom<&$top_type> for $inner_type { + type Error = $crate::models::error::Error; + + fn try_from(value: &$top_type) -> Result { + use ::bincode::Options; + match value.query_type { + $query_type_pat => { + Ok($crate::make_bincode_serializer().deserialize(&value.inner)?) + } + other => Err($crate::models::error::Error::InvalidQueryType { + source_query_type: other.to_string(), + target_query_type: stringify!($query_type_pat).to_string(), + }), + } + } + } + // implementation of conversion without the referenced type, i.e. + // e.g. TryFrom for InnerTopUpResponse + $crate::impl_tryfrom_ref!($top_type, $inner_type, $crate::models::error::Error); + + // conversion back from the particular type into the versioned type, i.e. + // e.g. TryFrom<&InnerTopUpResponse> for VersionedResponse + impl TryFrom<&$inner_type> for $top_type { + type Error = $crate::models::error::Error; + + fn try_from(value: &$inner_type) -> Result { + use ::bincode::Options; + Ok(Self { + query_type: $query_type_expr, + inner: $crate::make_bincode_serializer().serialize(value)?, + }) + } + } + + // implementation of conversion without the referenced type, i.e. + // e.g. TryFrom for VersionedResponse + $crate::impl_tryfrom_ref!($inner_type, $top_type, $crate::models::error::Error); + + // conversion from the'main' type (Request/Response) into the particular type + // e.g. TryFrom<&Response> for InnerTopUpResponse + impl TryFrom<&$main_type> for $inner_type { + type Error = $crate::error::MetadataError; + + fn try_from(value: &$main_type) -> Result { + <$top_type>::try_from(value)?.try_into().map_err( + |err: $crate::models::error::Error| $crate::error::MetadataError::Models { + message: err.to_string(), + }, + ) + } + } + + // implementation of conversion without the referenced type, i.e. + // e.g. TryFrom for InnerTopUpResponse + $crate::impl_tryfrom_ref!($main_type, $inner_type, $crate::error::MetadataError); + + // conversion from the particular type into the 'main' type (Request/Response) + // e.g. TryFrom<&InnerTopUpResponse> for Response + impl TryFrom<&$inner_type> for $main_type { + type Error = $crate::error::MetadataError; + + fn try_from(value: &$inner_type) -> Result { + <$top_type>::try_from(value)?.try_into().map_err( + |err: $crate::models::error::Error| $crate::error::MetadataError::Models { + message: err.to_string(), + }, + ) + } + } + + // implementation of conversion without the referenced type, i.e. + // e.g. TryFrom for Response + $crate::impl_tryfrom_ref!($inner_type, $main_type, $crate::error::MetadataError); + }; +} diff --git a/common/wireguard-private-metadata/shared/src/error.rs b/common/wireguard-private-metadata/shared/src/error.rs index 3783462a4d..f8db19c304 100644 --- a/common/wireguard-private-metadata/shared/src/error.rs +++ b/common/wireguard-private-metadata/shared/src/error.rs @@ -17,6 +17,9 @@ pub enum MetadataError { #[error("Credential verification error: {message}")] CredentialVerification { message: String }, + + #[error("Upgrade Mode JWT verification error: {message}")] + JWTVerification { message: String }, } impl From for MetadataError { diff --git a/common/wireguard-private-metadata/shared/src/lib.rs b/common/wireguard-private-metadata/shared/src/lib.rs index 54041fd8c7..c0d711ac42 100644 --- a/common/wireguard-private-metadata/shared/src/lib.rs +++ b/common/wireguard-private-metadata/shared/src/lib.rs @@ -1,6 +1,7 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +pub(crate) mod conversion_helpers; pub mod error; mod models; pub mod routes; @@ -9,7 +10,7 @@ pub mod routes; pub use models::v0; pub use models::{ AxumErrorResponse, AxumResult, Construct, ErrorResponse, Extract, Request, Response, Version, - error::Error as ModelError, interface, latest, v1, + error::Error as ModelError, interface, latest, v1, v2, }; fn make_bincode_serializer() -> impl bincode::Options { diff --git a/common/wireguard-private-metadata/shared/src/models/error.rs b/common/wireguard-private-metadata/shared/src/models/error.rs index 45dc88617d..aef1ac5ae6 100644 --- a/common/wireguard-private-metadata/shared/src/models/error.rs +++ b/common/wireguard-private-metadata/shared/src/models/error.rs @@ -15,7 +15,7 @@ pub enum Error { }, #[error( - "trying to deserialize from query type {source_query_type} query type {target_query_type}" + "trying to deserialize from query type {source_query_type} into query type {target_query_type}" )] InvalidQueryType { source_query_type: String, diff --git a/common/wireguard-private-metadata/shared/src/models/interface.rs b/common/wireguard-private-metadata/shared/src/models/interface.rs index 9d5a786c53..c97db77d2a 100644 --- a/common/wireguard-private-metadata/shared/src/models/interface.rs +++ b/common/wireguard-private-metadata/shared/src/models/interface.rs @@ -1,61 +1,90 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use nym_credentials_interface::CredentialSpendingData; +use nym_credentials_interface::BandwidthCredential; #[cfg(feature = "testing")] use crate::models::v0; -use crate::models::{Construct, Extract, Request, Response, Version, v1}; +use crate::models::{Construct, Extract, Request, Response, Version, latest, v1, v2}; + +pub use latest::check_upgrade_mode::request::UpgradeModeCheckRequestType; pub enum RequestData { - AvailableBandwidth(()), - TopUpBandwidth(Box), + AvailableBandwidth, + TopUpBandwidth { + credential: Box, + }, + UpgradeModeCheck { + typ: UpgradeModeCheckRequestType, + }, } -impl From for RequestData { - fn from(value: super::latest::interface::RequestData) -> Self { +impl From for RequestData { + fn from(value: latest::interface::RequestData) -> Self { match value { - super::latest::interface::RequestData::AvailableBandwidth(inner) => { - Self::AvailableBandwidth(inner) + latest::interface::RequestData::AvailableBandwidth => Self::AvailableBandwidth, + latest::interface::RequestData::TopUpBandwidth { credential } => { + Self::TopUpBandwidth { credential } } - super::latest::interface::RequestData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) + latest::interface::RequestData::UpgradeModeCheck { typ } => { + Self::UpgradeModeCheck { typ } } } } } -impl From for super::latest::interface::RequestData { +impl From for latest::interface::RequestData { fn from(value: RequestData) -> Self { match value { - RequestData::AvailableBandwidth(inner) => Self::AvailableBandwidth(inner), - RequestData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } + RequestData::AvailableBandwidth => Self::AvailableBandwidth, + RequestData::TopUpBandwidth { credential } => Self::TopUpBandwidth { credential }, + RequestData::UpgradeModeCheck { typ } => Self::UpgradeModeCheck { typ }, } } } -impl From for ResponseData { - fn from(value: super::latest::interface::ResponseData) -> Self { +impl From for ResponseData { + fn from(value: latest::interface::ResponseData) -> Self { match value { - super::latest::interface::ResponseData::AvailableBandwidth(inner) => { - Self::AvailableBandwidth(inner) - } - super::latest::interface::ResponseData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) + latest::interface::ResponseData::AvailableBandwidth { + amount, + upgrade_mode, + } => Self::AvailableBandwidth { + amount, + upgrade_mode, + }, + latest::interface::ResponseData::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + } => Self::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + }, + latest::interface::ResponseData::UpgradeMode { upgrade_mode } => { + Self::UpgradeMode { upgrade_mode } } } } } -impl From for super::latest::interface::ResponseData { +impl From for latest::interface::ResponseData { fn from(value: ResponseData) -> Self { match value { - ResponseData::AvailableBandwidth(inner) => Self::AvailableBandwidth(inner), - ResponseData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } + ResponseData::AvailableBandwidth { + amount, + upgrade_mode, + } => Self::AvailableBandwidth { + amount, + upgrade_mode, + }, + ResponseData::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + } => Self::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + }, + ResponseData::UpgradeMode { upgrade_mode } => Self::UpgradeMode { upgrade_mode }, } } } @@ -65,13 +94,26 @@ impl Construct for Request { match version { #[cfg(feature = "testing")] Version::V0 => { - let translate_info = super::latest::interface::RequestData::from(info); - let downgrade_info = v0::interface::RequestData::try_from(translate_info)?; - let versioned_request = v0::VersionedRequest::construct(downgrade_info, version)?; + // attempt to go through conversion chain for `info`: v2 => v1 => v0 + let v2_info = v2::interface::RequestData::from(info); + let v1_info = v1::interface::RequestData::try_from(v2_info)?; + let v0_info = v0::interface::RequestData::try_from(v1_info)?; + + let versioned_request = v0::VersionedRequest::construct(v0_info, version)?; Ok(versioned_request.try_into()?) } Version::V1 => { - let versioned_request = v1::VersionedRequest::construct(info.into(), version)?; + // attempt to go through conversion chain for `info`: v2 => v1 + let v2_info = v2::interface::RequestData::from(info); + let v1_info = v1::interface::RequestData::try_from(v2_info)?; + + let versioned_request = v1::VersionedRequest::construct(v1_info, version)?; + Ok(versioned_request.try_into()?) + } + Version::V2 => { + let v2_info = v2::interface::RequestData::from(info); + + let versioned_request = v2::VersionedRequest::construct(v2_info, version)?; Ok(versioned_request.try_into()?) } } @@ -84,24 +126,45 @@ impl Extract for Request { #[cfg(feature = "testing")] super::Version::V0 => { let versioned_request = v0::VersionedRequest::try_from(self.clone())?; - let (request, version) = versioned_request.extract()?; + let (extracted_v0_info, version) = versioned_request.extract()?; - let upgrade_request = super::latest::interface::RequestData::try_from(request)?; + let v1_info = v1::interface::RequestData::try_from(extracted_v0_info)?; + let v2_info = v2::interface::RequestData::try_from(v1_info)?; - Ok((upgrade_request.into(), version)) + let request_data = RequestData::from(v2_info); + Ok((request_data, version)) } super::Version::V1 => { - let versioned_request = v1::VersionedRequest::try_from(self.clone())?; - let (extracted, version) = versioned_request.extract()?; - Ok((extracted.into(), version)) + let versioned_request = v1::VersionedRequest::try_from(self)?; + let (extracted_v1_info, version) = versioned_request.extract()?; + let v2_info = v2::interface::RequestData::try_from(extracted_v1_info)?; + + let request_data = RequestData::from(v2_info); + Ok((request_data, version)) + } + super::Version::V2 => { + let versioned_request = v2::VersionedRequest::try_from(self)?; + let (extracted_v2_info, version) = versioned_request.extract()?; + + let request_data = RequestData::from(extracted_v2_info); + Ok((request_data, version)) } } } } pub enum ResponseData { - AvailableBandwidth(i64), - TopUpBandwidth(i64), + AvailableBandwidth { + amount: i64, + upgrade_mode: bool, + }, + TopUpBandwidth { + available_bandwidth: i64, + upgrade_mode: bool, + }, + UpgradeMode { + upgrade_mode: bool, + }, } impl Construct for Response { @@ -109,14 +172,26 @@ impl Construct for Response { match version { #[cfg(feature = "testing")] super::Version::V0 => { - let translate_response = super::latest::interface::ResponseData::from(info); - let downgrade_response = v0::interface::ResponseData::try_from(translate_response)?; - let versioned_response = - v0::VersionedResponse::construct(downgrade_response, version)?; + // attempt to go through conversion chain for `info`: v2 => v1 => v0 + let v2_info = v2::interface::ResponseData::from(info); + let v1_info = v1::interface::ResponseData::try_from(v2_info)?; + let v0_info = v0::interface::ResponseData::try_from(v1_info)?; + + let versioned_response = v0::VersionedResponse::construct(v0_info, version)?; Ok(versioned_response.try_into()?) } Version::V1 => { - let versioned_response = v1::VersionedResponse::construct(info.into(), version)?; + // attempt to go through conversion chain for `info`: v2 => v1 + let v2_info = v2::interface::ResponseData::from(info); + let v1_info = v1::interface::ResponseData::try_from(v2_info)?; + + let versioned_response = v1::VersionedResponse::construct(v1_info, version)?; + Ok(versioned_response.try_into()?) + } + Version::V2 => { + let v2_info = v2::interface::ResponseData::from(info); + + let versioned_response = v2::VersionedResponse::construct(v2_info, version)?; Ok(versioned_response.try_into()?) } } @@ -129,16 +204,27 @@ impl Extract for Response { #[cfg(feature = "testing")] super::Version::V0 => { let versioned_response = v0::VersionedResponse::try_from(self.clone())?; - let (response, version) = versioned_response.extract()?; + let (extracted_v0_info, version) = versioned_response.extract()?; + let v1_info = v1::interface::ResponseData::try_from(extracted_v0_info)?; + let v2_info = v2::interface::ResponseData::try_from(v1_info)?; - let upgrade_response = super::latest::interface::ResponseData::try_from(response)?; - - Ok((upgrade_response.into(), version)) + let response_data = ResponseData::from(v2_info); + Ok((response_data, version)) } super::Version::V1 => { let versioned_response = v1::VersionedResponse::try_from(self.clone())?; - let (extracted, version) = versioned_response.extract()?; - Ok((extracted.into(), version)) + let (extracted_v1_info, version) = versioned_response.extract()?; + let v2_info = v2::interface::ResponseData::try_from(extracted_v1_info)?; + + let response_data = ResponseData::from(v2_info); + Ok((response_data, version)) + } + super::Version::V2 => { + let versioned_response = v2::VersionedResponse::try_from(self.clone())?; + let (extracted_v2_info, version) = versioned_response.extract()?; + + let response_data = ResponseData::from(extracted_v2_info); + Ok((response_data, version)) } } } diff --git a/common/wireguard-private-metadata/shared/src/models/mod.rs b/common/wireguard-private-metadata/shared/src/models/mod.rs index e408d7c5df..ae39c890ac 100644 --- a/common/wireguard-private-metadata/shared/src/models/mod.rs +++ b/common/wireguard-private-metadata/shared/src/models/mod.rs @@ -14,7 +14,10 @@ pub mod interface; pub mod v0; // dummy version, only for filling boilerplate code for update/downgrade and testing pub mod v1; -pub use v1 as latest; +// adds upgrade mode information to bandwidth response +pub mod v2; + +pub use v2 as latest; use crate::models::error::Error; @@ -24,6 +27,7 @@ pub enum Version { /// only used for testing purposes, don't include it in your matching arms V0, V1, + V2, } impl From for Version { @@ -35,6 +39,7 @@ impl From for Version { match value { 0 => zero_version, 1 => Version::V1, + 2 => Version::V2, _ => latest::VERSION, // if unknown, it means we're behind, so we can use the latest we know about } } @@ -47,6 +52,7 @@ impl From for u64 { #[cfg(feature = "testing")] Version::V0 => 0, Version::V1 => 1, + Version::V2 => 2, } } } @@ -57,12 +63,24 @@ pub struct Request { pub(crate) inner: Vec, } -#[derive(Clone, Serialize, Deserialize, ToSchema)] +impl Request { + pub fn new(version: Version, inner: Vec) -> Self { + Request { version, inner } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct Response { pub version: Version, pub(crate) inner: Vec, } +impl Response { + pub fn new(version: Version, inner: Vec) -> Self { + Response { version, inner } + } +} + pub trait Extract { fn extract(&self) -> Result<(T, Version), Error>; } diff --git a/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/request.rs index 78dfdec7b5..c9cef56089 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/request.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/request.rs @@ -1,66 +1,29 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Request}; - -use super::super::{Error, QueryType, VersionedRequest}; +use super::super::{QueryType, VersionedRequest}; +use crate::impl_default_bincode_request_query_conversions; #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InnerAvailableBandwidthRequest {} -impl TryFrom for InnerAvailableBandwidthRequest { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - match value.query_type { - QueryType::AvailableBandwidth => { - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } - QueryType::TopupBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::AvailableBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: InnerAvailableBandwidthRequest) -> Result { - Ok(Self { - query_type: QueryType::AvailableBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerAvailableBandwidthRequest { - type Error = crate::error::MetadataError; - - fn try_from(value: Request) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Request { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerAvailableBandwidthRequest) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerAvailableBandwidthRequest +// - TryFrom for InnerAvailableBandwidthRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerAvailableBandwidthRequest, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); #[cfg(test)] mod tests { diff --git a/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/response.rs index 5243e312ee..d822a72e60 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/response.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/available_bandwidth/response.rs @@ -1,66 +1,30 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Response}; +use crate::impl_default_bincode_response_query_conversions; -use super::super::{Error, QueryType, VersionedResponse}; +use super::super::{QueryType, VersionedResponse}; #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InnerAvailableBandwidthResponse {} -impl TryFrom for InnerAvailableBandwidthResponse { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - match value.query_type { - QueryType::AvailableBandwidth => { - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } - QueryType::TopupBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::AvailableBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: InnerAvailableBandwidthResponse) -> Result { - Ok(Self { - query_type: QueryType::AvailableBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerAvailableBandwidthResponse { - type Error = crate::error::MetadataError; - - fn try_from(value: Response) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Response { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerAvailableBandwidthResponse) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerAvailableBandwidthResponse, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); #[cfg(test)] mod tests { diff --git a/common/wireguard-private-metadata/shared/src/models/v0/interface.rs b/common/wireguard-private-metadata/shared/src/models/v0/interface.rs index f52daba30e..9c6fc462b3 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/interface.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/interface.rs @@ -9,6 +9,7 @@ use super::{ topup_bandwidth::{request::InnerTopUpRequest, response::InnerTopUpResponse}, }; use crate::models::{Construct, Extract, Version, error::Error}; +use crate::{Request, Response}; #[derive(Debug, Clone, PartialEq)] pub enum RequestData { @@ -35,11 +36,11 @@ impl Extract for VersionedRequest { fn extract(&self) -> Result<(RequestData, Version), Error> { match self.query_type { QueryType::AvailableBandwidth => { - let _req = InnerAvailableBandwidthRequest::try_from(self.clone())?; + let _req = InnerAvailableBandwidthRequest::try_from(self)?; Ok((RequestData::AvailableBandwidth(()), VERSION)) } - QueryType::TopupBandwidth => { - let _req = InnerTopUpRequest::try_from(self.clone())?; + QueryType::TopUpBandwidth => { + let _req = InnerTopUpRequest::try_from(self)?; Ok((RequestData::TopUpBandwidth(()), VERSION)) } } @@ -61,13 +62,46 @@ impl Extract for VersionedResponse { fn extract(&self) -> Result<(ResponseData, Version), Error> { match self.query_type { QueryType::AvailableBandwidth => { - let _resp = InnerAvailableBandwidthResponse::try_from(self.clone())?; + let _resp = InnerAvailableBandwidthResponse::try_from(self)?; Ok((ResponseData::AvailableBandwidth(()), VERSION)) } - QueryType::TopupBandwidth => { - let _resp = InnerTopUpResponse::try_from(self.clone())?; + QueryType::TopUpBandwidth => { + let _resp = InnerTopUpResponse::try_from(self)?; Ok((ResponseData::TopUpBandwidth(()), VERSION)) } } } } + +#[cfg(feature = "testing")] +impl Extract for Request { + fn extract(&self) -> Result<(RequestData, Version), Error> { + match self.version { + Version::V0 => { + let versioned_request = VersionedRequest::try_from(self)?; + versioned_request.extract() + } + _ => Err(Error::UpdateNotPossible { + from: self.version, + to: VERSION, + }), + } + } +} + +#[cfg(feature = "testing")] +impl Construct for Response { + fn construct(info: ResponseData, version: Version) -> Result { + match version { + Version::V0 => { + let translate_response = info; + let versioned_response = VersionedResponse::construct(translate_response, version)?; + Ok(versioned_response.try_into()?) + } + _ => Err(Error::DowngradeNotPossible { + from: version, + to: VERSION, + }), + } + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v0/mod.rs b/common/wireguard-private-metadata/shared/src/models/v0/mod.rs index 997fbf6f57..fd90ac124a 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/mod.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/mod.rs @@ -3,15 +3,13 @@ use std::fmt::Display; -use bincode::Options; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use super::error::Error; use crate::{ - make_bincode_serializer, - models::{Request, Response, Version}, + impl_default_bincode_request_conversions, impl_default_bincode_response_conversions, + models::Version, }; pub(crate) mod available_bandwidth; @@ -31,7 +29,7 @@ pub use topup_bandwidth::{ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] pub enum QueryType { AvailableBandwidth, - TopupBandwidth, + TopUpBandwidth, } impl Display for QueryType { @@ -45,62 +43,24 @@ pub struct VersionedRequest { query_type: QueryType, inner: Vec, } - -impl TryFrom for Request { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - Ok(Request { - version: VERSION, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: Request) -> Result { - if value.version != VERSION { - return Err(Error::InvalidVersion { - source_version: value.version, - target_version: VERSION, - }); - } - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for Request +// - TryFrom for Request +// - TryFrom<&Request> for VersionedRequest +// - TryFrom for VersionedRequest +impl_default_bincode_request_conversions!(VersionedRequest, VERSION); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct VersionedResponse { query_type: QueryType, inner: Vec, } - -impl TryFrom for Response { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - Ok(Response { - version: VERSION, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: Response) -> Result { - if value.version != VERSION { - return Err(Error::InvalidVersion { - source_version: value.version, - target_version: VERSION, - }); - } - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for Response +// - TryFrom for Response +// - TryFrom<&Response> for VersionedResponse +// - TryFrom for VersionedResponse +impl_default_bincode_response_conversions!(VersionedResponse, VERSION); #[cfg(test)] mod tests { @@ -110,8 +70,10 @@ mod tests { }, topup_bandwidth::{request::InnerTopUpRequest, response::InnerTopUpResponse}, }; - use super::*; + use crate::make_bincode_serializer; + use crate::{Request, Response}; + use bincode::Options; #[test] fn serde_request_av_bw() { @@ -146,7 +108,7 @@ mod tests { #[test] fn serde_request_topup() { let req = VersionedRequest { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: make_bincode_serializer() .serialize(&InnerTopUpRequest {}) .unwrap(), @@ -161,7 +123,7 @@ mod tests { #[test] fn serde_response_topup() { let resp = VersionedResponse { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: make_bincode_serializer() .serialize(&InnerTopUpResponse {}) .unwrap(), diff --git a/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/request.rs index 9c333478d2..3cb15b5ee8 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/request.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/request.rs @@ -1,64 +1,30 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Request}; +use crate::impl_default_bincode_request_query_conversions; -use super::super::{Error, QueryType, VersionedRequest}; +use super::super::{QueryType, VersionedRequest}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InnerTopUpRequest {} -impl TryFrom for InnerTopUpRequest { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - match value.query_type { - QueryType::TopupBandwidth => Ok(make_bincode_serializer().deserialize(&value.inner)?), - QueryType::AvailableBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::TopupBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: InnerTopUpRequest) -> Result { - Ok(Self { - query_type: QueryType::TopupBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerTopUpRequest { - type Error = crate::error::MetadataError; - - fn try_from(value: Request) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Request { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerTopUpRequest) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerTopUpRequest, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); #[cfg(test)] mod tests { @@ -68,7 +34,7 @@ mod tests { fn serde() { let req = InnerTopUpRequest {}; let ser = VersionedRequest::try_from(req.clone()).unwrap(); - assert_eq!(QueryType::TopupBandwidth, ser.query_type); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); let de = InnerTopUpRequest::try_from(ser).unwrap(); assert_eq!(req, de); } @@ -76,7 +42,7 @@ mod tests { #[test] fn empty_content() { let future_req = VersionedRequest { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: vec![], }; assert!(InnerTopUpRequest::try_from(future_req).is_ok()); diff --git a/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/response.rs index cd934b6e7e..346792ce73 100644 --- a/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/response.rs +++ b/common/wireguard-private-metadata/shared/src/models/v0/topup_bandwidth/response.rs @@ -1,64 +1,30 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Response}; +use crate::impl_default_bincode_response_query_conversions; -use super::super::{Error, QueryType, VersionedResponse}; +use super::super::{QueryType, VersionedResponse}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InnerTopUpResponse {} -impl TryFrom for InnerTopUpResponse { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - match value.query_type { - QueryType::TopupBandwidth => Ok(make_bincode_serializer().deserialize(&value.inner)?), - QueryType::AvailableBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::TopupBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: InnerTopUpResponse) -> Result { - Ok(Self { - query_type: QueryType::TopupBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerTopUpResponse { - type Error = crate::error::MetadataError; - - fn try_from(value: Response) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Response { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerTopUpResponse) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerTopUpResponse, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); #[cfg(test)] mod tests { @@ -68,7 +34,7 @@ mod tests { fn serde() { let resp = InnerTopUpResponse {}; let ser = VersionedResponse::try_from(resp.clone()).unwrap(); - assert_eq!(QueryType::TopupBandwidth, ser.query_type); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); let de = InnerTopUpResponse::try_from(ser).unwrap(); assert_eq!(resp, de); } @@ -76,7 +42,7 @@ mod tests { #[test] fn empty_content() { let future_resp = VersionedResponse { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: vec![], }; assert!(InnerTopUpResponse::try_from(future_resp).is_ok()); diff --git a/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/request.rs index 78dfdec7b5..702c06377a 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/request.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/request.rs @@ -1,66 +1,30 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Request}; +use crate::impl_default_bincode_request_query_conversions; -use super::super::{Error, QueryType, VersionedRequest}; +use super::super::{QueryType, VersionedRequest}; #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InnerAvailableBandwidthRequest {} -impl TryFrom for InnerAvailableBandwidthRequest { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - match value.query_type { - QueryType::AvailableBandwidth => { - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } - QueryType::TopupBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::AvailableBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: InnerAvailableBandwidthRequest) -> Result { - Ok(Self { - query_type: QueryType::AvailableBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerAvailableBandwidthRequest { - type Error = crate::error::MetadataError; - - fn try_from(value: Request) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Request { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerAvailableBandwidthRequest) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerAvailableBandwidthRequest +// - TryFrom for InnerAvailableBandwidthRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerAvailableBandwidthRequest, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); #[cfg(test)] mod tests { diff --git a/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/response.rs index f5addd609f..a65ae64aed 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/response.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/available_bandwidth/response.rs @@ -1,68 +1,32 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Response}; +use crate::impl_default_bincode_response_query_conversions; -use super::super::{Error, QueryType, VersionedResponse}; +use super::super::{QueryType, VersionedResponse}; #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InnerAvailableBandwidthResponse { pub available_bandwidth: i64, } -impl TryFrom for InnerAvailableBandwidthResponse { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - match value.query_type { - QueryType::AvailableBandwidth => { - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } - QueryType::TopupBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::AvailableBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: InnerAvailableBandwidthResponse) -> Result { - Ok(Self { - query_type: QueryType::AvailableBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerAvailableBandwidthResponse { - type Error = crate::error::MetadataError; - - fn try_from(value: Response) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Response { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerAvailableBandwidthResponse) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerAvailableBandwidthResponse, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); #[cfg(test)] mod tests { diff --git a/common/wireguard-private-metadata/shared/src/models/v1/interface.rs b/common/wireguard-private-metadata/shared/src/models/v1/interface.rs index 763222e04a..7a02463956 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/interface.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/interface.rs @@ -5,6 +5,8 @@ use nym_credentials_interface::CredentialSpendingData; #[cfg(feature = "testing")] use super::super::v0 as previous; +#[cfg(feature = "testing")] +use crate::{Request, Response, v0}; use super::{ QueryType, VERSION, VersionedRequest, VersionedResponse, @@ -46,7 +48,7 @@ impl Extract for VersionedRequest { let _req = InnerAvailableBandwidthRequest::try_from(self.clone())?; Ok((RequestData::AvailableBandwidth(()), VERSION)) } - QueryType::TopupBandwidth => { + QueryType::TopUpBandwidth => { let req = InnerTopUpRequest::try_from(self.clone())?; Ok(( RequestData::TopUpBandwidth(Box::new(req.credential)), @@ -84,7 +86,7 @@ impl Extract for VersionedResponse { VERSION, )) } - QueryType::TopupBandwidth => { + QueryType::TopUpBandwidth => { let resp = InnerTopUpResponse::try_from(self.clone())?; Ok(( ResponseData::TopUpBandwidth(resp.available_bandwidth), @@ -98,7 +100,7 @@ impl Extract for VersionedResponse { // this should be with #[cfg(feature = "testing")] only coming from v0, don't copy this for future versions #[cfg(feature = "testing")] impl TryFrom for RequestData { - type Error = super::Error; + type Error = crate::models::error::Error; fn try_from(value: previous::interface::RequestData) -> Result { match value { @@ -106,7 +108,7 @@ impl TryFrom for RequestData { Ok(Self::AvailableBandwidth(inner)) } previous::interface::RequestData::TopUpBandwidth(_) => { - Err(super::Error::UpdateNotPossible { + Err(crate::models::Error::UpdateNotPossible { from: previous::VERSION, to: VERSION, }) @@ -118,7 +120,7 @@ impl TryFrom for RequestData { // this should be with #[cfg(feature = "testing")] only coming from v0, don't copy this for future versions #[cfg(feature = "testing")] impl TryFrom for previous::interface::RequestData { - type Error = super::Error; + type Error = crate::models::error::Error; fn try_from(value: RequestData) -> Result { match value { @@ -131,18 +133,18 @@ impl TryFrom for previous::interface::RequestData { // this should be with #[cfg(feature = "testing")] only coming from v0, don't copy this for future versions #[cfg(feature = "testing")] impl TryFrom for ResponseData { - type Error = super::Error; + type Error = crate::models::error::Error; fn try_from(value: previous::interface::ResponseData) -> Result { match value { previous::interface::ResponseData::AvailableBandwidth(_) => { - Err(super::Error::UpdateNotPossible { + Err(crate::models::error::Error::UpdateNotPossible { from: previous::VERSION, to: VERSION, }) } previous::interface::ResponseData::TopUpBandwidth(_) => { - Err(super::Error::UpdateNotPossible { + Err(crate::models::error::Error::UpdateNotPossible { from: previous::VERSION, to: VERSION, }) @@ -154,7 +156,7 @@ impl TryFrom for ResponseData { // this should be with #[cfg(feature = "testing")] only coming from v0, don't copy this for future versions #[cfg(feature = "testing")] impl TryFrom for previous::interface::ResponseData { - type Error = super::Error; + type Error = crate::models::error::Error; fn try_from(value: ResponseData) -> Result { match value { @@ -164,13 +166,64 @@ impl TryFrom for previous::interface::ResponseData { } } +#[cfg(feature = "testing")] +impl Extract for Request { + fn extract(&self) -> Result<(RequestData, Version), Error> { + match self.version { + Version::V0 => { + let versioned_request = v0::VersionedRequest::try_from(self)?; + let (extracted_v0_info, version) = versioned_request.extract()?; + + let v1_info = RequestData::try_from(extracted_v0_info)?; + Ok((v1_info, version)) + } + Version::V1 => { + let versioned_request = VersionedRequest::try_from(self)?; + versioned_request.extract() + } + // a v1 server does not have any code for downgrading v2 into v1 + _ => Err(Error::DowngradeNotPossible { + from: self.version, + to: VERSION, + }), + } + } +} + +#[cfg(feature = "testing")] +impl Construct for Response { + fn construct(info: ResponseData, version: Version) -> Result { + match version { + Version::V0 => { + let v1_info = info; + let v0_info = v0::interface::ResponseData::try_from(v1_info)?; + + let versioned_response = v0::VersionedResponse::construct(v0_info, version)?; + Ok(versioned_response.try_into()?) + } + Version::V1 => { + let translate_response = info; + let versioned_response = VersionedResponse::construct(translate_response, version)?; + Ok(versioned_response.try_into()?) + } + // a v1 server does not have any code for downgrading v2 into v1 + _ => Err(Error::DowngradeNotPossible { + from: version, + to: VERSION, + }), + } + } +} + #[cfg(test)] mod test { + #[cfg(feature = "testing")] + use super::*; + #[cfg(feature = "testing")] use crate::models::tests::CREDENTIAL_BYTES; - use super::*; - #[test] + #[cfg(feature = "testing")] fn request_upgrade() { assert_eq!( RequestData::try_from(previous::interface::RequestData::AvailableBandwidth(())) @@ -183,6 +236,7 @@ mod test { } #[test] + #[cfg(feature = "testing")] fn response_upgrade() { assert!( ResponseData::try_from(previous::interface::ResponseData::AvailableBandwidth(())) @@ -194,6 +248,7 @@ mod test { } #[test] + #[cfg(feature = "testing")] fn request_downgrade() { assert_eq!( previous::interface::RequestData::try_from(RequestData::AvailableBandwidth(())) @@ -210,6 +265,7 @@ mod test { } #[test] + #[cfg(feature = "testing")] fn response_downgrade() { assert_eq!( previous::interface::ResponseData::try_from(ResponseData::AvailableBandwidth(42)) diff --git a/common/wireguard-private-metadata/shared/src/models/v1/mod.rs b/common/wireguard-private-metadata/shared/src/models/v1/mod.rs index 787a74f671..050b97caa1 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/mod.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/mod.rs @@ -3,15 +3,13 @@ use std::fmt::Display; -use bincode::Options; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use super::error::Error; use crate::{ - make_bincode_serializer, - models::{Request, Response, Version}, + impl_default_bincode_request_conversions, impl_default_bincode_response_conversions, + models::Version, }; pub use available_bandwidth::{ @@ -31,7 +29,7 @@ pub const VERSION: Version = Version::V1; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] pub enum QueryType { AvailableBandwidth, - TopupBandwidth, + TopUpBandwidth, } impl Display for QueryType { @@ -45,82 +43,44 @@ pub struct VersionedRequest { query_type: QueryType, inner: Vec, } - -impl TryFrom for Request { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - Ok(Request { - version: VERSION, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: Request) -> Result { - if value.version != VERSION { - return Err(Error::InvalidVersion { - source_version: value.version, - target_version: VERSION, - }); - } - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for Request +// - TryFrom for Request +// - TryFrom<&Request> for VersionedRequest +// - TryFrom for VersionedRequest +impl_default_bincode_request_conversions!(VersionedRequest, VERSION); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct VersionedResponse { query_type: QueryType, inner: Vec, } - -impl TryFrom for Response { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - Ok(Response { - version: VERSION, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: Response) -> Result { - if value.version != VERSION { - return Err(Error::InvalidVersion { - source_version: value.version, - target_version: VERSION, - }); - } - Ok(make_bincode_serializer().deserialize(&value.inner)?) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for Response +// - TryFrom for Response +// - TryFrom<&Response> for VersionedResponse +// - TryFrom for VersionedResponse +impl_default_bincode_response_conversions!(VersionedResponse, VERSION); #[cfg(test)] mod tests { - - use nym_credentials_interface::CredentialSpendingData; - - use crate::models::tests::CREDENTIAL_BYTES; - use self::{ available_bandwidth::{ request::InnerAvailableBandwidthRequest, response::InnerAvailableBandwidthResponse, }, topup_bandwidth::{request::InnerTopUpRequest, response::InnerTopUpResponse}, }; + use crate::models::error::Error; + use crate::models::tests::CREDENTIAL_BYTES; + use crate::{Request, Response, make_bincode_serializer}; + use bincode::Options; + use nym_credentials_interface::CredentialSpendingData; use super::*; #[test] fn mismatched_request_version() { - let version = Version::V0; + let version = Version::V2; let future_bw = Request { version, inner: vec![], @@ -139,7 +99,7 @@ mod tests { #[test] fn mismatched_response_version() { - let version = Version::V0; + let version = Version::V2; let future_bw = Response { version, inner: vec![], @@ -191,7 +151,7 @@ mod tests { #[test] fn serde_request_topup() { let req = VersionedRequest { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: make_bincode_serializer() .serialize(&InnerTopUpRequest { credential: CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), @@ -208,7 +168,7 @@ mod tests { #[test] fn serde_response_topup() { let resp = VersionedResponse { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: make_bincode_serializer() .serialize(&InnerTopUpResponse { available_bandwidth: 42, diff --git a/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/request.rs index 871cc127ef..3a3de39689 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/request.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/request.rs @@ -1,13 +1,12 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use nym_credentials_interface::CredentialSpendingData; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Request}; +use crate::impl_default_bincode_request_query_conversions; -use super::super::{Error, QueryType, VersionedRequest}; +use super::super::{QueryType, VersionedRequest}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InnerTopUpRequest { @@ -15,54 +14,21 @@ pub struct InnerTopUpRequest { pub credential: CredentialSpendingData, } -impl TryFrom for InnerTopUpRequest { - type Error = Error; - - fn try_from(value: VersionedRequest) -> Result { - match value.query_type { - QueryType::TopupBandwidth => Ok(make_bincode_serializer().deserialize(&value.inner)?), - QueryType::AvailableBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::TopupBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedRequest { - type Error = Error; - - fn try_from(value: InnerTopUpRequest) -> Result { - Ok(Self { - query_type: QueryType::TopupBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerTopUpRequest { - type Error = crate::error::MetadataError; - - fn try_from(value: Request) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Request { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerTopUpRequest) -> Result { - VersionedRequest::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerTopUpRequest, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); #[cfg(test)] mod tests { @@ -76,7 +42,7 @@ mod tests { credential: CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), }; let ser = VersionedRequest::try_from(req.clone()).unwrap(); - assert_eq!(QueryType::TopupBandwidth, ser.query_type); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); let de = InnerTopUpRequest::try_from(ser).unwrap(); assert_eq!(req, de); } @@ -84,7 +50,7 @@ mod tests { #[test] fn invalid_content() { let future_req = VersionedRequest { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: vec![], }; assert!(InnerTopUpRequest::try_from(future_req).is_err()); diff --git a/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/response.rs index 08e0ef111f..dccc735b8f 100644 --- a/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/response.rs +++ b/common/wireguard-private-metadata/shared/src/models/v1/topup_bandwidth/response.rs @@ -1,66 +1,32 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use bincode::Options; use serde::{Deserialize, Serialize}; -use crate::{make_bincode_serializer, models::Response}; +use crate::impl_default_bincode_response_query_conversions; -use super::super::{Error, QueryType, VersionedResponse}; +use super::super::{QueryType, VersionedResponse}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InnerTopUpResponse { pub available_bandwidth: i64, } -impl TryFrom for InnerTopUpResponse { - type Error = Error; - - fn try_from(value: VersionedResponse) -> Result { - match value.query_type { - QueryType::TopupBandwidth => Ok(make_bincode_serializer().deserialize(&value.inner)?), - QueryType::AvailableBandwidth => Err(Error::InvalidQueryType { - source_query_type: value.query_type.to_string(), - target_query_type: QueryType::TopupBandwidth.to_string(), - }), - } - } -} - -impl TryFrom for VersionedResponse { - type Error = Error; - - fn try_from(value: InnerTopUpResponse) -> Result { - Ok(Self { - query_type: QueryType::TopupBandwidth, - inner: make_bincode_serializer().serialize(&value)?, - }) - } -} - -impl TryFrom for InnerTopUpResponse { - type Error = crate::error::MetadataError; - - fn try_from(value: Response) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} - -impl TryFrom for Response { - type Error = crate::error::MetadataError; - - fn try_from(value: InnerTopUpResponse) -> Result { - VersionedResponse::try_from(value)? - .try_into() - .map_err(|err: Error| crate::error::MetadataError::Models { - message: err.to_string(), - }) - } -} +// Implements: +// - TryFrom<&VersionedResponse> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerTopUpResponse, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); #[cfg(test)] mod tests { @@ -72,7 +38,7 @@ mod tests { available_bandwidth: 42, }; let ser = VersionedResponse::try_from(resp.clone()).unwrap(); - assert_eq!(QueryType::TopupBandwidth, ser.query_type); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); let de = InnerTopUpResponse::try_from(ser).unwrap(); assert_eq!(resp, de); } @@ -80,7 +46,7 @@ mod tests { #[test] fn invalid_content() { let future_resp = VersionedResponse { - query_type: QueryType::TopupBandwidth, + query_type: QueryType::TopUpBandwidth, inner: vec![], }; assert!(InnerTopUpResponse::try_from(future_resp).is_err()); diff --git a/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/mod.rs b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/mod.rs new file mode 100644 index 0000000000..10698cca2d --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod request; +pub mod response; diff --git a/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/request.rs new file mode 100644 index 0000000000..702c06377a --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/request.rs @@ -0,0 +1,50 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_request_query_conversions; + +use super::super::{QueryType, VersionedRequest}; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InnerAvailableBandwidthRequest {} + +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerAvailableBandwidthRequest +// - TryFrom for InnerAvailableBandwidthRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerAvailableBandwidthRequest, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde() { + let req = InnerAvailableBandwidthRequest {}; + let ser = VersionedRequest::try_from(req).unwrap(); + assert_eq!(QueryType::AvailableBandwidth, ser.query_type); + let de = InnerAvailableBandwidthRequest::try_from(ser).unwrap(); + assert_eq!(req, de); + } + + #[test] + fn empty_content() { + let future_req = VersionedRequest { + query_type: QueryType::AvailableBandwidth, + inner: vec![], + }; + assert!(InnerAvailableBandwidthRequest::try_from(future_req).is_ok()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/response.rs new file mode 100644 index 0000000000..0da8eb0a2b --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/available_bandwidth/response.rs @@ -0,0 +1,56 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_response_query_conversions; + +use super::super::{QueryType, VersionedResponse}; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InnerAvailableBandwidthResponse { + pub available_bandwidth: i64, + pub upgrade_mode: bool, +} + +// Implements: +// - TryFrom<&VersionedResponse> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerAvailableBandwidthResponse +// - TryFrom for InnerAvailableBandwidthResponse +// - TryFrom<&InnerAvailableBandwidthResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerAvailableBandwidthResponse, + QueryType::AvailableBandwidth, + QueryType::AvailableBandwidth +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde() { + let resp = InnerAvailableBandwidthResponse { + available_bandwidth: 42, + upgrade_mode: false, + }; + let ser = VersionedResponse::try_from(resp).unwrap(); + assert_eq!(QueryType::AvailableBandwidth, ser.query_type); + let de = InnerAvailableBandwidthResponse::try_from(ser).unwrap(); + assert_eq!(resp, de); + } + + #[test] + fn invalid_content() { + let future_resp = VersionedResponse { + query_type: QueryType::AvailableBandwidth, + inner: vec![], + }; + assert!(InnerAvailableBandwidthResponse::try_from(future_resp).is_err()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/mod.rs b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/mod.rs new file mode 100644 index 0000000000..10698cca2d --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod request; +pub mod response; diff --git a/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/request.rs b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/request.rs new file mode 100644 index 0000000000..2baba00f8a --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/request.rs @@ -0,0 +1,76 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_request_query_conversions; + +use super::super::{QueryType, VersionedRequest}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum UpgradeModeCheckRequestType { + /// Attempt to request upgrade mode recheck via the JWT issued as the result of + /// global attestation.json being published + UpgradeModeJwt { token: String }, +} + +// each versioned variant should always be a subset of the latest one defined in the interface +// so a From trait should always be implementable (as opposed to having to do TryFrom) +// (but this is not applicable in this instance as this IS the latest (05.11.25) +// impl From for crate::models::interface::UpgradeModeCheckRequestType { +// fn from(typ: UpgradeModeCheckRequestType) -> Self { +// match typ { +// UpgradeModeCheckRequestType::UpgradeModeJwt { token } => { +// crate::models::interface::UpgradeModeCheckRequestType::UpgradeModeJwt { token } +// } +// } +// } +// } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InnerUpgradeModeCheckRequest { + pub request_type: UpgradeModeCheckRequestType, +} + +// Implements: +// - TryFrom<&VersionedRequest> for InnerUpgradeModeCheckRequest +// - TryFrom for InnerUpgradeModeCheckRequest +// - TryFrom<&InnerUpgradeModeCheckRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerUpgradeModeCheckRequest +// - TryFrom for InnerUpgradeModeCheckRequest +// - TryFrom<&InnerUpgradeModeCheckRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerUpgradeModeCheckRequest, + QueryType::UpgradeModeCheck, + QueryType::UpgradeModeCheck +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde() { + let req = InnerUpgradeModeCheckRequest { + request_type: UpgradeModeCheckRequestType::UpgradeModeJwt { + token: "dummy.jwt.token".to_string(), + }, + }; + let ser = VersionedRequest::try_from(req.clone()).unwrap(); + assert_eq!(QueryType::UpgradeModeCheck, ser.query_type); + let de = InnerUpgradeModeCheckRequest::try_from(ser).unwrap(); + assert_eq!(req, de); + } + + #[test] + fn invalid_content() { + let future_req = VersionedRequest { + query_type: QueryType::UpgradeModeCheck, + inner: vec![], + }; + assert!(InnerUpgradeModeCheckRequest::try_from(future_req).is_err()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/response.rs b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/response.rs new file mode 100644 index 0000000000..3b83ae69eb --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/check_upgrade_mode/response.rs @@ -0,0 +1,52 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_response_query_conversions; + +use super::super::{QueryType, VersionedResponse}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InnerUpgradeModeCheckResponse { + pub upgrade_mode: bool, +} + +// Implements: +// - TryFrom<&VersionedResponse> for InnerUpgradeModeCheckResponse +// - TryFrom for InnerUpgradeModeCheckResponse +// - TryFrom<&InnerUpgradeModeCheckResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerUpgradeModeCheckResponse +// - TryFrom for InnerUpgradeModeCheckResponse +// - TryFrom<&InnerUpgradeModeCheckResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerUpgradeModeCheckResponse, + QueryType::UpgradeModeCheck, + QueryType::UpgradeModeCheck +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde() { + let resp = InnerUpgradeModeCheckResponse { upgrade_mode: true }; + let ser = VersionedResponse::try_from(resp.clone()).unwrap(); + assert_eq!(QueryType::UpgradeModeCheck, ser.query_type); + let de = InnerUpgradeModeCheckResponse::try_from(ser).unwrap(); + assert_eq!(resp, de); + } + + #[test] + fn invalid_content() { + let future_resp = VersionedResponse { + query_type: QueryType::UpgradeModeCheck, + inner: vec![], + }; + assert!(InnerUpgradeModeCheckResponse::try_from(future_resp).is_err()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/interface.rs b/common/wireguard-private-metadata/shared/src/models/v2/interface.rs new file mode 100644 index 0000000000..9a5e79ff61 --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/interface.rs @@ -0,0 +1,304 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_credentials_interface::BandwidthCredential; + +use super::super::v1 as previous; + +use super::{ + QueryType, VERSION, VersionedRequest, VersionedResponse, + available_bandwidth::{ + request::InnerAvailableBandwidthRequest, response::InnerAvailableBandwidthResponse, + }, + check_upgrade_mode::{ + request::{InnerUpgradeModeCheckRequest, UpgradeModeCheckRequestType}, + response::InnerUpgradeModeCheckResponse, + }, + topup_bandwidth::{request::InnerTopUpRequest, response::InnerTopUpResponse}, +}; +use crate::models::{Construct, Extract, Version, error::Error}; + +#[derive(Debug, Clone, PartialEq)] +pub enum RequestData { + AvailableBandwidth, + TopUpBandwidth { + credential: Box, + }, + UpgradeModeCheck { + typ: UpgradeModeCheckRequestType, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ResponseData { + AvailableBandwidth { + amount: i64, + upgrade_mode: bool, + }, + TopUpBandwidth { + available_bandwidth: i64, + upgrade_mode: bool, + }, + UpgradeMode { + upgrade_mode: bool, + }, +} + +impl Construct for VersionedRequest { + fn construct(info: RequestData, _version: Version) -> Result { + match info { + RequestData::AvailableBandwidth => Ok(InnerAvailableBandwidthRequest {}.try_into()?), + RequestData::TopUpBandwidth { credential } => Ok(InnerTopUpRequest { + credential: *credential, + } + .try_into()?), + RequestData::UpgradeModeCheck { typ } => { + Ok(InnerUpgradeModeCheckRequest { request_type: typ }.try_into()?) + } + } + } +} + +impl Extract for VersionedRequest { + fn extract(&self) -> Result<(RequestData, Version), Error> { + match self.query_type { + QueryType::AvailableBandwidth => { + let _req = InnerAvailableBandwidthRequest::try_from(self)?; + Ok((RequestData::AvailableBandwidth, VERSION)) + } + QueryType::TopUpBandwidth => { + let req = InnerTopUpRequest::try_from(self)?; + Ok(( + RequestData::TopUpBandwidth { + credential: Box::new(req.credential), + }, + VERSION, + )) + } + QueryType::UpgradeModeCheck => { + let req = InnerUpgradeModeCheckRequest::try_from(self)?; + Ok(( + RequestData::UpgradeModeCheck { + typ: req.request_type, + }, + VERSION, + )) + } + } + } +} + +impl Construct for VersionedResponse { + fn construct(info: ResponseData, _version: Version) -> Result { + match info { + ResponseData::AvailableBandwidth { + amount, + upgrade_mode, + } => Ok(InnerAvailableBandwidthResponse { + available_bandwidth: amount, + upgrade_mode, + } + .try_into()?), + ResponseData::TopUpBandwidth { + available_bandwidth, + upgrade_mode, + } => Ok(InnerTopUpResponse { + available_bandwidth, + upgrade_mode, + } + .try_into()?), + ResponseData::UpgradeMode { upgrade_mode } => { + Ok(InnerUpgradeModeCheckResponse { upgrade_mode }.try_into()?) + } + } + } +} + +impl Extract for VersionedResponse { + fn extract(&self) -> Result<(ResponseData, Version), Error> { + match self.query_type { + QueryType::AvailableBandwidth => { + let resp = InnerAvailableBandwidthResponse::try_from(self)?; + Ok(( + ResponseData::AvailableBandwidth { + amount: resp.available_bandwidth, + upgrade_mode: resp.upgrade_mode, + }, + VERSION, + )) + } + QueryType::TopUpBandwidth => { + let resp = InnerTopUpResponse::try_from(self)?; + Ok(( + ResponseData::TopUpBandwidth { + available_bandwidth: resp.available_bandwidth, + upgrade_mode: resp.upgrade_mode, + }, + VERSION, + )) + } + QueryType::UpgradeModeCheck => { + let resp = InnerUpgradeModeCheckResponse::try_from(self)?; + Ok(( + ResponseData::UpgradeMode { + upgrade_mode: resp.upgrade_mode, + }, + VERSION, + )) + } + } + } +} + +impl TryFrom for RequestData { + type Error = super::Error; + + fn try_from(value: previous::interface::RequestData) -> Result { + match value { + previous::interface::RequestData::AvailableBandwidth(_) => Ok(Self::AvailableBandwidth), + previous::interface::RequestData::TopUpBandwidth(zk_nym) => Ok(Self::TopUpBandwidth { + credential: Box::new((*zk_nym).into()), + }), + } + } +} + +impl TryFrom for previous::interface::RequestData { + type Error = super::Error; + + fn try_from(value: RequestData) -> Result { + match value { + RequestData::AvailableBandwidth => Ok(Self::AvailableBandwidth(())), + RequestData::TopUpBandwidth { credential } => match *credential { + BandwidthCredential::ZkNym(zk_nym) => Ok(Self::TopUpBandwidth(zk_nym)), + BandwidthCredential::UpgradeModeJWT { .. } => { + Err(super::Error::DowngradeNotPossible { + from: VERSION, + to: previous::VERSION, + }) + } + }, + RequestData::UpgradeModeCheck { .. } => Err(super::Error::DowngradeNotPossible { + from: VERSION, + to: previous::VERSION, + }), + } + } +} + +impl TryFrom for ResponseData { + type Error = super::Error; + + fn try_from(value: previous::interface::ResponseData) -> Result { + match value { + previous::interface::ResponseData::AvailableBandwidth(_) => { + Err(super::Error::UpdateNotPossible { + from: previous::VERSION, + to: VERSION, + }) + } + previous::interface::ResponseData::TopUpBandwidth(_) => { + Err(super::Error::UpdateNotPossible { + from: previous::VERSION, + to: VERSION, + }) + } + } + } +} + +impl TryFrom for previous::interface::ResponseData { + type Error = super::Error; + + fn try_from(value: ResponseData) -> Result { + match value { + ResponseData::AvailableBandwidth { amount, .. } => Ok(Self::AvailableBandwidth(amount)), + ResponseData::TopUpBandwidth { + available_bandwidth, + .. + } => Ok(Self::TopUpBandwidth(available_bandwidth)), + ResponseData::UpgradeMode { .. } => Err(super::Error::DowngradeNotPossible { + from: VERSION, + to: previous::VERSION, + }), + } + } +} + +#[cfg(test)] +mod test { + use crate::models::tests::CREDENTIAL_BYTES; + use nym_credentials_interface::CredentialSpendingData; + + use super::*; + + #[test] + fn request_upgrade() { + assert_eq!( + RequestData::try_from(previous::interface::RequestData::AvailableBandwidth(())) + .unwrap(), + RequestData::AvailableBandwidth + ); + assert_eq!( + RequestData::try_from(previous::interface::RequestData::TopUpBandwidth(Box::new( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap() + ))) + .unwrap(), + RequestData::TopUpBandwidth { + credential: Box::new(BandwidthCredential::ZkNym(Box::new( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap() + ))), + } + ); + } + + #[test] + fn response_upgrade() { + assert!( + ResponseData::try_from(previous::interface::ResponseData::AvailableBandwidth(42)) + .is_err() + ); + assert!( + ResponseData::try_from(previous::interface::ResponseData::TopUpBandwidth(42)).is_err() + ); + } + + #[test] + fn request_downgrade() { + assert_eq!( + previous::interface::RequestData::try_from(RequestData::AvailableBandwidth).unwrap(), + previous::interface::RequestData::AvailableBandwidth(()) + ); + assert_eq!( + previous::interface::RequestData::try_from(RequestData::TopUpBandwidth { + credential: Box::new(BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap() + )) + }) + .unwrap(), + previous::interface::RequestData::TopUpBandwidth(Box::new( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap() + )) + ); + } + + #[test] + fn response_downgrade() { + assert_eq!( + previous::interface::ResponseData::try_from(ResponseData::AvailableBandwidth { + amount: 42, + upgrade_mode: true + }) + .unwrap(), + previous::interface::ResponseData::AvailableBandwidth(42) + ); + assert_eq!( + previous::interface::ResponseData::try_from(ResponseData::TopUpBandwidth { + available_bandwidth: 42, + upgrade_mode: true, + }) + .unwrap(), + previous::interface::ResponseData::TopUpBandwidth(42) + ); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/mod.rs b/common/wireguard-private-metadata/shared/src/models/v2/mod.rs new file mode 100644 index 0000000000..6eea7d0713 --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/mod.rs @@ -0,0 +1,197 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use super::error::Error; +use crate::{ + impl_default_bincode_request_conversions, impl_default_bincode_response_conversions, + models::Version, +}; + +pub use available_bandwidth::{ + request::InnerAvailableBandwidthRequest as AvailableBandwidthRequest, + response::InnerAvailableBandwidthResponse as AvailableBandwidthResponse, +}; +pub use check_upgrade_mode::{ + request::{ + InnerUpgradeModeCheckRequest as UpgradeModeCheckRequest, UpgradeModeCheckRequestType, + }, + response::InnerUpgradeModeCheckResponse as UpgradeModeCheckResponse, +}; +pub use topup_bandwidth::{ + request::InnerTopUpRequest as TopUpRequest, response::InnerTopUpResponse as TopUpResponse, +}; + +pub(crate) mod available_bandwidth; +pub(crate) mod check_upgrade_mode; +pub mod interface; +pub(crate) mod topup_bandwidth; + +pub const VERSION: Version = Version::V2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] +pub enum QueryType { + AvailableBandwidth, + TopUpBandwidth, + UpgradeModeCheck, +} + +impl Display for QueryType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct VersionedRequest { + query_type: QueryType, + inner: Vec, +} +// Implements: +// - TryFrom<&VersionedRequest> for Request +// - TryFrom for Request +// - TryFrom<&Request> for VersionedRequest +// - TryFrom for VersionedRequest +impl_default_bincode_request_conversions!(VersionedRequest, VERSION); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct VersionedResponse { + query_type: QueryType, + inner: Vec, +} +// Implements: +// - TryFrom<&VersionedResponse> for Response +// - TryFrom for Response +// - TryFrom<&Response> for VersionedResponse +// - TryFrom for VersionedResponse +impl_default_bincode_response_conversions!(VersionedResponse, VERSION); + +#[cfg(test)] +mod tests { + + use self::{ + available_bandwidth::{ + request::InnerAvailableBandwidthRequest, response::InnerAvailableBandwidthResponse, + }, + topup_bandwidth::{request::InnerTopUpRequest, response::InnerTopUpResponse}, + }; + use crate::models::tests::CREDENTIAL_BYTES; + use crate::{Request, Response, make_bincode_serializer}; + use bincode::Options; + use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData}; + + use super::*; + + #[test] + fn mismatched_request_version() { + let version = Version::V1; + let future_bw = Request { + version, + inner: vec![], + }; + if let Err(Error::InvalidVersion { + source_version, + target_version, + }) = VersionedRequest::try_from(future_bw) + { + assert_eq!(source_version, version); + assert_eq!(target_version, VERSION); + } else { + panic!("failed"); + }; + } + + #[test] + fn mismatched_response_version() { + let version = Version::V1; + let future_bw = Response { + version, + inner: vec![], + }; + if let Err(Error::InvalidVersion { + source_version, + target_version, + }) = VersionedResponse::try_from(future_bw) + { + assert_eq!(source_version, version); + assert_eq!(target_version, VERSION); + } else { + panic!("failed"); + }; + } + + #[test] + fn serde_request_av_bw() { + let req = VersionedRequest { + query_type: QueryType::AvailableBandwidth, + inner: make_bincode_serializer() + .serialize(&InnerAvailableBandwidthResponse { + available_bandwidth: 42, + upgrade_mode: true, + }) + .unwrap(), + }; + + let ser = Request::try_from(req.clone()).unwrap(); + assert_eq!(VERSION, ser.version); + let de = VersionedRequest::try_from(ser).unwrap(); + assert_eq!(req, de); + } + + #[test] + fn serde_response_av_bw() { + let resp = VersionedResponse { + query_type: QueryType::AvailableBandwidth, + inner: make_bincode_serializer() + .serialize(&InnerAvailableBandwidthRequest {}) + .unwrap(), + }; + + let ser = Response::try_from(resp.clone()).unwrap(); + assert_eq!(VERSION, ser.version); + let de = VersionedResponse::try_from(ser).unwrap(); + assert_eq!(resp, de); + } + + #[test] + fn serde_request_topup() { + let req = VersionedRequest { + query_type: QueryType::TopUpBandwidth, + inner: make_bincode_serializer() + .serialize(&InnerTopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + }) + .unwrap(), + }; + + let ser = Request::try_from(req.clone()).unwrap(); + assert_eq!(VERSION, ser.version); + let de = VersionedRequest::try_from(ser).unwrap(); + assert_eq!(req, de); + } + + #[test] + fn serde_response_topup() { + let resp = VersionedResponse { + query_type: QueryType::TopUpBandwidth, + inner: make_bincode_serializer() + .serialize(&InnerTopUpResponse { + available_bandwidth: 42, + upgrade_mode: true, + }) + .unwrap(), + }; + + let ser = Response::try_from(resp.clone()).unwrap(); + assert_eq!(VERSION, ser.version); + let de = VersionedResponse::try_from(ser).unwrap(); + assert_eq!(resp, de); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/mod.rs b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/mod.rs new file mode 100644 index 0000000000..10698cca2d --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod request; +pub mod response; diff --git a/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/request.rs b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/request.rs new file mode 100644 index 0000000000..a029b3920e --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/request.rs @@ -0,0 +1,61 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_credentials_interface::BandwidthCredential; +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_request_query_conversions; + +use super::super::{QueryType, VersionedRequest}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InnerTopUpRequest { + /// Ecash credential + pub credential: BandwidthCredential, +} + +// Implements: +// - TryFrom<&VersionedRequest> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for VersionedRequest +// - TryFrom for VersionedRequest +// - TryFrom<&Request> for InnerTopUpRequest +// - TryFrom for InnerTopUpRequest +// - TryFrom<&InnerTopUpRequest> for Request +// - TryFrom for Request +impl_default_bincode_request_query_conversions!( + VersionedRequest, + InnerTopUpRequest, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); + +#[cfg(test)] +mod tests { + use crate::models::tests::CREDENTIAL_BYTES; + use nym_credentials_interface::CredentialSpendingData; + + use super::*; + + #[test] + fn serde() { + let req = InnerTopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + }; + let ser = VersionedRequest::try_from(req.clone()).unwrap(); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); + let de = InnerTopUpRequest::try_from(ser).unwrap(); + assert_eq!(req, de); + } + + #[test] + fn invalid_content() { + let future_req = VersionedRequest { + query_type: QueryType::TopUpBandwidth, + inner: vec![], + }; + assert!(InnerTopUpRequest::try_from(future_req).is_err()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/response.rs b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/response.rs new file mode 100644 index 0000000000..0cbf3b00d8 --- /dev/null +++ b/common/wireguard-private-metadata/shared/src/models/v2/topup_bandwidth/response.rs @@ -0,0 +1,56 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +use crate::impl_default_bincode_response_query_conversions; + +use super::super::{QueryType, VersionedResponse}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InnerTopUpResponse { + pub available_bandwidth: i64, + pub upgrade_mode: bool, +} + +// Implements: +// - TryFrom<&VersionedResponse> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for VersionedResponse +// - TryFrom for VersionedResponse +// - TryFrom<&Response> for InnerTopUpResponse +// - TryFrom for InnerTopUpResponse +// - TryFrom<&InnerTopUpResponse> for Response +// - TryFrom for Response +impl_default_bincode_response_query_conversions!( + VersionedResponse, + InnerTopUpResponse, + QueryType::TopUpBandwidth, + QueryType::TopUpBandwidth +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde() { + let resp = InnerTopUpResponse { + available_bandwidth: 42, + upgrade_mode: true, + }; + let ser = VersionedResponse::try_from(resp.clone()).unwrap(); + assert_eq!(QueryType::TopUpBandwidth, ser.query_type); + let de = InnerTopUpResponse::try_from(ser).unwrap(); + assert_eq!(resp, de); + } + + #[test] + fn invalid_content() { + let future_resp = VersionedResponse { + query_type: QueryType::TopUpBandwidth, + inner: vec![], + }; + assert!(InnerTopUpResponse::try_from(future_resp).is_err()); + } +} diff --git a/common/wireguard-private-metadata/shared/src/routes.rs b/common/wireguard-private-metadata/shared/src/routes.rs index bda615fe1c..2919908e5d 100644 --- a/common/wireguard-private-metadata/shared/src/routes.rs +++ b/common/wireguard-private-metadata/shared/src/routes.rs @@ -4,7 +4,9 @@ pub const V1_API_VERSION: &str = "v1"; pub const BANDWIDTH: &str = "bandwidth"; +pub const NETWORK: &str = "network"; pub const VERSION: &str = "version"; pub const AVAILABLE: &str = "available"; pub const TOPUP: &str = "topup"; +pub const UPGRADE_MODE_CHECK: &str = "upgrade-mode-check"; diff --git a/common/wireguard-private-metadata/tests/Cargo.toml b/common/wireguard-private-metadata/tests/Cargo.toml index 6827c16f60..074d088f15 100644 --- a/common/wireguard-private-metadata/tests/Cargo.toml +++ b/common/wireguard-private-metadata/tests/Cargo.toml @@ -11,16 +11,21 @@ license.workspace = true [dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["tokio", "macros"] } +futures = { workspace = true } nym-credential-verification = { path = "../../credential-verification" } nym-credentials-interface = { path = "../../credentials-interface" } +nym-crypto = { path = "../../crypto", features = ["asymmetric"] } nym-http-api-client = { path = "../../http-api-client" } nym-http-api-common = { path = "../../http-api-common", features = [ "middleware", "utoipa", "output", ] } +nym-upgrade-mode-check = { path = "../../upgrade-mode-check" } nym-wireguard = { path = "../../wireguard" } +time = { workspace = true, features = ["macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] } +tower = { workspace = true } tower-http = { workspace = true, features = [ "cors", "trace", @@ -37,5 +42,3 @@ nym-wireguard-private-metadata-shared = { path = "../shared", features = [ ] } nym-wireguard-private-metadata-server = { path = "../server" } -[lints] -workspace = true diff --git a/common/wireguard-private-metadata/tests/src/lib.rs b/common/wireguard-private-metadata/tests/src/lib.rs index 7c8cbec81d..6f3d5ca20f 100644 --- a/common/wireguard-private-metadata/tests/src/lib.rs +++ b/common/wireguard-private-metadata/tests/src/lib.rs @@ -1,20 +1,86 @@ #[cfg(test)] mod v0; +#[cfg(test)] +mod v1; +#[cfg(test)] +mod v2; + +// TODO: we might possibly want to move it to some common crate +// so that it could be re-used by other tests (if needed) +#[cfg(test)] +pub(crate) mod mock_connect_info; #[cfg(test)] mod tests { - use std::net::SocketAddr; - + use crate::v2::peer_controller::PeerControlRequestTypeV2; + use nym_credential_verification::upgrade_mode::UpgradeModeEnableError; use nym_credential_verification::{ClientBandwidth, TicketVerifier}; - use nym_credentials_interface::CredentialSpendingData; - use nym_http_api_client::Client; - use nym_wireguard::{CONTROL_CHANNEL_SIZE, peer_controller::PeerControlRequest}; - use nym_wireguard_private_metadata_client::WireguardMetadataApiClient; - use nym_wireguard_private_metadata_server::{ - AppState, PeerControllerTransceiver, RouterBuilder, + use nym_credentials_interface::{ + AvailableBandwidth, BandwidthCredential, CredentialSpendingData, }; - use nym_wireguard_private_metadata_shared::{latest, v0, v1}; - use tokio::{net::TcpListener, sync::mpsc}; + use nym_crypto::asymmetric::ed25519; + use nym_http_api_client::HttpClientError; + use nym_upgrade_mode_check::{ + CREDENTIAL_PROXY_JWT_ISSUER, UpgradeModeAttestation, + generate_jwt_for_upgrade_mode_attestation, generate_new_attestation_with_starting_time, + }; + use nym_wireguard_private_metadata_client::WireguardMetadataApiClient; + use nym_wireguard_private_metadata_shared::{v0, v1, v2}; + use std::net::IpAddr; + use std::time::Duration; + use time::OffsetDateTime; + use time::macros::datetime; + + fn unchecked_ip>(raw: S) -> IpAddr { + raw.into().parse().unwrap() + } + + const HIGH_BANDWIDTH: i64 = 20000000000000; + + const DUMMY_JWT_ISSUER_ED25519_PRIVATE_KEY: [u8; 32] = [ + 152, 17, 144, 255, 213, 219, 246, 208, 109, 33, 100, 73, 1, 141, 32, 63, 141, 89, 167, 2, + 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32, + ]; + + pub(crate) fn dummy_jwt_issuer_public_key() -> ed25519::PublicKey { + let private_key = + ed25519::PrivateKey::from_bytes(&DUMMY_JWT_ISSUER_ED25519_PRIVATE_KEY).unwrap(); + private_key.public_key() + } + + const DUMMY_ATTESTER_ED25519_PRIVATE_KEY: [u8; 32] = [ + 108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248, + 163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161, + ]; + + pub(crate) fn dummy_attester_public_key() -> ed25519::PublicKey { + let private_key = + ed25519::PrivateKey::from_bytes(&DUMMY_ATTESTER_ED25519_PRIVATE_KEY).unwrap(); + private_key.public_key() + } + + fn high_bandwidth() -> Result { + bandwidth_response(HIGH_BANDWIDTH) + } + + fn low_bandwidth() -> Result { + bandwidth_response(0) + } + + fn bandwidth_response(amount: i64) -> Result { + Ok::<_, nym_wireguard::Error>(ClientBandwidth::new(AvailableBandwidth { + bytes: amount, + expiration: OffsetDateTime::from_unix_timestamp(2000000000).unwrap(), + })) + } + + fn mock_verifier( + bandwidth: i64, + ) -> Result, nym_wireguard::Error> { + Ok::<_, nym_wireguard::Error>( + Box::new(MockVerifier::new(bandwidth)) as Box + ) + } pub(crate) const VERIFIER_AVAILABLE_BANDWIDTH: i64 = 42; pub(crate) const CREDENTIAL_BYTES: [u8; 1245] = [ @@ -82,6 +148,52 @@ mod tests { 0, 0, 0, 0, 0, 1, ]; + pub(crate) fn mock_upgrade_mode_attestation() -> UpgradeModeAttestation { + let starting_time = datetime!(2025-10-20 12:00 UTC); + + // just some random, HARDCODED, key + let key = ed25519::PrivateKey::from_bytes(&DUMMY_ATTESTER_ED25519_PRIVATE_KEY).unwrap(); + + generate_new_attestation_with_starting_time( + &key, + vec![dummy_jwt_issuer_public_key()], + starting_time, + ) + } + + pub(crate) fn mock_different_upgrade_mode_attestation() -> UpgradeModeAttestation { + let starting_time = datetime!(2025-10-30 12:00 UTC); + + // just some random, HARDCODED, key + let key = ed25519::PrivateKey::from_bytes(&[ + 108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248, + 163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161, + ]) + .unwrap(); + + generate_new_attestation_with_starting_time( + &key, + vec![dummy_jwt_issuer_public_key()], + starting_time, + ) + } + + pub(crate) fn mock_upgrade_mode_jwt() -> String { + let jwt_key = + ed25519::PrivateKey::from_bytes(&DUMMY_JWT_ISSUER_ED25519_PRIVATE_KEY).unwrap(); + let keys = ed25519::KeyPair::from(jwt_key); + // sanity check in case hardcoded values were modified inconsistently + debug_assert_eq!(*keys.public_key(), dummy_jwt_issuer_public_key()); + + let attestation = mock_upgrade_mode_attestation(); + generate_jwt_for_upgrade_mode_attestation( + attestation, + Duration::from_secs(60 * 60), + &keys, + Some(CREDENTIAL_PROXY_JWT_ISSUER), + ) + } + pub(crate) struct MockVerifier { ret: i64, } @@ -99,56 +211,12 @@ mod tests { } } - pub(crate) async fn spawn_server_and_create_client() -> Client { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let (request_tx, mut request_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE); - let router = RouterBuilder::with_default_routes() - .with_state(AppState::new(PeerControllerTransceiver::new(request_tx))) - .router; - - tokio::spawn(async move { - loop { - match request_rx.recv().await { - Some(PeerControlRequest::GetClientBandwidthByIp { ip: _, response_tx }) => { - response_tx - .send(Ok(ClientBandwidth::new(Default::default()))) - .ok(); - } - Some(PeerControlRequest::GetVerifierByIp { - ip: _, - credential: _, - response_tx, - }) => { - response_tx - .send(Ok(Box::new(MockVerifier::new( - VERIFIER_AVAILABLE_BANDWIDTH, - )))) - .ok(); - } - None => break, - _ => panic!("Not expected"), - } - } - }); - - tokio::spawn(async move { - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await - .unwrap(); - }); - Client::new_url(addr.to_string(), None).unwrap() - } - - #[tokio::test] - async fn query_latest_version() { - let client = spawn_server_and_create_client().await; - let version = client.version().await.unwrap(); - assert_eq!(version, latest::VERSION); - } + // #[tokio::test] + // async fn query_latest_version() { + // let client = super::v2::network::test::spawn_server_and_create_client().await; + // let version = client.version().await.unwrap(); + // assert_eq!(version, latest::VERSION); + // } #[tokio::test] async fn query_against_server_v0() { @@ -158,7 +226,7 @@ mod tests { let version = client.version().await.unwrap(); assert_eq!(version, v0::VERSION); - // v0 reqwests + // v0 requests let request = v0::AvailableBandwidthRequest {}.try_into().unwrap(); let response = client.available_bandwidth(&request).await.unwrap(); v0::AvailableBandwidthResponse::try_from(response).unwrap(); @@ -167,7 +235,7 @@ mod tests { let response = client.topup_bandwidth(&request).await.unwrap(); v0::TopUpResponse::try_from(response).unwrap(); - // v1 reqwests + // v1 requests let request = v1::AvailableBandwidthRequest {}.try_into().unwrap(); assert!(client.available_bandwidth(&request).await.is_err()); @@ -177,17 +245,30 @@ mod tests { .try_into() .unwrap(); assert!(client.topup_bandwidth(&request).await.is_err()); + + // v2 requests + let request = v2::AvailableBandwidthRequest {}.try_into().unwrap(); + assert!(client.available_bandwidth(&request).await.is_err()); + + let request = v2::TopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + } + .try_into() + .unwrap(); + assert!(client.topup_bandwidth(&request).await.is_err()); } #[tokio::test] async fn query_against_server_v1() { - let client = spawn_server_and_create_client().await; + let client = super::v1::network::test::spawn_server_and_create_client().await; // version check let version = client.version().await.unwrap(); assert_eq!(version, v1::VERSION); - // v0 reqwests + // v0 requests let request = v0::AvailableBandwidthRequest {}.try_into().unwrap(); let response = client.available_bandwidth(&request).await.unwrap(); v0::AvailableBandwidthResponse::try_from(response).unwrap(); @@ -195,7 +276,7 @@ mod tests { let request = v0::TopUpRequest {}.try_into().unwrap(); assert!(client.topup_bandwidth(&request).await.is_err()); - // v1 reqwests + // v1 requests let request = v1::AvailableBandwidthRequest {}.try_into().unwrap(); let response = client.available_bandwidth(&request).await.unwrap(); let available_bandwidth = v1::AvailableBandwidthResponse::try_from(response) @@ -213,5 +294,272 @@ mod tests { .unwrap() .available_bandwidth; assert_eq!(available_bandwidth, VERIFIER_AVAILABLE_BANDWIDTH); + + // v2 requests + let request = v2::AvailableBandwidthRequest {}.try_into().unwrap(); + assert!(client.available_bandwidth(&request).await.is_err()); + + let request = v2::TopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + } + .try_into() + .unwrap(); + assert!(client.topup_bandwidth(&request).await.is_err()); + } + + #[tokio::test] + async fn query_against_server_v2() { + let server_test = super::v2::network::test::spawn_server_and_create_client().await; + let client = &server_test.api_client; + + // version check + let version = client.version().await.unwrap(); + assert_eq!(version, v2::VERSION); + + // =========== + // v0 requests + // =========== + let client_ip = unchecked_ip("0.0.0.1"); + server_test.set_client_ip(client_ip); + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetClientBandwidthByIp { ip: client_ip }, + bandwidth_response(0), + ) + .await; + + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetVerifierByIp { ip: client_ip }, + mock_verifier(10), + ) + .await; + + let request = v0::AvailableBandwidthRequest {}.try_into().unwrap(); + let response = client.available_bandwidth(&request).await.unwrap(); + v0::AvailableBandwidthResponse::try_from(response).unwrap(); + + let request = v0::TopUpRequest {}.try_into().unwrap(); + assert!(client.topup_bandwidth(&request).await.is_err()); + server_test.reset_registered_responses().await; + + // =========== + // v1 requests + // =========== + let client_ip = unchecked_ip("1.1.1.1"); + server_test.set_client_ip(client_ip); + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetClientBandwidthByIp { ip: client_ip }, + bandwidth_response(0), + ) + .await; + + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetVerifierByIp { ip: client_ip }, + mock_verifier(100), + ) + .await; + + let request = v1::AvailableBandwidthRequest {}.try_into().unwrap(); + let response = client.available_bandwidth(&request).await.unwrap(); + let available_bandwidth = v1::AvailableBandwidthResponse::try_from(response) + .unwrap() + .available_bandwidth; + assert_eq!(available_bandwidth, 0); + + let request = v1::TopUpRequest { + credential: CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + + let available_bandwidth = v1::TopUpResponse::try_from(response) + .unwrap() + .available_bandwidth; + assert_eq!(available_bandwidth, 100); + server_test.reset_registered_responses().await; + + // =========== + // v2 requests + // =========== + let client_ip = unchecked_ip("2.2.2.1"); + server_test.set_client_ip(client_ip); + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetClientBandwidthByIp { ip: client_ip }, + bandwidth_response(0), + ) + .await; + + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetVerifierByIp { ip: client_ip }, + mock_verifier(200), + ) + .await; + + let request = v2::AvailableBandwidthRequest {}.try_into().unwrap(); + let response = client.available_bandwidth(&request).await.unwrap(); + let available = v2::AvailableBandwidthResponse::try_from(response).unwrap(); + assert_eq!(available.available_bandwidth, 0); + assert!(!available.upgrade_mode); + + let request = v2::TopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + let top_up = v2::TopUpResponse::try_from(response).unwrap(); + assert_eq!(top_up.available_bandwidth, 200); + assert!(!top_up.upgrade_mode); + server_test.reset_registered_responses().await; + + // upgrade mode test + let upgrade_mode_client = unchecked_ip("2.2.2.2"); + server_test.set_client_ip(upgrade_mode_client); + let good_attestation_alt = mock_different_upgrade_mode_attestation(); + let good_jwt = mock_upgrade_mode_jwt(); + + // 1. send attestation when upgrade mode is not enabled + let request = v2::TopUpRequest { + credential: BandwidthCredential::UpgradeModeJWT { + token: good_jwt.clone(), + }, + } + .try_into() + .unwrap(); + let response_err = client.topup_bandwidth(&request).await.unwrap_err(); + let HttpClientError::EndpointFailure { error, .. } = response_err else { + panic!("unexpected response") + }; + assert!(error.contains(&UpgradeModeEnableError::AttestationNotPublished.to_string())); + server_test.reset_registered_responses().await; + + // 2.1. send attestation when upgrade mode is enabled (low bandwidth) + let request_typ = PeerControlRequestTypeV2::GetClientBandwidthByIp { + ip: upgrade_mode_client, + }; + server_test + .register_peer_controller_response(request_typ, low_bandwidth()) + .await; + server_test.enable_upgrade_mode().await; + let request = v2::TopUpRequest { + credential: BandwidthCredential::UpgradeModeJWT { + token: good_jwt.clone(), + }, + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + let top_up = v2::TopUpResponse::try_from(response).unwrap(); + // as defined by `DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD` + assert_eq!(top_up.available_bandwidth, 1024 * 1024 * 1024); + assert!(top_up.upgrade_mode); + server_test.reset_registered_responses().await; + + // 2.2. send attestation when upgrade mode is enabled (high bandwidth) + let request_typ = PeerControlRequestTypeV2::GetClientBandwidthByIp { + ip: upgrade_mode_client, + }; + server_test + .register_peer_controller_response(request_typ, high_bandwidth()) + .await; + server_test.enable_upgrade_mode().await; + let request = v2::TopUpRequest { + credential: BandwidthCredential::UpgradeModeJWT { + token: good_jwt.clone(), + }, + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + let top_up = v2::TopUpResponse::try_from(response).unwrap(); + assert_eq!(top_up.available_bandwidth, HIGH_BANDWIDTH); + assert!(top_up.upgrade_mode); + server_test.reset_registered_responses().await; + + // 3. send bad attestation when upgrade mode is enabled + // (we don't validate it, so client is let through) + // (the only case where invalid attestation would have been rejected is when server + // is not aware of the UM, and that was meant to trigger a refresh. however, a test for that + // is out of scope for these unit tests) + server_test + .change_upgrade_mode_attestation(good_attestation_alt) + .await; + server_test + .register_peer_controller_response(request_typ, high_bandwidth()) + .await; + let request = v2::TopUpRequest { + credential: BandwidthCredential::UpgradeModeJWT { + token: good_jwt.clone(), + }, + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + let top_up = v2::TopUpResponse::try_from(response).unwrap(); + assert_eq!(top_up.available_bandwidth, HIGH_BANDWIDTH); + assert!(top_up.upgrade_mode); + server_test.reset_registered_responses().await; + + // 4. send zk-nym when upgrade mode is enabled + server_test + .register_peer_controller_response(request_typ, high_bandwidth()) + .await; + server_test + .register_peer_controller_response( + PeerControlRequestTypeV2::GetVerifierByIp { + ip: upgrade_mode_client, + }, + mock_verifier(300), + ) + .await; + let request = v2::TopUpRequest { + credential: BandwidthCredential::from( + CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES).unwrap(), + ), + } + .try_into() + .unwrap(); + let response = client.topup_bandwidth(&request).await.unwrap(); + let top_up = v2::TopUpResponse::try_from(response).unwrap(); + // as defined by `DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD` + assert_eq!(top_up.available_bandwidth, 1024 * 1024 * 1024); + assert!(top_up.upgrade_mode); + server_test.reset_registered_responses().await; + + // attempt to enable UM with a valid token + // no global attestation + server_test.disable_upgrade_mode().await; + let request = v2::UpgradeModeCheckRequest { + request_type: v2::UpgradeModeCheckRequestType::UpgradeModeJwt { + token: "".to_string(), + }, + } + .try_into() + .unwrap(); + let response = client.request_upgrade_mode_check(&request).await; + assert!(response.is_err()); + + server_test.publish_upgrade_mode_attestation().await; + // global attestation + let request = v2::UpgradeModeCheckRequest { + request_type: v2::UpgradeModeCheckRequestType::UpgradeModeJwt { + token: mock_upgrade_mode_jwt(), + }, + } + .try_into() + .unwrap(); + let response = client.request_upgrade_mode_check(&request).await.unwrap(); + let upgrade_mode = v2::UpgradeModeCheckResponse::try_from(response).unwrap(); + assert!(upgrade_mode.upgrade_mode); } } diff --git a/common/wireguard-private-metadata/tests/src/mock_connect_info.rs b/common/wireguard-private-metadata/tests/src/mock_connect_info.rs new file mode 100644 index 0000000000..95633d9e01 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/mock_connect_info.rs @@ -0,0 +1,121 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use axum::extract::FromRequestParts; +use axum::http::Request; +use axum::http::request::Parts; +use std::fmt::Display; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::task::{Context, Poll}; +use tower::Layer; +use tower::Service; + +#[derive(Clone)] +pub struct DummyConnectInfo { + // store it as atomic i32 to avoid having to use locks to read and set the value + address: Arc, +} + +impl Display for DummyConnectInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.address().fmt(f) + } +} + +impl DummyConnectInfo { + pub fn new() -> Self { + let dummy_ip = Ipv4Addr::new(1, 2, 3, 4); + DummyConnectInfo { + address: Arc::new(AtomicU32::new(dummy_ip.to_bits())), + } + } + + #[allow(clippy::panic)] + pub fn set(&self, address: IpAddr) { + let IpAddr::V4(v4_address) = address else { + // it would be relatively easy to support ipv6 with multiple atomics, + // but I didn't feel it was needed at the time + panic!("ipv6 not supported") + }; + + self.address.store(v4_address.to_bits(), Ordering::Relaxed); + } + + pub fn address(&self) -> SocketAddr { + let bits = self.address.load(Ordering::Relaxed); + let ipv4 = Ipv4Addr::from(bits); + + SocketAddr::new(IpAddr::V4(ipv4), 1791) + } + + pub fn ip(&self) -> IpAddr { + self.address().ip() + } +} + +#[async_trait] +impl FromRequestParts for DummyConnectInfo +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + #[allow(clippy::panic)] + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + if let Some(info) = parts.extensions.get::() { + Ok(info.clone()) + } else { + // this is a test code so that's fine + panic!("DummyConnectInfo not set") + } + } +} + +#[derive(Clone)] +pub struct MockConnectInfoLayer { + info: DummyConnectInfo, +} + +impl MockConnectInfoLayer { + pub fn new(info: DummyConnectInfo) -> Self { + Self { info } + } +} + +impl Layer for MockConnectInfoLayer { + type Service = MockConnectInfoMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + MockConnectInfoMiddleware { + inner, + info: self.info.clone(), + } + } +} + +#[derive(Clone)] +pub struct MockConnectInfoMiddleware { + inner: S, + info: DummyConnectInfo, +} + +impl Service> for MockConnectInfoMiddleware +where + S: Service>, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + req.extensions_mut().insert(self.info.clone()); + self.inner.call(req) + } +} diff --git a/common/wireguard-private-metadata/tests/src/v0/app_state.rs b/common/wireguard-private-metadata/tests/src/v0/app_state.rs new file mode 100644 index 0000000000..45222bda15 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v0/app_state.rs @@ -0,0 +1,7 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::v1::app_state::AppStateV1; + +// there have been no changes between v0 and v1 so there's no point in redefining it +pub type AppStateV0 = AppStateV1; diff --git a/common/wireguard-private-metadata/tests/src/v0/interface.rs b/common/wireguard-private-metadata/tests/src/v0/interface.rs deleted file mode 100644 index a09ff4307a..0000000000 --- a/common/wireguard-private-metadata/tests/src/v0/interface.rs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use nym_wireguard_private_metadata_shared::{ - Construct, Extract, Request, Response, Version, v0 as latest, -}; - -pub enum RequestData { - AvailableBandwidth(()), - TopUpBandwidth(()), -} - -impl From for RequestData { - fn from(value: latest::interface::RequestData) -> Self { - match value { - latest::interface::RequestData::AvailableBandwidth(inner) => { - Self::AvailableBandwidth(inner) - } - latest::interface::RequestData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } - } - } -} - -impl From for latest::interface::RequestData { - fn from(value: RequestData) -> Self { - match value { - RequestData::AvailableBandwidth(inner) => Self::AvailableBandwidth(inner), - RequestData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } - } - } -} - -impl From for ResponseData { - fn from(value: latest::interface::ResponseData) -> Self { - match value { - latest::interface::ResponseData::AvailableBandwidth(inner) => { - Self::AvailableBandwidth(inner) - } - latest::interface::ResponseData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } - } - } -} - -impl From for latest::interface::ResponseData { - fn from(value: ResponseData) -> Self { - match value { - ResponseData::AvailableBandwidth(inner) => Self::AvailableBandwidth(inner), - ResponseData::TopUpBandwidth(credential_spending_data) => { - Self::TopUpBandwidth(credential_spending_data) - } - } - } -} - -impl Construct for Request { - fn construct( - info: RequestData, - version: Version, - ) -> Result { - match version { - Version::V0 => { - let translate_info = latest::interface::RequestData::from(info); - let versioned_request = - latest::VersionedRequest::construct(translate_info, latest::VERSION)?; - Ok(versioned_request.try_into()?) - } - _ => Err( - nym_wireguard_private_metadata_shared::ModelError::DowngradeNotPossible { - from: version, - to: Version::V0, - }, - ), - } - } -} - -impl Extract for Request { - fn extract( - &self, - ) -> Result<(RequestData, Version), nym_wireguard_private_metadata_shared::ModelError> { - match self.version { - Version::V0 => { - let versioned_request = latest::VersionedRequest::try_from(self.clone())?; - let (request, version) = versioned_request.extract()?; - - Ok((request.into(), version)) - } - _ => Err( - nym_wireguard_private_metadata_shared::ModelError::UpdateNotPossible { - from: self.version, - to: Version::V0, - }, - ), - } - } -} - -pub enum ResponseData { - AvailableBandwidth(()), - TopUpBandwidth(()), -} - -impl Construct for Response { - fn construct( - info: ResponseData, - version: Version, - ) -> Result { - match version { - Version::V0 => { - let translate_response = latest::interface::ResponseData::from(info); - let versioned_response = - latest::VersionedResponse::construct(translate_response, version)?; - Ok(versioned_response.try_into()?) - } - _ => Err( - nym_wireguard_private_metadata_shared::ModelError::DowngradeNotPossible { - from: version, - to: Version::V0, - }, - ), - } - } -} - -impl Extract for Response { - fn extract( - &self, - ) -> Result<(ResponseData, Version), nym_wireguard_private_metadata_shared::ModelError> { - match self.version { - Version::V0 => { - let versioned_response = latest::VersionedResponse::try_from(self.clone())?; - let (response, version) = versioned_response.extract()?; - - Ok((response.into(), version)) - } - _ => Err( - nym_wireguard_private_metadata_shared::ModelError::UpdateNotPossible { - from: self.version, - to: Version::V0, - }, - ), - } - } -} diff --git a/common/wireguard-private-metadata/tests/src/v0/mod.rs b/common/wireguard-private-metadata/tests/src/v0/mod.rs index 7338cc5ae2..2af556497b 100644 --- a/common/wireguard-private-metadata/tests/src/v0/mod.rs +++ b/common/wireguard-private-metadata/tests/src/v0/mod.rs @@ -1,2 +1,3 @@ -pub(crate) mod interface; +pub(crate) mod app_state; pub(crate) mod network; +pub(crate) mod peer_controller; diff --git a/common/wireguard-private-metadata/tests/src/v0/network.rs b/common/wireguard-private-metadata/tests/src/v0/network.rs index b0b7361e84..e45c506b94 100644 --- a/common/wireguard-private-metadata/tests/src/v0/network.rs +++ b/common/wireguard-private-metadata/tests/src/v0/network.rs @@ -5,25 +5,24 @@ pub(crate) mod test { use std::net::SocketAddr; - use crate::{ - tests::{MockVerifier, VERIFIER_AVAILABLE_BANDWIDTH}, - v0::interface::{RequestData, ResponseData}, - }; + use crate::tests::{MockVerifier, VERIFIER_AVAILABLE_BANDWIDTH}; use axum::{Json, Router, extract::Query}; use nym_credential_verification::ClientBandwidth; use nym_http_api_client::Client; use nym_http_api_common::{FormattedResponse, OutputParams}; use nym_wireguard::{CONTROL_CHANNEL_SIZE, peer_controller::PeerControlRequest}; use nym_wireguard_private_metadata_server::PeerControllerTransceiver; + use nym_wireguard_private_metadata_shared::v0::interface::{RequestData, ResponseData}; use nym_wireguard_private_metadata_shared::{ AxumErrorResponse, AxumResult, Construct, Extract, Request, Response, v0 as latest, }; + use tokio::sync::mpsc::Receiver; use tokio::{net::TcpListener, sync::mpsc}; use tower_http::compression::CompressionLayer; - use nym_wireguard_private_metadata_server::AppState; + use crate::v0::app_state::AppStateV0; - fn bandwidth_routes() -> Router { + fn bandwidth_routes() -> Router { Router::new() .route("/version", axum::routing::get(version)) .route("/available", axum::routing::post(available_bandwidth)) @@ -31,32 +30,11 @@ pub(crate) mod test { .layer(CompressionLayer::new()) } - #[utoipa::path( - tag = "bandwidth", - get, - path = "/v1/bandwidth/version", - responses( - (status = 200, content( - (Response = "application/bincode") - )) - ), -)] async fn version(Query(output): Query) -> AxumResult> { let output = output.output.unwrap_or_default(); Ok(output.to_response(latest::VERSION.into())) } - #[utoipa::path( - tag = "bandwidth", - post, - request_body = Request, - path = "/v1/bandwidth/available", - responses( - (status = 200, content( - (Response = "application/bincode") - )) - ), -)] async fn available_bandwidth( Query(output): Query, Json(request): Json, @@ -74,17 +52,6 @@ pub(crate) mod test { Ok(output.to_response(response)) } - #[utoipa::path( - tag = "bandwidth", - post, - request_body = Request, - path = "/v1/bandwidth/topup", - responses( - (status = 200, content( - (Response = "application/bincode") - )) - ), -)] async fn topup_bandwidth( Query(output): Query, Json(request): Json, @@ -102,35 +69,41 @@ pub(crate) mod test { Ok(output.to_response(response)) } + fn spawn_mock_peer_controller(mut request_rx: Receiver) { + tokio::spawn(async move { + while let Some(request) = request_rx.recv().await { + match request { + PeerControlRequest::GetClientBandwidthByIp { ip: _, response_tx } => { + response_tx + .send(Ok(ClientBandwidth::new(Default::default()))) + .ok(); + } + PeerControlRequest::GetVerifierByIp { + ip: _, + credential: _, + response_tx, + } => { + response_tx + .send(Ok(Box::new(MockVerifier::new( + VERIFIER_AVAILABLE_BANDWIDTH, + )))) + .ok(); + } + _ => panic!("Not expected"), + } + } + }); + } + pub(crate) async fn spawn_server_and_create_client() -> Client { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let (request_tx, mut request_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE); + let (request_tx, request_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE); let router = Router::new() .nest("/v1", Router::new().nest("/bandwidth", bandwidth_routes())) - .with_state(AppState::new(PeerControllerTransceiver::new(request_tx))); + .with_state(AppStateV0::new(PeerControllerTransceiver::new(request_tx))); - tokio::spawn(async move { - match request_rx.recv().await.unwrap() { - PeerControlRequest::GetClientBandwidthByIp { ip: _, response_tx } => { - response_tx - .send(Ok(ClientBandwidth::new(Default::default()))) - .ok(); - } - PeerControlRequest::GetVerifierByIp { - ip: _, - credential: _, - response_tx, - } => { - response_tx - .send(Ok(Box::new(MockVerifier::new( - VERIFIER_AVAILABLE_BANDWIDTH, - )))) - .ok(); - } - _ => panic!("Not expected"), - } - }); + spawn_mock_peer_controller(request_rx); tokio::spawn(async move { axum::serve( diff --git a/common/wireguard-private-metadata/tests/src/v0/peer_controller.rs b/common/wireguard-private-metadata/tests/src/v0/peer_controller.rs new file mode 100644 index 0000000000..b1f80ad87c --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v0/peer_controller.rs @@ -0,0 +1,9 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +use crate::v2::peer_controller::{MockPeerControllerStateV2, MockPeerControllerV2}; + +pub type MockPeerControllerStateV0 = MockPeerControllerStateV2; +pub type MockPeerControllerV0 = MockPeerControllerV2; diff --git a/common/wireguard-private-metadata/tests/src/v1/app_state.rs b/common/wireguard-private-metadata/tests/src/v1/app_state.rs new file mode 100644 index 0000000000..f773165819 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v1/app_state.rs @@ -0,0 +1,33 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_credentials_interface::CredentialSpendingData; +use nym_wireguard_private_metadata_server::PeerControllerTransceiver; +use nym_wireguard_private_metadata_shared::error::MetadataError; +use std::net::IpAddr; + +#[derive(Clone, axum::extract::FromRef)] +pub struct AppStateV1 { + transceiver: PeerControllerTransceiver, +} + +impl AppStateV1 { + pub fn new(transceiver: PeerControllerTransceiver) -> Self { + Self { transceiver } + } + + pub async fn available_bandwidth(&self, ip: IpAddr) -> Result { + self.transceiver.query_bandwidth(ip).await + } + + // Top up with a credential and return the afterwards available bandwidth + pub async fn topup_bandwidth( + &self, + ip: IpAddr, + credential: CredentialSpendingData, + ) -> Result { + self.transceiver + .topup_bandwidth(ip, Box::new(credential)) + .await + } +} diff --git a/common/wireguard-private-metadata/tests/src/v1/mod.rs b/common/wireguard-private-metadata/tests/src/v1/mod.rs new file mode 100644 index 0000000000..3cf9729866 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v1/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod app_state; +pub(crate) mod network; +pub(crate) mod peer_controller; diff --git a/common/wireguard-private-metadata/tests/src/v1/network.rs b/common/wireguard-private-metadata/tests/src/v1/network.rs new file mode 100644 index 0000000000..07b23fccbc --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v1/network.rs @@ -0,0 +1,134 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(test)] +pub(crate) mod test { + use std::net::SocketAddr; + + use crate::tests::{MockVerifier, VERIFIER_AVAILABLE_BANDWIDTH}; + use crate::v1::app_state::AppStateV1; + use axum::extract::{ConnectInfo, State}; + use axum::{Json, Router, extract::Query}; + use nym_credential_verification::ClientBandwidth; + use nym_http_api_client::Client; + use nym_http_api_common::{FormattedResponse, OutputParams}; + use nym_wireguard::{CONTROL_CHANNEL_SIZE, peer_controller::PeerControlRequest}; + use nym_wireguard_private_metadata_server::PeerControllerTransceiver; + use nym_wireguard_private_metadata_shared::v1::interface::{RequestData, ResponseData}; + use nym_wireguard_private_metadata_shared::{ + AxumErrorResponse, AxumResult, Construct, Extract, Request, Response, v1 as latest, + }; + use tokio::sync::mpsc::Receiver; + use tokio::{net::TcpListener, sync::mpsc}; + use tower_http::compression::CompressionLayer; + + fn bandwidth_routes() -> Router { + Router::new() + .route("/version", axum::routing::get(version)) + .route("/available", axum::routing::post(available_bandwidth)) + .route("/topup", axum::routing::post(topup_bandwidth)) + .layer(CompressionLayer::new()) + } + + async fn version(Query(output): Query) -> AxumResult> { + let output = output.output.unwrap_or_default(); + Ok(output.to_response(latest::VERSION.into())) + } + + async fn available_bandwidth( + ConnectInfo(addr): ConnectInfo, + Query(output): Query, + State(state): State, + Json(request): Json, + ) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::AvailableBandwidth(_), version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let available_bandwidth = state + .available_bandwidth(addr.ip()) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct( + ResponseData::AvailableBandwidth(available_bandwidth), + version, + ) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) + } + + async fn topup_bandwidth( + ConnectInfo(addr): ConnectInfo, + Query(output): Query, + State(state): State, + Json(request): Json, + ) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::TopUpBandwidth(credential), version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let available_bandwidth = state + .topup_bandwidth(addr.ip(), *credential) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = + Response::construct(ResponseData::TopUpBandwidth(available_bandwidth), version) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) + } + + fn spawn_mock_peer_controller(mut request_rx: Receiver) { + tokio::spawn(async move { + while let Some(request) = request_rx.recv().await { + match request { + PeerControlRequest::GetClientBandwidthByIp { ip: _, response_tx } => { + response_tx + .send(Ok(ClientBandwidth::new(Default::default()))) + .ok(); + } + PeerControlRequest::GetVerifierByIp { + ip: _, + credential: _, + response_tx, + } => { + response_tx + .send(Ok(Box::new(MockVerifier::new( + VERIFIER_AVAILABLE_BANDWIDTH, + )))) + .ok(); + } + _ => panic!("Not expected"), + } + } + }); + } + + pub(crate) async fn spawn_server_and_create_client() -> Client { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (request_tx, request_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE); + let router = Router::new() + .nest("/v1", Router::new().nest("/bandwidth", bandwidth_routes())) + .with_state(AppStateV1::new(PeerControllerTransceiver::new(request_tx))); + + spawn_mock_peer_controller(request_rx); + + tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + Client::new_url(addr.to_string(), None).unwrap() + } +} diff --git a/common/wireguard-private-metadata/tests/src/v1/peer_controller.rs b/common/wireguard-private-metadata/tests/src/v1/peer_controller.rs new file mode 100644 index 0000000000..7291d5c5b3 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v1/peer_controller.rs @@ -0,0 +1,9 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +use crate::v2::peer_controller::{MockPeerControllerStateV2, MockPeerControllerV2}; + +pub type MockPeerControllerStateV1 = MockPeerControllerStateV2; +pub type MockPeerControllerV1 = MockPeerControllerV2; diff --git a/common/wireguard-private-metadata/tests/src/v2/app_state.rs b/common/wireguard-private-metadata/tests/src/v2/app_state.rs new file mode 100644 index 0000000000..3ebf886df1 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v2/app_state.rs @@ -0,0 +1,8 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_wireguard_private_metadata_server::AppState; + +// given latest is v2, we just create a type alias +// for any future versions, this would have to be adjusted +pub type AppStateV2 = AppState; diff --git a/common/wireguard-private-metadata/tests/src/v2/mod.rs b/common/wireguard-private-metadata/tests/src/v2/mod.rs new file mode 100644 index 0000000000..3cf9729866 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v2/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod app_state; +pub(crate) mod network; +pub(crate) mod peer_controller; diff --git a/common/wireguard-private-metadata/tests/src/v2/network.rs b/common/wireguard-private-metadata/tests/src/v2/network.rs new file mode 100644 index 0000000000..fd2520e8e5 --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v2/network.rs @@ -0,0 +1,329 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(test)] +pub(crate) mod test { + use crate::mock_connect_info::{DummyConnectInfo, MockConnectInfoLayer}; + use crate::tests::{dummy_attester_public_key, mock_upgrade_mode_attestation}; + use crate::v2::app_state::AppStateV2; + use crate::v2::peer_controller::{ + MockPeerControllerStateV2, MockPeerControllerV2, PeerControlRequestTypeV2, + }; + use axum::extract::State; + use axum::{Extension, Json, Router, extract::Query}; + use futures::StreamExt; + use nym_credential_verification::upgrade_mode::{ + CheckRequest, UpgradeModeCheckConfig, UpgradeModeCheckRequestReceiver, + UpgradeModeCheckRequestSender, UpgradeModeDetails, UpgradeModeState, + }; + use nym_http_api_client::Client; + use nym_http_api_common::{FormattedResponse, OutputParams}; + use nym_upgrade_mode_check::UpgradeModeAttestation; + use nym_wireguard::CONTROL_CHANNEL_SIZE; + use nym_wireguard_private_metadata_server::AppState; + use nym_wireguard_private_metadata_server::PeerControllerTransceiver; + use nym_wireguard_private_metadata_shared::interface::RequestData; + use nym_wireguard_private_metadata_shared::{ + AxumErrorResponse, AxumResult, Construct, Extract, Request, Response, v2 as latest, + }; + use std::any::Any; + use std::net::IpAddr; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Mutex; + use tokio::task::JoinSet; + use tokio::{net::TcpListener, sync::mpsc}; + use tower_http::compression::CompressionLayer; + + pub struct MockUpgradeModeWatcher { + check_request_receiver: UpgradeModeCheckRequestReceiver, + upgrade_mode_state: UpgradeModeState, + + mock_published_attestation: Arc>>, + } + + impl MockUpgradeModeWatcher { + pub fn new( + check_request_receiver: UpgradeModeCheckRequestReceiver, + upgrade_mode_state: UpgradeModeState, + mock_published_attestation: Arc>>, + ) -> Self { + MockUpgradeModeWatcher { + check_request_receiver, + upgrade_mode_state, + mock_published_attestation, + } + } + + async fn handle_check_request(&mut self, polled_request: CheckRequest) { + let mut requests = vec![polled_request]; + while let Ok(Some(queued_up)) = self.check_request_receiver.try_next() { + requests.push(queued_up); + } + + let published = self.mock_published_attestation.lock().await; + self.upgrade_mode_state + .try_set_expected_attestation(published.clone()) + .await; + + for request in requests { + request.finalize() + } + } + + pub async fn run(&mut self) { + // for now don't do anything apart from notifying the caller + while let Some(polled_request) = self.check_request_receiver.next().await { + self.handle_check_request(polled_request).await + } + } + } + + pub struct ServerTest { + // among other things gives you access to the shared state, so you could toggle the flag + // and thus change server behaviour + upgrade_mode_state: UpgradeModeState, + + // shared state with the mock attestation watcher to make it think new attestation has been published + mock_published_attestation: Arc>>, + + connect_info: DummyConnectInfo, + + // handles to the following tasks: + // - the actual axum server + // - dummy attestation watcher + // - dummy peer controller + _server_tasks: JoinSet<()>, + + peer_controller_state: MockPeerControllerStateV2, + + pub(crate) api_client: Client, + } + + impl ServerTest { + pub(crate) async fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (request_tx, request_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE); + + let (um_recheck_tx, um_recheck_rx) = futures::channel::mpsc::unbounded(); + let upgrade_mode_state = UpgradeModeState::new(dummy_attester_public_key()); + let upgrade_mode_details = UpgradeModeDetails::new( + UpgradeModeCheckConfig { + // essentially we never want to trigger this in our tests + min_staleness_recheck: Duration::from_nanos(1), + }, + UpgradeModeCheckRequestSender::new(um_recheck_tx), + upgrade_mode_state.clone(), + ); + + let dummy_connect_info = DummyConnectInfo::new(); + + let router = Router::new() + .nest( + "/v1", + Router::new() + .nest("/bandwidth", bandwidth_routes()) + .nest("/network", network_routes()), + ) + .with_state(AppStateV2::new( + PeerControllerTransceiver::new(request_tx), + upgrade_mode_details, + )); + + // register responses for expected requests + let peer_controller_state = MockPeerControllerStateV2::default(); + let mut server_tasks = JoinSet::new(); + + let mut peer_controller = + MockPeerControllerV2::new(peer_controller_state.clone(), request_rx); + + let mock_published_attestation = Arc::new(Mutex::new(None)); + let mut upgrade_mode_watcher = MockUpgradeModeWatcher::new( + um_recheck_rx, + upgrade_mode_state.clone(), + mock_published_attestation.clone(), + ); + + // spawn all the tasks + server_tasks.spawn(async move { + peer_controller.run().await; + }); + server_tasks.spawn(async move { + upgrade_mode_watcher.run().await; + }); + + let connect_info = dummy_connect_info.clone(); + server_tasks.spawn(async move { + axum::serve( + listener, + // router.into_make_service_with_connect_info::(), + router.layer(MockConnectInfoLayer::new(connect_info)), + ) + .await + .unwrap(); + }); + let api_client = Client::new_url(addr.to_string(), None).unwrap(); + + ServerTest { + upgrade_mode_state, + mock_published_attestation, + connect_info: dummy_connect_info, + _server_tasks: server_tasks, + peer_controller_state, + api_client, + } + } + + pub(crate) async fn enable_upgrade_mode(&self) { + self.change_upgrade_mode_attestation(mock_upgrade_mode_attestation()) + .await + } + + pub(crate) async fn change_upgrade_mode_attestation( + &self, + attestation: UpgradeModeAttestation, + ) { + self.upgrade_mode_state + .try_set_expected_attestation(Some(attestation)) + .await + } + + pub(crate) async fn publish_upgrade_mode_attestation(&self) { + *self.mock_published_attestation.lock().await = Some(mock_upgrade_mode_attestation()) + } + + #[allow(dead_code)] + pub(crate) async fn disable_upgrade_mode(&self) { + self.upgrade_mode_state + .try_set_expected_attestation(None) + .await; + } + + pub(crate) fn set_client_ip(&self, ip: IpAddr) { + self.connect_info.set(ip) + } + + #[allow(dead_code)] + pub(crate) fn client_ip(&self) -> IpAddr { + self.connect_info.ip() + } + + // note: it's caller's responsibility to make sure the response type is correct! + pub(crate) async fn register_peer_controller_response( + &self, + request: PeerControlRequestTypeV2, + response: impl Any + Send + Sync + 'static, + ) { + self.peer_controller_state + .register_response(request, response) + .await + } + + pub(crate) async fn reset_registered_responses(&self) { + self.peer_controller_state + .clear_registered_responses() + .await + } + } + + fn bandwidth_routes() -> Router { + Router::new() + .route("/version", axum::routing::get(version)) + .route("/available", axum::routing::post(available_bandwidth)) + .route("/topup", axum::routing::post(topup_bandwidth)) + .layer(CompressionLayer::new()) + } + + fn network_routes() -> Router { + Router::new() + .route( + "/upgrade-mode-check", + axum::routing::post(upgrade_mode_check), + ) + .layer(CompressionLayer::new()) + } + + async fn version(Query(output): Query) -> AxumResult> { + let output = output.output.unwrap_or_default(); + Ok(output.to_response(latest::VERSION.into())) + } + + async fn available_bandwidth( + // ❗ \/ DIFFERENT FROM ACTUAL SERVER \/ ❗ + // we use different ConnectInfo to be able to mock different ip addresses + Extension(addr): Extension, + // ❗ /\ DIFFERENT FROM ACTUAL SERVER /\ ❗ + Query(output): Query, + State(state): State, + Json(request): Json, + ) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::AvailableBandwidth, version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let available_bandwidth_response = state + .available_bandwidth(addr.ip()) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct(available_bandwidth_response, version) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) + } + + async fn topup_bandwidth( + // ❗ \/ DIFFERENT FROM ACTUAL SERVER \/ ❗ + // we use different ConnectInfo to be able to mock different ip addresses + Extension(addr): Extension, + // ❗ /\ DIFFERENT FROM ACTUAL SERVER /\ ❗ + Query(output): Query, + State(state): State, + Json(request): Json, + ) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::TopUpBandwidth { credential }, version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let top_up_bandwidth_response = state + .topup_bandwidth(addr.ip(), credential) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct(top_up_bandwidth_response, version) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) + } + + async fn upgrade_mode_check( + Query(output): Query, + State(state): State, + Json(request): Json, + ) -> AxumResult> { + let output = output.output.unwrap_or_default(); + + let (RequestData::UpgradeModeCheck { typ }, version) = + request.extract().map_err(AxumErrorResponse::bad_request)? + else { + return Err(AxumErrorResponse::bad_request("incorrect request type")); + }; + let upgrade_mode_check_response = state + .upgrade_mode_check(typ) + .await + .map_err(AxumErrorResponse::bad_request)?; + let response = Response::construct(upgrade_mode_check_response, version) + .map_err(AxumErrorResponse::bad_request)?; + + Ok(output.to_response(response)) + } + + pub(crate) async fn spawn_server_and_create_client() -> ServerTest { + ServerTest::new().await + } +} diff --git a/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs b/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs new file mode 100644 index 0000000000..435359efac --- /dev/null +++ b/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs @@ -0,0 +1,177 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// not declared as a 'global' since I can imagine it might change between versions + +use nym_wireguard::peer_controller::PeerControlRequest; +use std::any::Any; +use std::collections::{HashMap, VecDeque}; +use std::net::IpAddr; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::sync::mpsc::Receiver; + +#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq, Copy)] +pub enum PeerControlRequestTypeV2 { + AddPeer, + RemovePeer, + QueryPeer, + GetClientBandwidthByKey, + GetClientBandwidthByIp { ip: IpAddr }, + GetVerifierByKey, + GetVerifierByIp { ip: IpAddr }, +} + +impl From<&PeerControlRequest> for PeerControlRequestTypeV2 { + fn from(req: &PeerControlRequest) -> Self { + match req { + PeerControlRequest::AddPeer { .. } => PeerControlRequestTypeV2::AddPeer, + PeerControlRequest::RemovePeer { .. } => PeerControlRequestTypeV2::RemovePeer, + PeerControlRequest::QueryPeer { .. } => PeerControlRequestTypeV2::QueryPeer, + PeerControlRequest::GetClientBandwidthByKey { .. } => { + PeerControlRequestTypeV2::GetClientBandwidthByKey + } + PeerControlRequest::GetClientBandwidthByIp { ip, .. } => { + PeerControlRequestTypeV2::GetClientBandwidthByIp { ip: *ip } + } + PeerControlRequest::GetVerifierByKey { .. } => { + PeerControlRequestTypeV2::GetVerifierByKey + } + PeerControlRequest::GetVerifierByIp { ip, .. } => { + PeerControlRequestTypeV2::GetVerifierByIp { ip: *ip } + } + } + } +} + +// all responses are registered as a queue for particular type +// (this is because the actual type can't be cloned as the `Error` does not implement Clone) +type RegisteredResponses = + HashMap>>; + +#[derive(Clone, Default)] +pub struct MockPeerControllerStateV2 { + registered_responses: Arc>, +} + +impl MockPeerControllerStateV2 { + pub async fn register_response( + &self, + request: PeerControlRequestTypeV2, + response: impl Any + Send + Sync + 'static, + ) { + self.registered_responses + .write() + .await + .entry(request) + .or_default() + .push_back(Box::new(response)); + } + + pub async fn clear_registered_responses(&self) { + self.registered_responses.write().await.clear(); + } +} + +pub struct MockPeerControllerV2 { + state: MockPeerControllerStateV2, + request_rx: Receiver, +} + +impl MockPeerControllerV2 { + pub(crate) fn new( + state: MockPeerControllerStateV2, + request_rx: Receiver, + ) -> Self { + MockPeerControllerV2 { state, request_rx } + } + + async fn handle_request(&mut self, request: PeerControlRequest) { + let typ = PeerControlRequestTypeV2::from(&request); + + let mut guard = self.state.registered_responses.write().await; + let Some(registered_responses) = guard.get_mut(&typ) else { + panic!( + "received a request for {typ:?} but there are no registered responses - this is probably due to a bug in your test setup" + ); + }; + + let Some(response) = registered_responses.pop_front() else { + panic!( + "received a request for {typ:?} but there are no registered responses - this is probably due to a bug in your test setup" + ); + }; + + match request { + PeerControlRequest::AddPeer { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } + PeerControlRequest::RemovePeer { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } + PeerControlRequest::QueryPeer { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } + PeerControlRequest::GetClientBandwidthByKey { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } + PeerControlRequest::GetClientBandwidthByIp { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } + PeerControlRequest::GetVerifierByKey { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .ok(); + } + PeerControlRequest::GetVerifierByIp { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .ok(); + } + } + } + + pub(crate) async fn run(&mut self) { + while let Some(request) = self.request_rx.recv().await { + self.handle_request(request).await; + } + } +} diff --git a/common/wireguard-types/Cargo.toml b/common/wireguard-types/Cargo.toml index e1ffda761c..9a8a783db0 100644 --- a/common/wireguard-types/Cargo.toml +++ b/common/wireguard-types/Cargo.toml @@ -12,14 +12,11 @@ license.workspace = true [dependencies] base64 = { workspace = true } -log = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } -nym-config = { path = "../config" } -nym-network-defaults = { path = "../network-defaults" } - x25519-dalek = { workspace = true, features = ["static_secrets"] } +nym-crypto = { path = "../crypto", features = ["asymmetric"] } [dev-dependencies] rand = { workspace = true } diff --git a/common/wireguard-types/src/public_key.rs b/common/wireguard-types/src/public_key.rs index 3b3bbd60d1..755c60d628 100644 --- a/common/wireguard-types/src/public_key.rs +++ b/common/wireguard-types/src/public_key.rs @@ -9,11 +9,24 @@ use std::fmt; use std::ops::Deref; use std::str::FromStr; +use nym_crypto::asymmetric::x25519; use x25519_dalek::PublicKey; #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] pub struct PeerPublicKey(PublicKey); +impl From for PeerPublicKey { + fn from(pk: x25519::PublicKey) -> Self { + PeerPublicKey(pk.into()) + } +} + +impl From<&x25519::PublicKey> for PeerPublicKey { + fn from(pk: &x25519::PublicKey) -> Self { + (*pk).into() + } +} + impl PeerPublicKey { pub fn new(key: PublicKey) -> Self { PeerPublicKey(key) diff --git a/common/wireguard/Cargo.toml b/common/wireguard/Cargo.toml index e98e5fc27d..f2a773d4ec 100644 --- a/common/wireguard/Cargo.toml +++ b/common/wireguard/Cargo.toml @@ -11,27 +11,15 @@ license.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { workspace = true } base64 = { workspace = true } -bincode = { workspace = true } -chrono = { workspace = true } -dashmap = { workspace = true } defguard_wireguard_rs = { workspace = true } -dyn-clone = { workspace = true } futures = { workspace = true } -# The latest version on crates.io at the time of writing this (6.0.0) has a -# version mismatch with x25519-dalek/curve25519-dalek that is resolved in the -# latest commit. So pick that for now. -x25519-dalek = { workspace = true } ip_network = { workspace = true } -log.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] } tokio-stream = { workspace = true } -time = { workspace = true } tracing = { workspace = true } -nym-authenticator-requests = { path = "../authenticator-requests" } nym-credentials-interface = { path = "../credentials-interface" } nym-credential-verification = { path = "../credential-verification" } nym-crypto = { path = "../crypto", features = ["asymmetric"] } @@ -48,3 +36,6 @@ nym-gateway-storage = { path = "../gateway-storage", features = ["mock"] } [features] default = [] mock = ["nym-gateway-storage/mock"] + +[lints] +workspace = true diff --git a/common/wireguard/src/lib.rs b/common/wireguard/src/lib.rs index 6b2c632d22..cf7ff7f32f 100644 --- a/common/wireguard/src/lib.rs +++ b/common/wireguard/src/lib.rs @@ -7,13 +7,15 @@ // #![warn(clippy::unwrap_used)] use defguard_wireguard_rs::{WGApi, WireguardInterfaceApi, host::Peer, key::Key, net::IpAddrMask}; -#[cfg(target_os = "linux")] -use nym_credential_verification::ecash::EcashManager; use nym_crypto::asymmetric::x25519::KeyPair; use nym_wireguard_types::Config; use peer_controller::PeerControlRequest; use std::sync::Arc; use tokio::sync::mpsc::{self, Receiver, Sender}; +use tracing::error; + +#[cfg(target_os = "linux")] +use nym_credential_verification::ecash::EcashManager; #[cfg(target_os = "linux")] use nym_network_defaults::constants::WG_TUN_BASE_NAME; @@ -23,6 +25,8 @@ pub mod peer_controller; pub mod peer_handle; pub mod peer_storage_manager; +pub use error::Error; + pub const CONTROL_CHANNEL_SIZE: usize = 256; pub struct WgApiWrapper { @@ -114,7 +118,7 @@ impl Drop for WgApiWrapper { fn drop(&mut self) { if let Err(e) = defguard_wireguard_rs::WireguardInterfaceApi::remove_interface(&self.inner) { - log::error!("Could not remove the wireguard interface: {e:?}"); + error!("Could not remove the wireguard interface: {e:?}"); } } } @@ -163,6 +167,7 @@ pub async fn start_wireguard( ecash_manager: Arc, metrics: nym_node_metrics::NymNodeMetrics, peers: Vec, + upgrade_mode_status: nym_credential_verification::upgrade_mode::UpgradeModeStatus, shutdown_token: nym_task::ShutdownToken, wireguard_data: WireguardData, ) -> Result, Box> { @@ -250,6 +255,7 @@ pub async fn start_wireguard( peer_bandwidth_managers, wireguard_data.inner.peer_tx.clone(), wireguard_data.peer_rx, + upgrade_mode_status, shutdown_token, ); tokio::spawn(async move { controller.run().await }); diff --git a/common/wireguard/src/peer_controller.rs b/common/wireguard/src/peer_controller.rs index e9e4f78f2d..54b208c5af 100644 --- a/common/wireguard/src/peer_controller.rs +++ b/common/wireguard/src/peer_controller.rs @@ -1,13 +1,18 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::{ + error::{Error, Result}, + peer_handle::SharedBandwidthStorageManager, +}; +use crate::{peer_handle::PeerHandle, peer_storage_manager::CachedPeerManager}; use defguard_wireguard_rs::{ WireguardInterfaceApi, host::{Host, Peer}, key::Key, }; use futures::channel::oneshot; -use log::info; +use nym_credential_verification::upgrade_mode::UpgradeModeStatus; use nym_credential_verification::{ BandwidthFlushingBehaviourConfig, ClientBandwidth, CredentialVerifier, TicketVerifier, bandwidth_storage_manager::BandwidthStorageManager, ecash::traits::EcashManager, @@ -24,12 +29,7 @@ use std::{ }; use tokio::sync::{RwLock, mpsc}; use tokio_stream::{StreamExt, wrappers::IntervalStream}; - -use crate::{ - error::{Error, Result}, - peer_handle::SharedBandwidthStorageManager, -}; -use crate::{peer_handle::PeerHandle, peer_storage_manager::CachedPeerManager}; +use tracing::{debug, error, info, trace}; pub enum PeerControlRequest { AddPeer { @@ -84,6 +84,10 @@ pub struct PeerController { host_information: Arc>, bw_storage_managers: HashMap, timeout_check_interval: IntervalStream, + + /// Flag indicating whether the system is undergoing an upgrade and thus peers shouldn't be getting + /// their bandwidth metered. + upgrade_mode: UpgradeModeStatus, shutdown_token: nym_task::ShutdownToken, } @@ -97,6 +101,7 @@ impl PeerController { bw_storage_managers: HashMap, request_tx: mpsc::Sender, request_rx: mpsc::Receiver, + upgrade_mode: UpgradeModeStatus, shutdown_token: nym_task::ShutdownToken, ) -> Self { let timeout_check_interval = @@ -110,12 +115,13 @@ impl PeerController { cached_peer_manager, bandwidth_storage_manager.clone(), request_tx.clone(), + upgrade_mode.clone(), &shutdown_token, ); let public_key = public_key.clone(); tokio::spawn(async move { handle.run().await; - log::debug!("Peer handle shut down for {public_key}"); + debug!("Peer handle shut down for {public_key}"); }); } let bw_storage_managers = bw_storage_managers @@ -131,6 +137,7 @@ impl PeerController { request_tx, request_rx, timeout_check_interval, + upgrade_mode, shutdown_token, metrics, } @@ -145,7 +152,7 @@ impl PeerController { self.bw_storage_managers.remove(key); let ret = self.wg_api.remove_peer(key); if ret.is_err() { - log::error!( + error!( "Wireguard peer could not be removed from wireguard kernel module. Process should be restarted so that the interface is reset." ); } @@ -192,6 +199,7 @@ impl PeerController { cached_peer_manager, bandwidth_storage_manager.clone(), self.request_tx.clone(), + self.upgrade_mode.clone(), &self.shutdown_token, ); self.bw_storage_managers @@ -203,7 +211,7 @@ impl PeerController { let public_key = peer.public_key.clone(); tokio::spawn(async move { handle.run().await; - log::debug!("Peer handle shut down for {public_key}"); + debug!("Peer handle shut down for {public_key}"); }); Ok(()) } @@ -357,6 +365,7 @@ impl PeerController { } } + #[allow(clippy::expect_used)] self.metrics.wireguard.update( // if the conversion fails it means we're running not running on a 64bit system // and that's a reason enough for this failure. @@ -377,7 +386,7 @@ impl PeerController { tokio::select! { _ = self.timeout_check_interval.next() => { let Ok(host) = self.wg_api.read_interface_data() else { - log::error!("Can't read wireguard kernel data"); + error!("Can't read wireguard kernel data"); continue; }; self.update_metrics(&host).await; @@ -385,7 +394,7 @@ impl PeerController { *self.host_information.write().await = host; } _ = self.shutdown_token.cancelled() => { - log::trace!("PeerController handler: Received shutdown"); + trace!("PeerController handler: Received shutdown"); break; } msg = self.request_rx.recv() => { @@ -412,7 +421,7 @@ impl PeerController { response_tx.send(self.handle_query_verifier_by_ip(ip, *credential).await).ok(); } None => { - log::trace!("PeerController [main loop]: stopping since channel closed"); + trace!("PeerController [main loop]: stopping since channel closed"); break; } } @@ -429,6 +438,9 @@ struct MockWgApi { } #[cfg(feature = "mock")] +// unwraps, etc. are fine in test code +#[allow(clippy::unwrap_used)] +#[allow(clippy::todo)] impl WireguardInterfaceApi for MockWgApi { fn create_interface( &self, @@ -534,6 +546,7 @@ pub fn start_controller( Default::default(), request_tx, request_rx, + UpgradeModeStatus::default(), shutdown_manager.child_shutdown_token(), ); tokio::spawn(async move { peer_controller.run().await }); @@ -542,12 +555,15 @@ pub fn start_controller( } #[cfg(feature = "mock")] +// unwraps are fine in test code +#[allow(clippy::unwrap_used)] pub async fn stop_controller(mut shutdown_manager: nym_task::ShutdownManager) { shutdown_manager.send_cancellation(); shutdown_manager.run_until_shutdown().await; } #[cfg(test)] +#[cfg(feature = "mock")] mod tests { use super::*; diff --git a/common/wireguard/src/peer_handle.rs b/common/wireguard/src/peer_handle.rs index 57c92d5072..b5dd5d83fc 100644 --- a/common/wireguard/src/peer_handle.rs +++ b/common/wireguard/src/peer_handle.rs @@ -6,12 +6,16 @@ use crate::peer_controller::PeerControlRequest; use crate::peer_storage_manager::{CachedPeerManager, PeerInformation}; use defguard_wireguard_rs::{host::Host, key::Key, net::IpAddrMask}; use futures::channel::oneshot; +use nym_credential_verification::OutOfBandwidthResultExt; use nym_credential_verification::bandwidth_storage_manager::BandwidthStorageManager; +use nym_credential_verification::upgrade_mode::UpgradeModeStatus; use nym_task::ShutdownToken; use nym_wireguard_types::DEFAULT_PEER_TIMEOUT_CHECK; +use std::fmt::Display; use std::sync::Arc; use tokio::sync::{RwLock, mpsc}; use tokio_stream::{StreamExt, wrappers::IntervalStream}; +use tracing::{debug, error, trace, warn}; #[derive(Clone)] pub(crate) struct SharedBandwidthStorageManager { @@ -43,9 +47,19 @@ pub struct PeerHandle { bandwidth_storage_manager: SharedBandwidthStorageManager, request_tx: mpsc::Sender, timeout_check_interval: IntervalStream, + + /// Flag indicating whether the system is undergoing an upgrade and thus peers shouldn't be getting + /// their bandwidth metered. + upgrade_mode: UpgradeModeStatus, shutdown_token: ShutdownToken, } +impl Display for PeerHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "peer {}", self.public_key) + } +} + impl PeerHandle { pub(crate) fn new( public_key: Key, @@ -53,11 +67,11 @@ impl PeerHandle { cached_peer: CachedPeerManager, bandwidth_storage_manager: SharedBandwidthStorageManager, request_tx: mpsc::Sender, + upgrade_mode: UpgradeModeStatus, shutdown_token: &ShutdownToken, ) -> Self { - let timeout_check_interval = tokio_stream::wrappers::IntervalStream::new( - tokio::time::interval(DEFAULT_PEER_TIMEOUT_CHECK), - ); + let timeout_check_interval = + IntervalStream::new(tokio::time::interval(DEFAULT_PEER_TIMEOUT_CHECK)); let shutdown_token = shutdown_token.clone(); PeerHandle { public_key, @@ -66,10 +80,22 @@ impl PeerHandle { bandwidth_storage_manager, request_tx, timeout_check_interval, + upgrade_mode, shutdown_token, } } + /// Attempt to use the specified amount of bandwidth and update internal cache. + /// Returns the amount of bandwidth remaining + async fn try_use_bandwidth(&self, spent: i64) -> nym_credential_verification::Result { + self.bandwidth_storage_manager + .inner + .write() + .await + .try_use_bandwidth(spent) + .await + } + async fn remove_peer(&self) -> Result { let (response_tx, response_rx) = oneshot::channel(); self.request_tx @@ -87,73 +113,33 @@ impl PeerHandle { Ok(success) } - fn compute_spent_bandwidth( - kernel_peer: PeerInformation, - cached_peer: PeerInformation, - ) -> Option { - let kernel_total = kernel_peer - .rx_bytes - .checked_add(kernel_peer.tx_bytes) - .or_else(|| { - tracing::error!( - "Overflow on kernel adding bytes: {} + {}", - kernel_peer.rx_bytes, - kernel_peer.tx_bytes - ); - None - })?; - let cached_total = cached_peer - .rx_bytes - .checked_add(cached_peer.tx_bytes) - .or_else(|| { - tracing::error!( - "Overflow on cached adding bytes: {} + {}", - cached_peer.rx_bytes, - cached_peer.tx_bytes - ); - None - })?; - - kernel_total.checked_sub(cached_total).or_else(|| { - tracing::error!("Overflow on spent bandwidth subtraction: kernel - cached = {kernel_total} - {cached_total}"); - None - }) - } - async fn active_peer(&mut self, kernel_peer: PeerInformation) -> Result { let Some(cached_peer) = self.cached_peer.get_peer() else { - log::debug!( - "Peer {:?} not in storage anymore, shutting down handle", - self.public_key - ); + debug!("{self} not in storage anymore, shutting down handle"); return Ok(false); }; - let spent_bandwidth = Self::compute_spent_bandwidth(kernel_peer, cached_peer) - .unwrap_or_default() - .try_into() - .inspect_err(|err| tracing::error!("Could not convert from u64 to i64: {err:?}")) - .unwrap_or_default(); - + let spent_bandwidth = kernel_peer.consumed_kernel_bandwidth(&cached_peer); self.cached_peer.update(kernel_peer); - if spent_bandwidth > 0 - && self - .bandwidth_storage_manager - .inner() - .write() - .await + if spent_bandwidth > 0 { + trace!("{self} has used {spent_bandwidth} of bandwidth"); + if self.upgrade_mode.enabled() { + debug!("we're in upgrade mode - {self} is not going to get its bandwidth deducted"); + return Ok(true); + } + + // 'regular' flow + if self .try_use_bandwidth(spent_bandwidth) .await - .is_err() - { - tracing::debug!( - "Peer {} is out of bandwidth, removing it", - self.public_key.to_string() - ); - let success = self.remove_peer().await?; - self.cached_peer.remove_peer(); - return Ok(!success); + .is_out_of_bandwidth() + { + debug!("{self} is out of bandwidth, removing it"); + let success = self.remove_peer().await?; + self.cached_peer.remove_peer(); + return Ok(!success); + } } Ok(true) @@ -169,10 +155,7 @@ impl PeerHandle { .ok_or(Error::MissingClientKernelEntry(self.public_key.to_string()))? .into(); if !self.active_peer(kernel_peer).await? { - log::debug!( - "Peer {:?} is not active anymore, shutting down handle", - self.public_key - ); + debug!("{self} is not active anymore, shutting down handle",); Ok(false) } else { Ok(true) @@ -184,12 +167,12 @@ impl PeerHandle { tokio::select! { biased; _ = self.shutdown_token.cancelled() => { - log::trace!("PeerHandle: Received shutdown"); + trace!("PeerHandle: Received shutdown"); if let Err(e) = self.bandwidth_storage_manager.inner().write().await.sync_storage_bandwidth().await { - log::error!("Storage sync failed - {e}, unaccounted bandwidth might have been consumed"); + error!("Storage sync failed - {e}, unaccounted bandwidth might have been consumed"); } - log::trace!("PeerHandle: Finished shutdown"); + trace!("PeerHandle: Finished shutdown"); break; } _ = self.timeout_check_interval.next() => { @@ -199,11 +182,11 @@ impl PeerHandle { Err(err) => { match self.remove_peer().await { Ok(true) => { - tracing::debug!("Removed peer due to error {err}"); + debug!("Removed peer due to error {err}"); return; } _ => { - tracing::warn!("Could not remove peer yet, we'll try again later. If this message persists, the gateway might need to be restarted"); + warn!("Could not remove peer yet, we'll try again later. If this message persists, the gateway might need to be restarted"); continue; } } diff --git a/common/wireguard/src/peer_storage_manager.rs b/common/wireguard/src/peer_storage_manager.rs index 1675cf6b2f..00c439350d 100644 --- a/common/wireguard/src/peer_storage_manager.rs +++ b/common/wireguard/src/peer_storage_manager.rs @@ -46,7 +46,7 @@ impl CachedPeerManager { pub(crate) fn update(&mut self, kernel_peer: PeerInformation) { if let Some(peer_information) = self.peer_information.as_mut() { - peer_information.update_trx_bytes(kernel_peer); + peer_information.update_tx_rx_bytes(kernel_peer); } } } @@ -67,8 +67,49 @@ impl From<&Peer> for PeerInformation { } impl PeerInformation { - pub(crate) fn update_trx_bytes(&mut self, peer: PeerInformation) { + pub(crate) fn update_tx_rx_bytes(&mut self, peer: PeerInformation) { self.tx_bytes = peer.tx_bytes; self.rx_bytes = peer.rx_bytes; } + + fn rx_tx_total(&self, typ: &'static str) -> Option { + self.rx_bytes.checked_add(self.tx_bytes).or_else(|| { + tracing::error!( + "overflow on {typ} adding bytes: {} + {}", + self.rx_bytes, + self.tx_bytes + ); + None + }) + } + + /// Attempt to determine the amount of consumed bandwidth based on the current peer information + /// and state from the last checkpoint. + pub(crate) fn consumed_bandwidth(kernel: &Self, previous_cached: &Self) -> Option { + let kernel_total = kernel.rx_tx_total("kernel")?; + let cached_total = previous_cached.rx_tx_total("cached")?; + kernel_total.checked_sub(cached_total).or_else(|| { + tracing::error!("Overflow on spent bandwidth subtraction: kernel - cached = {kernel_total} - {cached_total}"); + None + }) + } + + /// Attempt to determine the amount of consumed bandwidth based on the current peer information + /// and state from the last checkpoint. + /// On failures, it will attempt to default to most sensible alternative + /// + /// Note, it is responsibility of the caller to ensure that `self` corresponds to the kernel peer information + pub(crate) fn consumed_kernel_bandwidth(&self, previous_cached: &Self) -> i64 { + let Some(consumed) = Self::consumed_bandwidth(self, previous_cached) else { + // old behaviour of returning the `Default::default()` + return 0; + }; + + // old behaviour would have also returned 0 here, but I'd argue if u64 can't fit in i64, + // it means we're over i64::MAX, thus that's what we should return + consumed + .try_into() + .inspect_err(|err| tracing::error!("Could not convert from u64 to i64: {err:?}")) + .unwrap_or(i64::MAX) + } } diff --git a/envs/canary.env b/envs/canary.env index f494ad3270..fbf97b2643 100644 --- a/envs/canary.env +++ b/envs/canary.env @@ -22,3 +22,5 @@ NYXD=https://rpc.canary-validator.performance.nymte.ch NYM_API=https://canary-api.performance.nymte.ch/api/ NYXD_WS=wss://rpc.canary-validator.performance.nymte.ch/websocket NYM_VPN_API=https://nym-vpn-api-git-deploy-canary-nyx-network-staging.vercel.app/api/ + +UPGRADE_MODE_ATTESTER_ED25519_PUBKEY=U1NXToPYUTsh7pYPLcwXCXwcL6pGoLUou7fyAJrNz8b \ No newline at end of file diff --git a/envs/mainnet.env b/envs/mainnet.env index 444307f836..01e5e9092b 100644 --- a/envs/mainnet.env +++ b/envs/mainnet.env @@ -25,3 +25,5 @@ NYXD=https://rpc.nymtech.net NYM_API=https://validator.nymtech.net/api/ NYXD_WS=wss://rpc.nymtech.net/websocket NYM_VPN_API=https://nymvpn.com/api/ + +UPGRADE_MODE_ATTESTER_ED25519_PUBKEY=3bgffBYcfFkTTXc2npNNn9MkddFZ3H2LrPjXDmnJzrqd \ No newline at end of file diff --git a/envs/sandbox.env b/envs/sandbox.env index e90257f015..96a46881dd 100644 --- a/envs/sandbox.env +++ b/envs/sandbox.env @@ -23,3 +23,5 @@ NYXD=https://rpc.sandbox.nymtech.net NYXD_WS=wss://rpc.sandbox.nymtech.net/websocket NYM_API=https://sandbox-nym-api1.nymtech.net/api/ NYM_VPN_API=https://nym-vpn-api-git-deploy-sandbox-nyx-network-staging.vercel.app/api/ + +UPGRADE_MODE_ATTESTER_ED25519_PUBKEY=EGwzKXPrqStv8cHF68VT2LbQuEBGDPzhCAixScvybfem \ No newline at end of file diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 1e4a5be417..cf1b8f286b 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -19,7 +19,6 @@ rust-version = "1.85" path = "src/lib.rs" [dependencies] -anyhow = { workspace = true } bincode = { workspace = true } async-trait = { workspace = true } bip39 = { workspace = true } @@ -30,7 +29,6 @@ futures = { workspace = true } ipnetwork = { workspace = true } rand = { workspace = true } serde = { workspace = true, features = ["derive"] } -sha2 = { workspace = true } thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = [ @@ -40,16 +38,14 @@ tokio = { workspace = true, features = [ "fs", "time", ] } -tokio-stream = { workspace = true, features = ["fs"] } +tokio-stream = { workspace = true } tokio-tungstenite = { workspace = true } -tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } url = { workspace = true, features = ["serde"] } zeroize = { workspace = true } # internal -nym-api-requests = { path = "../nym-api/nym-api-requests" } nym-credentials = { path = "../common/credentials" } nym-credentials-interface = { path = "../common/credentials-interface" } nym-credential-verification = { path = "../common/credential-verification" } @@ -58,7 +54,6 @@ nym-gateway-storage = { path = "../common/gateway-storage" } nym-gateway-stats-storage = { path = "../common/gateway-stats-storage" } nym-gateway-requests = { path = "../common/gateway-requests" } nym-mixnet-client = { path = "../common/client-libs/mixnet-client" } -nym-mixnode-common = { path = "../common/mixnode-common" } nym-network-defaults = { path = "../common/network-defaults" } nym-network-requester = { path = "../service-providers/network-requester" } nym-sdk = { path = "../sdk/rust/nym-sdk" } @@ -66,10 +61,10 @@ nym-sphinx = { path = "../common/nymsphinx" } nym-statistics-common = { path = "../common/statistics" } nym-task = { path = "../common/task" } nym-topology = { path = "../common/topology" } -nym-types = { path = "../common/types" } nym-validator-client = { path = "../common/client-libs/validator-client" } nym-ip-packet-router = { path = "../service-providers/ip-packet-router" } nym-node-metrics = { path = "../nym-node/nym-node-metrics" } +nym-upgrade-mode-check = { path = "../common/upgrade-mode-check" } nym-wireguard = { path = "../common/wireguard" } nym-wireguard-private-metadata-server = { path = "../common/wireguard-private-metadata/server" } @@ -80,7 +75,6 @@ nym-client-core = { path = "../common/client-core", features = ["cli"] } nym-id = { path = "../common/nym-id" } nym-service-provider-requests-common = { path = "../common/service-provider-requests-common" } - defguard_wireguard_rs = { workspace = true } [dev-dependencies] @@ -88,3 +82,6 @@ nym-gateway-storage = { path = "../common/gateway-storage", features = ["mock"] nym-wireguard = { path = "../common/wireguard", features = ["mock"] } mock_instant = "0.6.0" time = { workspace = true } + +[lints] +workspace = true \ No newline at end of file diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 363ddbb88e..8df528674b 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -13,6 +13,8 @@ pub struct Config { pub ip_packet_router: IpPacketRouter, + pub upgrade_mode_watcher: UpgradeModeWatcher, + pub debug: Debug, } @@ -21,12 +23,14 @@ impl Config { gateway: impl Into, network_requester: impl Into, ip_packet_router: impl Into, + upgrade_mode_watcher: impl Into, debug: impl Into, ) -> Self { Config { gateway: gateway.into(), network_requester: network_requester.into(), ip_packet_router: ip_packet_router.into(), + upgrade_mode_watcher: upgrade_mode_watcher.into(), debug: debug.into(), } } @@ -57,6 +61,28 @@ pub struct Gateway { pub nyxd_urls: Vec, } +#[derive(Debug)] +pub struct UpgradeModeWatcher { + /// Specifies whether this gateway watches for upgrade mode changes + /// via the published attestation file. + pub enabled: bool, + + /// Endpoint to query to retrieve current upgrade mode attestation. + /// If not provided, it implicitly disables the watcher and upgrade-mode features + pub attestation_url: Url, + + pub debug: UpgradeModeWatcherDebug, +} + +#[derive(Debug)] +pub struct UpgradeModeWatcherDebug { + /// Default polling interval + pub regular_polling_interval: Duration, + + /// Expedited polling interval for once upgrade mode is detected + pub expedited_poll_interval: Duration, +} + #[derive(Debug, PartialEq)] pub struct NetworkRequester { /// Specifies whether network requester service is enabled in this process. @@ -104,6 +130,9 @@ pub struct Debug { /// Defines the timestamp skew of a signed authentication request before it's deemed too excessive to process. pub max_request_timestamp_skew: Duration, + + /// The minimum duration since the last explicit check for the upgrade mode to allow creation of new requests. + pub upgrade_mode_min_staleness_recheck: Duration, } #[derive(Debug, Clone)] diff --git a/gateway/src/node/client_handling/active_clients.rs b/gateway/src/node/client_handling/active_clients.rs index a215affdf9..8f577ba13f 100644 --- a/gateway/src/node/client_handling/active_clients.rs +++ b/gateway/src/node/client_handling/active_clients.rs @@ -147,7 +147,7 @@ impl ActiveClientsStore { handle: MixMessageSender, is_active_request_sender: IsActiveRequestSender, session_request_timestamp: OffsetDateTime, - ) { + ) -> bool { let entry = ActiveClient::Remote(RemoteClientData { session_request_timestamp, channels: ClientIncomingChannels { @@ -156,11 +156,16 @@ impl ActiveClientsStore { }, }); if self.inner.insert(client, entry).is_some() { - panic!("inserted a duplicate remote client") + // this should be impossible under normal circumstances, + // but in some rare edge cases of clients performing very careful timing attacks, + // this branch could be potentially triggered + return false; } + true } /// Inserts a handle to the embedded client + #[allow(clippy::panic)] pub fn insert_embedded(&self, local_client_handle: LocalEmbeddedClientHandle) { let key = local_client_handle.client_destination(); let entry = ActiveClient::Embedded(Box::new(local_client_handle)); diff --git a/gateway/src/node/client_handling/websocket/common_state.rs b/gateway/src/node/client_handling/websocket/common_state.rs index 7f4d66c828..f3e9f711fa 100644 --- a/gateway/src/node/client_handling/websocket/common_state.rs +++ b/gateway/src/node/client_handling/websocket/common_state.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::node::ActiveClientsStore; +use nym_credential_verification::upgrade_mode::UpgradeModeDetails; use nym_credential_verification::{ecash::EcashManager, BandwidthFlushingBehaviourConfig}; use nym_crypto::asymmetric::ed25519; use nym_gateway_storage::GatewayStorage; @@ -11,7 +12,7 @@ use nym_node_metrics::NymNodeMetrics; use std::sync::Arc; use std::time::Duration; -#[derive(Clone)] +#[derive(Clone, Copy)] pub(crate) struct Config { pub(crate) enforce_zk_nym: bool, pub(crate) max_request_timestamp_skew: Duration, @@ -29,6 +30,7 @@ pub(crate) struct CommonHandlerState { pub(crate) metrics_sender: MetricEventsSender, pub(crate) outbound_mix_sender: MixForwardingSender, pub(crate) active_clients_store: ActiveClientsStore, + pub(crate) upgrade_mode: UpgradeModeDetails, } impl CommonHandlerState { diff --git a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs index 36b8029332..b40642930a 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs @@ -14,14 +14,16 @@ use futures::{ future::{FusedFuture, OptionFuture}, FutureExt, StreamExt, }; +use nym_credential_verification::upgrade_mode::UpgradeModeEnableError; use nym_credential_verification::CredentialVerifier; use nym_credential_verification::{ bandwidth_storage_manager::BandwidthStorageManager, ClientBandwidth, }; +use nym_credentials_interface::DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD; use nym_gateway_requests::{ types::{BinaryRequest, ServerResponse}, - ClientControlRequest, ClientRequest, GatewayRequestsError, SensitiveServerResponse, - SimpleGatewayRequestsError, + BandwidthResponse, ClientControlRequest, ClientRequest, GatewayRequestsError, SendResponse, + SensitiveServerResponse, SimpleGatewayRequestsError, }; use nym_gateway_storage::error::GatewayStorageError; use nym_gateway_storage::traits::BandwidthGatewayStorage; @@ -31,6 +33,7 @@ use nym_sphinx::forwarding::packet::MixPacket; use nym_statistics_common::{gateways::GatewaySessionEvent, types::SessionType}; use nym_validator_client::coconut::EcashApiError; use rand::{random, CryptoRng, Rng}; +use std::cmp::max; use std::{process, time::Duration}; use thiserror::Error; use tokio::io::{AsyncRead, AsyncWrite}; @@ -92,8 +95,11 @@ pub enum RequestHandlingError { #[error("failed to recover bandwidth value: {0}")] BandwidthRecoveryFailure(#[from] BandwidthError), - #[error("{0}")] + #[error(transparent)] CredentialVerification(#[from] nym_credential_verification::Error), + + #[error(transparent)] + UpgradeModeEnable(#[from] UpgradeModeEnableError), } impl RequestHandlingError { @@ -161,6 +167,10 @@ impl AuthenticatedHandler { &self.inner } + fn upgrade_mode_enabled(&self) -> bool { + self.inner.upgrade_mode_enabled() + } + /// Upgrades `FreshHandler` into the Authenticated variant implying the client is now authenticated /// and thus allowed to perform more actions with the gateway, such as redeeming bandwidth or /// sending sphinx packets. @@ -271,7 +281,50 @@ impl AuthenticatedHandler { .inspect_err(|verification_failure| debug!("{verification_failure}"))?; trace!("available total bandwidth: {available_total}"); - Ok(ServerResponse::Bandwidth { available_total }) + Ok(ServerResponse::Bandwidth(BandwidthResponse { + available_total, + upgrade_mode: self.upgrade_mode_enabled(), + })) + } + + async fn upgrade_mode_bandwidth(&self) -> i64 { + // if we're undergoing upgrade mode, we don't meter bandwidth, + // we simply return MAX of clients current bandwidth and minimum bandwidth before default + // client would have attempted to send new ticket + // the latter is to support older clients that will ignore `upgrade_mode` field in the response + // as they're not aware of its existence + let available_bandwidth = self.bandwidth_storage_manager.available_bandwidth().await; + max( + DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD + 1, + available_bandwidth, + ) + } + + /// Tries to handle the received JWT token request by checking its correctness and + /// internally enables upgrade mode if it hasn't been set before. + /// Furthermore, clients bandwidth metering is getting disabled. + async fn handle_upgrade_mode_jwt( + &self, + token: String, + ) -> Result { + // if we're already in the upgrade mode, don't bother validating the token + if self.upgrade_mode_enabled() { + return Ok(ServerResponse::Bandwidth(BandwidthResponse { + available_total: self.upgrade_mode_bandwidth().await, + upgrade_mode: true, + })); + } + + self.inner + .shared_state + .upgrade_mode + .try_enable_via_received_jwt(token) + .await?; + + Ok(ServerResponse::Bandwidth(BandwidthResponse { + available_total: self.upgrade_mode_bandwidth().await, + upgrade_mode: true, + })) } /// Tries to handle request to forward sphinx packet into the network. The request can only succeed @@ -289,15 +342,22 @@ impl AuthenticatedHandler { ) -> Result { let required_bandwidth = mix_packet.packet().len() as i64; - let remaining_bandwidth = self - .bandwidth_storage_manager - .try_use_bandwidth(required_bandwidth) - .await?; + let upgrade_mode = self.upgrade_mode_enabled(); + + let remaining_bandwidth = if self.upgrade_mode_enabled() { + self.upgrade_mode_bandwidth().await + } else { + self.bandwidth_storage_manager + .try_use_bandwidth(required_bandwidth) + .await? + }; + self.forward_packet(mix_packet); - Ok(ServerResponse::Send { + Ok(ServerResponse::Send(SendResponse { remaining_bandwidth, - }) + upgrade_mode, + })) } /// Attempts to handle a binary data frame websocket message. @@ -432,6 +492,9 @@ impl AuthenticatedHandler { ClientControlRequest::EcashCredential { enc_credential, iv } => { self.handle_ecash_bandwidth(enc_credential, iv).await } + ClientControlRequest::UpgradeModeJWT { token } => { + self.handle_upgrade_mode_jwt(token).await + } ClientControlRequest::BandwidthCredential { .. } => { Err(RequestHandlingError::IllegalRequest { additional_context: "coconut credential are not longer supported".into(), @@ -446,7 +509,13 @@ impl AuthenticatedHandler { .bandwidth_storage_manager .handle_claim_testnet_bandwidth() .await - .map_err(|e| e.into()), + .map_err(|e| e.into()) + .map(|available_total| { + ServerResponse::Bandwidth(BandwidthResponse { + available_total, + upgrade_mode: self.upgrade_mode_enabled(), + }) + }), ClientControlRequest::SupportedProtocol { .. } => { Ok(self.inner.handle_supported_protocol_request()) } diff --git a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs index 54d6e02f6e..0eb18a602a 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs @@ -20,11 +20,12 @@ use nym_gateway_requests::authenticate::AuthenticateRequest; use nym_gateway_requests::authentication::encrypted_address::{ EncryptedAddressBytes, EncryptedAddressConversionError, }; +use nym_gateway_requests::registration::handshake::HandshakeResult; use nym_gateway_requests::{ registration::handshake::{error::HandshakeError, gateway_handshake}, types::{ClientControlRequest, ServerResponse}, - AuthenticationFailure, BinaryResponse, SharedGatewayKey, CURRENT_PROTOCOL_VERSION, - INITIAL_PROTOCOL_VERSION, + AuthenticationFailure, BinaryResponse, GatewayProtocolVersion, GatewayProtocolVersionExt, + SharedGatewayKey, CURRENT_PROTOCOL_VERSION, }; use nym_gateway_storage::error::GatewayStorageError; use nym_gateway_storage::traits::BandwidthGatewayStorage; @@ -88,9 +89,6 @@ pub(crate) enum InitialAuthenticationError { #[error("Experienced connection error: {0}")] ConnectionError(Box), - #[error("Attempted to negotiate connection with client using incompatible protocol version. Ours is {current} and the client reports {client:?}")] - IncompatibleProtocol { client: Option, current: u8 }, - #[error("failed to send authentication response: {source}")] ResponseSendFailure { #[source] @@ -130,7 +128,7 @@ pub(crate) struct FreshHandler { pub(crate) shutdown: ShutdownToken, // currently unused (but populated) - pub(crate) negotiated_protocol: Option, + pub(crate) negotiated_protocol: Option, } impl FreshHandler { @@ -138,6 +136,10 @@ impl FreshHandler { &self.shared_state } + pub(crate) fn upgrade_mode_enabled(&self) -> bool { + self.shared_state.upgrade_mode.enabled() + } + // for time being we assume handle is always constructed from raw socket. // if we decide we want to change it, that's not too difficult pub(crate) fn new( @@ -189,7 +191,8 @@ impl FreshHandler { async fn perform_registration_handshake( &mut self, init_msg: Vec, - ) -> Result + requested_protocol: Option, + ) -> Result where S: AsyncRead + AsyncWrite + Unpin + Send, R: CryptoRng + RngCore + Send, @@ -202,15 +205,17 @@ impl FreshHandler { ws_stream, self.shared_state.local_identity.as_ref(), init_msg, + requested_protocol, self.shutdown.clone(), ) .await } - _ => unreachable!(), + _ => Err(HandshakeError::ConnectionInInvalidState), } } /// Attempts to read websocket message from the associated socket. + #[allow(clippy::panic)] pub(crate) async fn read_websocket_message(&mut self) -> Option> where S: AsyncRead + AsyncWrite + Unpin, @@ -226,6 +231,7 @@ impl FreshHandler { /// # Arguments /// /// * `msg`: WebSocket message to write back to the client. + #[allow(clippy::panic)] pub(crate) async fn send_websocket_message( &mut self, msg: impl Into, @@ -269,6 +275,7 @@ impl FreshHandler { /// /// * `shared_keys`: keys derived between the client and gateway. /// * `packets`: unwrapped packets that are to be pushed back to the client. + #[allow(clippy::panic)] pub(crate) async fn push_packets_to_client( &mut self, shared_keys: &SharedGatewayKey, @@ -411,59 +418,6 @@ impl FreshHandler { } } - fn negotiate_client_protocol( - &self, - client_protocol: Option, - ) -> Result { - debug!("client protocol: {client_protocol:?}, ours: {CURRENT_PROTOCOL_VERSION}"); - let Some(client_protocol_version) = client_protocol else { - warn!("the client we're connected to has not specified its protocol version. It's probably running version < 1.1.X, but that's still fine for now. It will become a hard error in 1.2.0"); - // note: in +1.2.0 we will have to return a hard error here - return Ok(INITIAL_PROTOCOL_VERSION); - }; - - // ##### - // On backwards compat: - // Currently it is the case that gateways will understand all previous protocol versions - // and will downgrade accordingly, but this will now always be the case. - // For example, once we remove downgrade on legacy auth, anything below version 4 will be rejected - // ##### - - // a v2 gateway will understand v1 requests, but v1 client will not understand v2 responses - if client_protocol_version == 1 { - return Ok(1); - } - - // a v3 gateway will understand v2 requests (legacy keys) - if client_protocol_version == 2 { - return Ok(2); - } - - // a v4 gateway will understand v3 requests (aes256gcm-siv) - if client_protocol_version == 3 { - return Ok(3); - } - - // a v5 gateway will understand v4 requests (key-rotation) - if client_protocol_version == 4 { - return Ok(4); - } - - // we can't handle clients with higher protocol than ours - // (perhaps we could try to negotiate downgrade on our end? sounds like a nice future improvement) - if client_protocol_version <= CURRENT_PROTOCOL_VERSION { - debug!("the client is using exactly the same (or older) protocol version as we are. We're good to continue!"); - Ok(CURRENT_PROTOCOL_VERSION) - } else { - let err = InitialAuthenticationError::IncompatibleProtocol { - client: client_protocol, - current: CURRENT_PROTOCOL_VERSION, - }; - error!("{err}"); - Err(err) - } - } - async fn handle_duplicate_client( &mut self, address: DestinationAddressBytes, @@ -551,6 +505,29 @@ impl FreshHandler { Ok(available_bandwidth) } + fn negotiate_proposed_protocol( + &self, + client_protocol_version: Option, + ) -> Option { + if client_protocol_version.is_future_version() { + // this should never happen in a non-malicious client as it should use at most whatever version this gateway has announced + warn!("client has announced protocol version greater than one known by this gateway (v{client_protocol_version:?} vs v{}). attempting to downgrade.", GatewayProtocolVersion::CURRENT); + // we just reply with our current version, and it's up to the client to accept it or terminate the connection + Some(GatewayProtocolVersion::CURRENT) + } else { + // ##### + // On backwards compat: + // Currently it is the case that gateways will understand all previous protocol versions + // and will downgrade accordingly, but this will not always be the case. + // For example, once we remove downgrade on legacy auth, anything below version 4 will be rejected + // ##### + debug!( + "using the protocol version proposed by the client: v{client_protocol_version:?}" + ); + client_protocol_version + } + } + /// Tries to handle the received authentication request by checking correctness of the received data. /// /// # Arguments @@ -565,7 +542,7 @@ impl FreshHandler { )] async fn handle_legacy_authenticate( &mut self, - client_protocol_version: Option, + client_protocol_version: Option, address: String, enc_address: String, raw_nonce: String, @@ -575,9 +552,9 @@ impl FreshHandler { { debug!("handling client authentication (v1)"); - let negotiated_protocol = self.negotiate_client_protocol(client_protocol_version)?; + let negotiated_protocol = self.negotiate_proposed_protocol(client_protocol_version); // populate the negotiated protocol for future uses - self.negotiated_protocol = Some(negotiated_protocol); + self.negotiated_protocol = negotiated_protocol; let address = DestinationAddressBytes::try_from_base58_string(address) .map_err(|err| InitialAuthenticationError::MalformedClientAddress(err.to_string()))?; @@ -592,7 +569,7 @@ impl FreshHandler { .await? else { // it feels weird to be returning an 'Ok' here, but I didn't want to change the existing behaviour - return Ok(InitialAuthResult::new_failed(Some(negotiated_protocol))); + return Ok(InitialAuthResult::new_legacy_failed(negotiated_protocol)); }; // in v1 we don't have explicit data so we have to use current timestamp @@ -634,9 +611,10 @@ impl FreshHandler { session_request_start, )), ServerResponse::Authenticate { - protocol_version: Some(negotiated_protocol), + protocol_version: negotiated_protocol, status: true, bandwidth_remaining, + upgrade_mode: self.upgrade_mode_enabled(), }, )) } @@ -651,9 +629,9 @@ impl FreshHandler { debug!("handling client authentication (v2)"); let negotiated_protocol = - self.negotiate_client_protocol(Some(request.content.protocol_version))?; + self.negotiate_proposed_protocol(Some(request.content.protocol_version)); // populate the negotiated protocol for future uses - self.negotiated_protocol = Some(negotiated_protocol); + self.negotiated_protocol = negotiated_protocol; let address = request.content.client_identity.derive_destination_address(); @@ -720,9 +698,10 @@ impl FreshHandler { session_request_start, )), ServerResponse::Authenticate { - protocol_version: Some(negotiated_protocol), + protocol_version: negotiated_protocol, status: true, bandwidth_remaining, + upgrade_mode: self.upgrade_mode_enabled(), }, )) } @@ -782,17 +761,13 @@ impl FreshHandler { /// * `init_data`: init payload of the registration handshake. async fn handle_register( &mut self, - client_protocol_version: Option, + client_protocol_version: Option, init_data: Vec, ) -> Result where S: AsyncRead + AsyncWrite + Unpin + Send, R: CryptoRng + RngCore + Send, { - let negotiated_protocol = self.negotiate_client_protocol(client_protocol_version)?; - // populate the negotiated protocol for future uses - self.negotiated_protocol = Some(negotiated_protocol); - let remote_identity = Self::extract_remote_identity_from_register_init(&init_data)?; let remote_address = remote_identity.derive_destination_address(); @@ -806,11 +781,20 @@ impl FreshHandler { return Err(InitialAuthenticationError::DuplicateConnection); } - let shared_keys = self.perform_registration_handshake(init_data).await?; + let handshake_result = self + .perform_registration_handshake(init_data, client_protocol_version) + .await?; + let shared_keys = handshake_result.derived_key; + + // populate the negotiated protocol for future uses + self.negotiated_protocol = Some(handshake_result.negotiated_protocol); + let client_id = self.register_client(remote_address, &shared_keys).await?; debug!(client_id = %client_id, "managed to finalize client registration"); + let upgrade_mode = self.upgrade_mode_enabled(); + let client_details = ClientDetails::new( client_id, remote_address, @@ -821,8 +805,9 @@ impl FreshHandler { Ok(InitialAuthResult::new( Some(client_details), ServerResponse::Register { - protocol_version: Some(negotiated_protocol), + protocol_version: self.negotiated_protocol, status: true, + upgrade_mode, }, )) } @@ -946,12 +931,15 @@ impl FreshHandler { let (mix_sender, mix_receiver) = mpsc::unbounded(); // Channel for handlers to ask other handlers if they are still active. let (is_active_request_sender, is_active_request_receiver) = mpsc::unbounded(); - self.shared_state.active_clients_store.insert_remote( + if !self.shared_state.active_clients_store.insert_remote( registration_details.address, mix_sender, is_active_request_sender, registration_details.session_request_timestamp, - ); + ) { + error!("failed to insert remote client handle as it already existed!"); + return None; + } return AuthenticatedHandler::upgrade( self, diff --git a/gateway/src/node/client_handling/websocket/connection_handler/mod.rs b/gateway/src/node/client_handling/websocket/connection_handler/mod.rs index 2fd97600a7..b8ecf12ec8 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/mod.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/mod.rs @@ -79,13 +79,16 @@ impl InitialAuthResult { } } - fn new_failed(protocol_version: Option) -> Self { + fn new_legacy_failed(protocol_version: Option) -> Self { InitialAuthResult { client_details: None, server_response: ServerResponse::Authenticate { protocol_version, status: false, bandwidth_remaining: 0, + // given this response is given only to legacy clients, + // we use the default value as clients wouldn't deserialise it anyway + upgrade_mode: false, }, } } diff --git a/gateway/src/node/internal_service_providers/authenticator/error.rs b/gateway/src/node/internal_service_providers/authenticator/error.rs index ac9b4fd261..5bdde15919 100644 --- a/gateway/src/node/internal_service_providers/authenticator/error.rs +++ b/gateway/src/node/internal_service_providers/authenticator/error.rs @@ -3,7 +3,9 @@ use ipnetwork::IpNetworkError; use nym_client_core::error::ClientCoreError; +use nym_credential_verification::upgrade_mode::UpgradeModeEnableError; use nym_id::NymIdError; +use nym_service_provider_requests_common::ProtocolError; #[derive(thiserror::Error, Debug)] pub enum AuthenticatorError { @@ -17,9 +19,6 @@ pub enum AuthenticatorError { #[error("{0}")] CredentialVerificationError(#[from] nym_credential_verification::Error), - #[error("invalid credential type")] - InvalidCredentialType, - #[error("the entity wrapping the network requester has disconnected")] DisconnectedParent, @@ -50,6 +49,12 @@ pub enum AuthenticatorError { #[error("internal error: {0}")] InternalError(String), + #[error(transparent)] + InvalidPacketHeader { + #[from] + source: ProtocolError, + }, + #[error("received packet has an invalid type: {0}")] InvalidPacketType(u8), @@ -100,4 +105,15 @@ pub enum AuthenticatorError { #[error("no credential received")] NoCredentialReceived, + + #[error(transparent)] + UpgradeModeEnable(#[from] UpgradeModeEnableError), +} + +impl AuthenticatorError { + pub fn response_serialisation(source: impl Into>) -> Self { + AuthenticatorError::FailedToSerializeResponsePacket { + source: source.into(), + } + } } diff --git a/gateway/src/node/internal_service_providers/authenticator/mixnet_listener.rs b/gateway/src/node/internal_service_providers/authenticator/mixnet_listener.rs index af476bfba7..c05d9d8cd6 100644 --- a/gateway/src/node/internal_service_providers/authenticator/mixnet_listener.rs +++ b/gateway/src/node/internal_service_providers/authenticator/mixnet_listener.rs @@ -1,12 +1,6 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use std::{ - net::IpAddr, - sync::Arc, - time::{Duration, SystemTime}, -}; - use crate::node::internal_service_providers::authenticator::{ config::Config, error::AuthenticatorError, peer_manager::PeerManager, seen_credential_cache::SeenCredentialCache, @@ -14,47 +8,58 @@ use crate::node::internal_service_providers::authenticator::{ use defguard_wireguard_rs::net::IpAddrMask; use defguard_wireguard_rs::{host::Peer, key::Key}; use futures::StreamExt; -use nym_authenticator_requests::{ - latest::registration::RegistrationData, v4::registration::IpPair, -}; +use nym_authenticator_requests::models::BandwidthClaim; +use nym_authenticator_requests::traits::UpgradeModeMessage; +use nym_authenticator_requests::{latest, v4::registration::IpPair}; use nym_authenticator_requests::{ latest::registration::{GatewayClient, PendingRegistrations, PrivateIPs}, request::AuthenticatorRequest, traits::{FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage}, - v1, v2, v3, v4, v5, AuthenticatorVersion, CURRENT_VERSION, + v1, v2, v3, v4, v5, v6, AuthenticatorVersion, CURRENT_VERSION, }; use nym_credential_verification::ecash::traits::EcashManager; +use nym_credential_verification::upgrade_mode::UpgradeModeDetails; use nym_credential_verification::{ bandwidth_storage_manager::BandwidthStorageManager, BandwidthFlushingBehaviourConfig, ClientBandwidth, CredentialVerifier, }; -use nym_credentials_interface::{CredentialSpendingData, TicketType}; +use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData}; use nym_crypto::asymmetric::x25519::KeyPair; use nym_gateway_requests::models::CredentialSpendingRequest; use nym_gateway_storage::models::PersistedBandwidth; use nym_sdk::mixnet::{ AnonymousSenderTag, InputMessage, MixnetMessageSender, Recipient, TransmissionLane, }; -use nym_service_provider_requests_common::{Protocol, ServiceProviderType}; +use nym_service_provider_requests_common::{Protocol, ServiceProviderTypeExt}; use nym_sphinx::receiver::ReconstructedMessage; use nym_task::ShutdownToken; use nym_wireguard::WireguardGatewayData; use nym_wireguard_types::PeerPublicKey; use rand::{prelude::IteratorRandom, thread_rng}; +use std::cmp::max; +use std::{ + net::IpAddr, + sync::Arc, + time::{Duration, SystemTime}, +}; use tokio::sync::RwLock; use tokio_stream::wrappers::IntervalStream; type AuthenticatorHandleResult = Result<(Vec, Option), AuthenticatorError>; const DEFAULT_REGISTRATION_TIMEOUT_CHECK: Duration = Duration::from_secs(60); // 1 minute -pub(crate) struct RegistredAndFree { +// we need to be above MINIMUM_REMAINING_BANDWIDTH (500MB) plus we also have to trick the client +// its depletion is low enough to not require sending new tickets +const DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD: i64 = 1024 * 1024 * 1024; + +pub(crate) struct RegisteredAndFree { registration_in_progres: PendingRegistrations, free_private_network_ips: PrivateIPs, } -impl RegistredAndFree { +impl RegisteredAndFree { pub(crate) fn new(free_private_network_ips: PrivateIPs) -> Self { - RegistredAndFree { + RegisteredAndFree { registration_in_progres: Default::default(), free_private_network_ips, } @@ -69,10 +74,12 @@ pub(crate) struct MixnetListener { pub(crate) mixnet_client: nym_sdk::mixnet::MixnetClient, // Registrations awaiting confirmation - pub(crate) registred_and_free: RwLock, + pub(crate) registered_and_free: RwLock, pub(crate) peer_manager: PeerManager, + pub(crate) upgrade_mode: UpgradeModeDetails, + pub(crate) ecash_verifier: Arc, pub(crate) timeout_check_interval: IntervalStream, @@ -86,6 +93,7 @@ impl MixnetListener { free_private_network_ips: PrivateIPs, wireguard_gateway_data: WireguardGatewayData, mixnet_client: nym_sdk::mixnet::MixnetClient, + upgrade_mode: UpgradeModeDetails, ecash_verifier: Arc, ) -> Self { let timeout_check_interval = @@ -93,27 +101,45 @@ impl MixnetListener { MixnetListener { config, mixnet_client, - registred_and_free: RwLock::new(RegistredAndFree::new(free_private_network_ips)), + registered_and_free: RwLock::new(RegisteredAndFree::new(free_private_network_ips)), peer_manager: PeerManager::new(wireguard_gateway_data), + upgrade_mode, ecash_verifier, timeout_check_interval, seen_credential_cache: SeenCredentialCache::new(), } } + fn upgrade_mode_enabled(&self) -> bool { + self.upgrade_mode.enabled() + } + fn keypair(&self) -> &Arc { self.peer_manager.wireguard_gateway_data.keypair() } + async fn upgrade_mode_bandwidth(&self, peer: PeerPublicKey) -> Result { + // if we're undergoing upgrade mode, we don't meter bandwidth, + // we simply return MAX of clients current bandwidth and minimum bandwidth before default + // client would have attempted to send new ticket (hopefully) + // the latter is to support older clients that will ignore `upgrade_mode` field in the response + // as they're not aware of its existence + let available_bandwidth = self.peer_manager.query_bandwidth(peer).await?; + Ok(max( + DEFAULT_WG_CLIENT_BANDWIDTH_THRESHOLD, + available_bandwidth, + )) + } + async fn remove_stale_registrations(&self) -> Result<(), AuthenticatorError> { - let mut registred_and_free = self.registred_and_free.write().await; - let registred_values: Vec<_> = registred_and_free + let mut registered_and_free = self.registered_and_free.write().await; + let registered_values: Vec<_> = registered_and_free .registration_in_progres .values() .cloned() .collect(); - for reg in registred_values { - let ip = registred_and_free + for reg in registered_values { + let ip = registered_and_free .free_private_network_ips .get_mut(®.gateway_data.private_ips) .ok_or(AuthenticatorError::InternalDataCorruption(format!( @@ -122,7 +148,7 @@ impl MixnetListener { )))?; let Some(timestamp) = ip else { - registred_and_free + registered_and_free .registration_in_progres .remove(®.gateway_data.pub_key()); tracing::debug!( @@ -138,7 +164,7 @@ impl MixnetListener { })?; if duration > DEFAULT_REGISTRATION_TIMEOUT_CHECK { *ip = None; - registred_and_free + registered_and_free .registration_in_progres .remove(®.gateway_data.pub_key()); tracing::debug!( @@ -159,8 +185,8 @@ impl MixnetListener { ) -> AuthenticatorHandleResult { let remote_public = init_message.pub_key(); let nonce: u64 = fastrand::u64(..); - let mut registred_and_free = self.registred_and_free.write().await; - if let Some(registration_data) = registred_and_free + let mut registered_and_free = self.registered_and_free.write().await; + if let Some(registration_data) = registered_and_free .registration_in_progres .get(&remote_public) { @@ -181,9 +207,7 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V2 => { v2::response::AuthenticatorResponse::new_pending_registration_success( @@ -201,9 +225,7 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V3 => { v3::response::AuthenticatorResponse::new_pending_registration_success( @@ -221,38 +243,49 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V4 => { v4::response::AuthenticatorResponse::new_pending_registration_success( v4::registration::RegistrationData { nonce: registration_data.nonce, - gateway_data: registration_data.gateway_data.clone().into(), + // convert current to v5 and then v5 to v4 (current as of 28.08.25) + gateway_data: v5::registration::GatewayClient::from( + registration_data.gateway_data.clone(), + ) + .into(), wg_port: registration_data.wg_port, }, request_id, reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V5 => { v5::response::AuthenticatorResponse::new_pending_registration_success( v5::registration::RegistrationData { nonce: registration_data.nonce, - gateway_data: registration_data.gateway_data.clone(), + gateway_data: registration_data.gateway_data.clone().into(), wg_port: registration_data.wg_port, }, request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? + } + AuthenticatorVersion::V6 => { + v6::response::AuthenticatorResponse::new_pending_registration_success( + v6::registration::RegistrationData { + nonce: registration_data.nonce, + gateway_data: registration_data.gateway_data.clone(), + wg_port: registration_data.wg_port, + }, + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::UNKNOWN => return Err(AuthenticatorError::UnknownVersion), }; @@ -265,7 +298,7 @@ impl MixnetListener { .allowed_ips .iter() .find_map(|ip_mask| match ip_mask.ip { - std::net::IpAddr::V4(ipv4_addr) => Some(ipv4_addr), + IpAddr::V4(ipv4_addr) => Some(ipv4_addr), _ => None, }) .ok_or(AuthenticatorError::InternalError( @@ -275,14 +308,14 @@ impl MixnetListener { .allowed_ips .iter() .find_map(|ip_mask| match ip_mask.ip { - std::net::IpAddr::V6(ipv6_addr) => Some(ipv6_addr), + IpAddr::V6(ipv6_addr) => Some(ipv6_addr), _ => None, }) .unwrap_or(IpPair::from(IpAddr::from(allowed_ipv4)).ipv6); let bytes = match AuthenticatorVersion::from(protocol) { AuthenticatorVersion::V1 => v1::response::AuthenticatorResponse::new_registered( - v1::registration::RegistredData { - pub_key: PeerPublicKey::new(self.keypair().public_key().to_bytes().into()), + v1::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), private_ip: allowed_ipv4.into(), wg_port: self.config.authenticator.tunnel_announced_port, }, @@ -290,12 +323,10 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V2 => v2::response::AuthenticatorResponse::new_registered( - v2::registration::RegistredData { - pub_key: PeerPublicKey::new(self.keypair().public_key().to_bytes().into()), + v2::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), private_ip: allowed_ipv4.into(), wg_port: self.config.authenticator.tunnel_announced_port, }, @@ -303,12 +334,10 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V3 => v3::response::AuthenticatorResponse::new_registered( - v3::registration::RegistredData { - pub_key: PeerPublicKey::new(self.keypair().public_key().to_bytes().into()), + v3::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), private_ip: allowed_ipv4.into(), wg_port: self.config.authenticator.tunnel_announced_port, }, @@ -316,12 +345,10 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V4 => v4::response::AuthenticatorResponse::new_registered( - v4::registration::RegistredData { - pub_key: PeerPublicKey::new(self.keypair().public_key().to_bytes().into()), + v4::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), private_ips: (allowed_ipv4, allowed_ipv6).into(), wg_port: self.config.authenticator.tunnel_announced_port, }, @@ -329,27 +356,34 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::new_registered( - v5::registration::RegistredData { - pub_key: PeerPublicKey::new(self.keypair().public_key().to_bytes().into()), + v5::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), private_ips: (allowed_ipv4, allowed_ipv6).into(), wg_port: self.config.authenticator.tunnel_announced_port, }, request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })?, + .map_err(AuthenticatorError::response_serialisation)?, + AuthenticatorVersion::V6 => v6::response::AuthenticatorResponse::new_registered( + v6::registration::RegisteredData { + pub_key: self.keypair().public_key().into(), + private_ips: (allowed_ipv4, allowed_ipv6).into(), + wg_port: self.config.authenticator.tunnel_announced_port, + }, + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::UNKNOWN => return Err(AuthenticatorError::UnknownVersion), }; return Ok((bytes, reply_to)); } - let private_ip_ref = registred_and_free + let private_ip_ref = registered_and_free .free_private_network_ips .iter_mut() .filter(|r| r.1.is_none()) @@ -364,12 +398,12 @@ impl MixnetListener { *private_ip_ref.0, nonce, ); - let registration_data = RegistrationData { + let registration_data = latest::registration::RegistrationData { nonce, gateway_data: gateway_data.clone(), wg_port: self.config.authenticator.tunnel_announced_port, }; - registred_and_free + registered_and_free .registration_in_progres .insert(remote_public, registration_data.clone()); let bytes = match AuthenticatorVersion::from(protocol) { @@ -389,9 +423,7 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V2 => { v2::response::AuthenticatorResponse::new_pending_registration_success( @@ -409,9 +441,7 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V3 => { v3::response::AuthenticatorResponse::new_pending_registration_success( @@ -429,38 +459,49 @@ impl MixnetListener { reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V4 => { v4::response::AuthenticatorResponse::new_pending_registration_success( v4::registration::RegistrationData { nonce: registration_data.nonce, - gateway_data: registration_data.gateway_data.into(), + // convert current to v5 and then v5 to v4 (current as of 28.08.25) + gateway_data: v5::registration::GatewayClient::from( + registration_data.gateway_data.clone(), + ) + .into(), wg_port: registration_data.wg_port, }, request_id, reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V5 => { v5::response::AuthenticatorResponse::new_pending_registration_success( v5::registration::RegistrationData { nonce: registration_data.nonce, - gateway_data: registration_data.gateway_data, + gateway_data: registration_data.gateway_data.into(), wg_port: registration_data.wg_port, }, request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? + } + AuthenticatorVersion::V6 => { + v6::response::AuthenticatorResponse::new_pending_registration_success( + v6::registration::RegistrationData { + nonce: registration_data.nonce, + gateway_data: registration_data.gateway_data, + wg_port: registration_data.wg_port, + }, + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::UNKNOWN => return Err(AuthenticatorError::UnknownVersion), }; @@ -468,6 +509,29 @@ impl MixnetListener { Ok((bytes, reply_to)) } + async fn handle_final_credential_claim( + &self, + claim: BandwidthClaim, + client_id: i64, + ) -> Result<(), AuthenticatorError> { + match claim.credential { + BandwidthCredential::ZkNym(zk_nym) => { + // if we got zk-nym, we just try to verify it + credential_verification(self.ecash_verifier.clone(), *zk_nym, client_id).await?; + Ok(()) + } + BandwidthCredential::UpgradeModeJWT { token } => { + // if we're already in the upgrade mode, don't bother validating the token + if self.upgrade_mode_enabled() { + return Ok(()); + } + + self.upgrade_mode.try_enable_via_received_jwt(token).await?; + Ok(()) + } + } + } + async fn on_final_request( &mut self, final_message: Box, @@ -475,8 +539,8 @@ impl MixnetListener { request_id: u64, reply_to: Option, ) -> AuthenticatorHandleResult { - let mut registred_and_free = self.registred_and_free.write().await; - let registration_data = registred_and_free + let mut registered_and_free = self.registered_and_free.write().await; + let registration_data = registered_and_free .registration_in_progres .get(&final_message.gateway_client_pub_key()) .ok_or(AuthenticatorError::RegistrationNotInProgress)? @@ -497,28 +561,31 @@ impl MixnetListener { 128, )); + // ideally credential wouldn't have been required in upgrade mode, + // however, we need some basic information to insert valid wg peer let Some(credential) = final_message.credential() else { return Err(AuthenticatorError::NoCredentialReceived); }; + + let typ = credential.kind; + let client_id = self .ecash_verifier .storage() - .insert_wireguard_peer( - &peer, - TicketType::try_from_encoded(credential.payment.t_type) - .map_err(|_| AuthenticatorError::InvalidCredentialType)? - .into(), - ) + .insert_wireguard_peer(&peer, typ.into()) .await?; - if let Err(e) = - credential_verification(self.ecash_verifier.clone(), credential, client_id).await + + if let Err(err) = self + .handle_final_credential_claim(credential, client_id) + .await { self.ecash_verifier .storage() .remove_wireguard_peer(&peer.public_key.to_string()) .await?; - return Err(e); + return Err(err); } + let public_key = peer.public_key.to_string(); if let Err(e) = self.peer_manager.add_peer(peer).await { self.ecash_verifier @@ -528,13 +595,13 @@ impl MixnetListener { return Err(e); } - registred_and_free + registered_and_free .registration_in_progres .remove(&final_message.gateway_client_pub_key()); let bytes = match AuthenticatorVersion::from(protocol) { AuthenticatorVersion::V1 => v1::response::AuthenticatorResponse::new_registered( - v1::registration::RegistredData { + v1::registration::RegisteredData { pub_key: registration_data.gateway_data.pub_key, private_ip: registration_data.gateway_data.private_ips.ipv4.into(), wg_port: registration_data.wg_port, @@ -543,9 +610,9 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V2 => v2::response::AuthenticatorResponse::new_registered( - v2::registration::RegistredData { + v2::registration::RegisteredData { pub_key: registration_data.gateway_data.pub_key, private_ip: registration_data.gateway_data.private_ips.ipv4.into(), wg_port: registration_data.wg_port, @@ -554,9 +621,9 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V3 => v3::response::AuthenticatorResponse::new_registered( - v3::registration::RegistredData { + v3::registration::RegisteredData { pub_key: registration_data.gateway_data.pub_key, private_ip: registration_data.gateway_data.private_ips.ipv4.into(), wg_port: registration_data.wg_port, @@ -565,28 +632,43 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V4 => v4::response::AuthenticatorResponse::new_registered( - v4::registration::RegistredData { + v4::registration::RegisteredData { + pub_key: registration_data.gateway_data.pub_key, + // convert current to v5 and then v5 to v4 (current as of 28.08.25) + private_ips: v5::registration::IpPair::from( + registration_data.gateway_data.private_ips, + ) + .into(), + wg_port: registration_data.wg_port, + }, + reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, + request_id, + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)?, + AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::new_registered( + v5::registration::RegisteredData { pub_key: registration_data.gateway_data.pub_key, private_ips: registration_data.gateway_data.private_ips.into(), wg_port: registration_data.wg_port, }, - reply_to.ok_or(AuthenticatorError::MissingReplyToForOldClient)?, request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, - AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::new_registered( - v5::registration::RegistredData { + .map_err(AuthenticatorError::response_serialisation)?, + AuthenticatorVersion::V6 => v6::response::AuthenticatorResponse::new_registered( + v6::registration::RegisteredData { pub_key: registration_data.gateway_data.pub_key, private_ips: registration_data.gateway_data.private_ips, wg_port: registration_data.wg_port, }, request_id, + self.upgrade_mode_enabled(), ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::UNKNOWN => return Err(AuthenticatorError::UnknownVersion), }; Ok((bytes, reply_to)) @@ -599,7 +681,12 @@ impl MixnetListener { request_id: u64, reply_to: Option, ) -> AuthenticatorHandleResult { - let available_bandwidth = self.peer_manager.query_bandwidth(msg.pub_key()).await?; + let available_bandwidth = if self.upgrade_mode_enabled() { + self.upgrade_mode_bandwidth(msg.pub_key()).await? + } else { + self.peer_manager.query_bandwidth(msg.pub_key()).await? + }; + let bytes = match AuthenticatorVersion::from(protocol) { AuthenticatorVersion::V1 => { v1::response::AuthenticatorResponse::new_remaining_bandwidth( @@ -611,9 +698,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V2 => { v2::response::AuthenticatorResponse::new_remaining_bandwidth( @@ -624,9 +709,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V3 => { v3::response::AuthenticatorResponse::new_remaining_bandwidth( @@ -637,9 +720,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V4 => { v4::response::AuthenticatorResponse::new_remaining_bandwidth( @@ -650,9 +731,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::V5 => { v5::response::AuthenticatorResponse::new_remaining_bandwidth( @@ -662,15 +741,25 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| { - AuthenticatorError::FailedToSerializeResponsePacket { source: err } - })? + .map_err(AuthenticatorError::response_serialisation)? + } + AuthenticatorVersion::V6 => { + v6::response::AuthenticatorResponse::new_remaining_bandwidth( + Some(v6::registration::RemainingBandwidthData { + available_bandwidth, + }), + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)? } AuthenticatorVersion::UNKNOWN => return Err(AuthenticatorError::UnknownVersion), }; Ok((bytes, reply_to)) } + // if we received a topup request, don't do anything with the upgrade mode async fn on_topup_bandwidth_request( &mut self, msg: Box, @@ -693,6 +782,15 @@ impl MixnetListener { }; let bytes = match AuthenticatorVersion::from(protocol) { + AuthenticatorVersion::V6 => v6::response::AuthenticatorResponse::new_topup_bandwidth( + v6::registration::RemainingBandwidthData { + available_bandwidth, + }, + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::new_topup_bandwidth( v5::registration::RemainingBandwidthData { available_bandwidth, @@ -700,7 +798,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V4 => v4::response::AuthenticatorResponse::new_topup_bandwidth( v4::registration::RemainingBandwidthData { available_bandwidth, @@ -709,7 +807,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V3 => v3::response::AuthenticatorResponse::new_topup_bandwidth( v3::registration::RemainingBandwidthData { available_bandwidth, @@ -718,7 +816,7 @@ impl MixnetListener { request_id, ) .to_bytes() - .map_err(|err| AuthenticatorError::FailedToSerializeResponsePacket { source: err })?, + .map_err(AuthenticatorError::response_serialisation)?, AuthenticatorVersion::V1 | AuthenticatorVersion::V2 | AuthenticatorVersion::UNKNOWN => { return Err(AuthenticatorError::UnknownVersion) } @@ -727,6 +825,46 @@ impl MixnetListener { Ok((bytes, reply_to)) } + async fn on_upgrade_mode_check( + &mut self, + msg: Box, + protocol: Protocol, + request_id: u64, + ) -> AuthenticatorHandleResult { + // if upgrade mode is already enabled, we don't need to perform any additional checks + if !self.upgrade_mode_enabled() { + // currently upgrade mode JWT is the only type of emergency credentials supported + if let Some(upgrade_mode_jwt) = msg.upgrade_mode_global_attestation_jwt() { + self.upgrade_mode + .try_enable_via_received_jwt(upgrade_mode_jwt) + .await?; + } + } + + let bytes = match AuthenticatorVersion::from(protocol) { + AuthenticatorVersion::UNKNOWN + | AuthenticatorVersion::V1 + | AuthenticatorVersion::V2 + | AuthenticatorVersion::V3 + | AuthenticatorVersion::V4 + | AuthenticatorVersion::V5 => { + // pre v6 this message hasn't existed + return Err(AuthenticatorError::UnknownVersion); + } + AuthenticatorVersion::V6 => { + v6::response::AuthenticatorResponse::new_upgrade_mode_check( + request_id, + self.upgrade_mode_enabled(), + ) + .to_bytes() + .map_err(AuthenticatorError::response_serialisation)? + } + }; + + // no need to support reply_to, as this is never set in v6 and older versions do not include this message + Ok((bytes, None)) + } + fn received_retry(&self, msg: &(dyn TopUpMessage + Send + Sync + 'static)) -> bool { if let Some(peer_pub_key) = self .seen_credential_cache @@ -787,6 +925,11 @@ impl MixnetListener { self.on_topup_bandwidth_request(msg, protocol, request_id, reply_to) .await } + AuthenticatorRequest::CheckUpgradeMode { + msg, + protocol, + request_id, + } => self.on_upgrade_mode_check(msg, protocol, request_id).await, } } @@ -893,64 +1036,68 @@ async fn credential_verification( fn deserialize_request( reconstructed: &ReconstructedMessage, ) -> Result { - let request_version = *reconstructed + let header = reconstructed .message .first_chunk::<2>() .ok_or(AuthenticatorError::ShortPacket)?; - // Check version of the request and convert to the latest version if necessary - match request_version { - [1, _] => v1::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + let version = header[0]; + + // special case for v1 request where service provider information hasn't been exposed in the header + if version == v1::VERSION { + return v1::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) - .map(Into::into), - [2, request_type] => { - if request_type == ServiceProviderType::Authenticator as u8 { - v2::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) - .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { - source: err, - }) - .map(Into::::into) - .map(Into::into) - } else { - Err(AuthenticatorError::InvalidPacketType(request_type)) - } + .map(Into::into); + } + + let protocol = Protocol::try_from(header)?; + + if !protocol.service_provider_type.is_authenticator() { + return Err(AuthenticatorError::InvalidPacketType( + protocol.service_provider_type as u8, + )); + } + + let version = AuthenticatorVersion::from(protocol.version); + + // Check version of the request and convert to the latest version if necessary + match version { + AuthenticatorVersion::V1 => { + // this branch should be unreachable as v1 has already been handled independently + Err(AuthenticatorError::UnknownVersion) } - [3, request_type] => { - if request_type == ServiceProviderType::Authenticator as u8 { - v3::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) - .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { - source: err, - }) - .map(Into::into) - } else { - Err(AuthenticatorError::InvalidPacketType(request_type)) - } + AuthenticatorVersion::V2 => { + v2::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) + .map(Into::::into) + .map(Into::into) } - [4, request_type] => { - if request_type == ServiceProviderType::Authenticator as u8 { - v4::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) - .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { - source: err, - }) - .map(Into::into) - } else { - Err(AuthenticatorError::InvalidPacketType(request_type)) - } + AuthenticatorVersion::V3 => { + v3::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) + .map(Into::into) } - [5, request_type] => { - if request_type == ServiceProviderType::Authenticator as u8 { - v5::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) - .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { - source: err, - }) - .map(Into::into) - } else { - Err(AuthenticatorError::InvalidPacketType(request_type)) - } + AuthenticatorVersion::V4 => { + v4::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) + .map(Into::into) } - [version, _] => { - tracing::info!("Received packet with invalid version: v{version}"); - Err(AuthenticatorError::InvalidPacketVersion(version)) + AuthenticatorVersion::V5 => { + v5::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) + .map(Into::into) + } + AuthenticatorVersion::V6 => { + v6::request::AuthenticatorRequest::from_reconstructed_message(reconstructed) + .map_err(|err| AuthenticatorError::FailedToDeserializeTaggedPacket { source: err }) + .map(Into::into) + } + AuthenticatorVersion::UNKNOWN => { + tracing::info!( + "Received packet with invalid version: v{}", + protocol.version + ); + Err(AuthenticatorError::InvalidPacketVersion(protocol.version)) } } } diff --git a/gateway/src/node/internal_service_providers/authenticator/mod.rs b/gateway/src/node/internal_service_providers/authenticator/mod.rs index 358bc5cb30..f63a86fcc2 100644 --- a/gateway/src/node/internal_service_providers/authenticator/mod.rs +++ b/gateway/src/node/internal_service_providers/authenticator/mod.rs @@ -11,6 +11,9 @@ use nym_task::ShutdownTracker; use nym_wireguard::WireguardGatewayData; use std::{net::IpAddr, path::Path, sync::Arc, time::SystemTime}; +pub use config::Config; +use nym_credential_verification::upgrade_mode::UpgradeModeDetails; + pub mod config; pub mod error; pub mod mixnet_client; @@ -18,8 +21,6 @@ pub mod mixnet_listener; mod peer_manager; mod seen_credential_cache; -pub use config::Config; - pub struct OnStartData { // to add more fields as required pub address: Recipient, @@ -33,7 +34,8 @@ impl OnStartData { pub struct Authenticator { #[allow(unused)] - config: crate::node::internal_service_providers::authenticator::Config, + config: Config, + upgrade_mode_state: UpgradeModeDetails, wait_for_gateway: bool, custom_topology_provider: Option>, custom_gateway_transceiver: Option>, @@ -46,7 +48,8 @@ pub struct Authenticator { impl Authenticator { pub fn new( - config: crate::node::internal_service_providers::authenticator::Config, + config: Config, + upgrade_mode_state: UpgradeModeDetails, wireguard_gateway_data: WireguardGatewayData, used_private_network_ips: Vec, ecash_verifier: Arc, @@ -54,6 +57,7 @@ impl Authenticator { ) -> Self { Self { config, + upgrade_mode_state, wait_for_gateway: false, custom_topology_provider: None, custom_gateway_transceiver: None, @@ -152,6 +156,7 @@ impl Authenticator { free_private_network_ips, self.wireguard_gateway_data, mixnet_client, + self.upgrade_mode_state, self.ecash_verifier, ); diff --git a/gateway/src/node/internal_service_providers/authenticator/peer_manager.rs b/gateway/src/node/internal_service_providers/authenticator/peer_manager.rs index fce8cf7174..c057dfa57c 100644 --- a/gateway/src/node/internal_service_providers/authenticator/peer_manager.rs +++ b/gateway/src/node/internal_service_providers/authenticator/peer_manager.rs @@ -19,7 +19,7 @@ impl PeerManager { wireguard_gateway_data, } } - pub async fn add_peer(&mut self, peer: Peer) -> Result<(), AuthenticatorError> { + pub async fn add_peer(&self, peer: Peer) -> Result<(), AuthenticatorError> { let (response_tx, response_rx) = oneshot::channel(); let msg = PeerControlRequest::AddPeer { peer, response_tx }; self.wireguard_gateway_data @@ -38,7 +38,7 @@ impl PeerManager { }) } - pub async fn _remove_peer(&mut self, pub_key: PeerPublicKey) -> Result<(), AuthenticatorError> { + pub async fn _remove_peer(&self, pub_key: PeerPublicKey) -> Result<(), AuthenticatorError> { let key = Key::new(pub_key.to_bytes()); let (response_tx, response_rx) = oneshot::channel(); let msg = PeerControlRequest::RemovePeer { key, response_tx }; @@ -61,7 +61,7 @@ impl PeerManager { } pub async fn query_peer( - &mut self, + &self, public_key: PeerPublicKey, ) -> Result, AuthenticatorError> { let key = Key::new(public_key.to_bytes()); @@ -86,7 +86,7 @@ impl PeerManager { } pub async fn query_bandwidth( - &mut self, + &self, public_key: PeerPublicKey, ) -> Result { let client_bandwidth = self.query_client_bandwidth(public_key).await?; @@ -94,7 +94,7 @@ impl PeerManager { } pub async fn query_client_bandwidth( - &mut self, + &self, key: PeerPublicKey, ) -> Result { let key = Key::new(key.to_bytes()); @@ -121,7 +121,7 @@ impl PeerManager { } pub async fn query_verifier_by_key( - &mut self, + &self, key: PeerPublicKey, credential: CredentialSpendingData, ) -> Result, AuthenticatorError> { @@ -243,7 +243,7 @@ mod tests { Authenticator::default().into(), Arc::new(KeyPair::new(&mut OsRng)), ); - let mut peer_manager = PeerManager::new(wireguard_data); + let peer_manager = PeerManager::new(wireguard_data); let (storage, task_manager) = start_controller( peer_manager.wireguard_gateway_data.peer_tx().clone(), request_rx, diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index e4542b0dd6..7f08a5e52c 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -1,8 +1,10 @@ // Copyright 2020-2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::config::Config; use crate::error::GatewayError; use crate::node::client_handling::websocket; +use crate::node::internal_service_providers::authenticator::Authenticator; use crate::node::internal_service_providers::{ authenticator, ExitServiceProviders, ServiceProviderBeingBuilt, SpMessageRouterBuilder, }; @@ -11,6 +13,9 @@ use futures::channel::oneshot; use nym_credential_verification::ecash::{ credential_sender::CredentialHandlerConfig, EcashManager, }; +use nym_credential_verification::upgrade_mode::{ + UpgradeModeCheckConfig, UpgradeModeDetails, UpgradeModeState, +}; use nym_crypto::asymmetric::ed25519; use nym_ip_packet_router::IpPacketRouter; use nym_mixnet_client::forwarder::MixForwardingSender; @@ -30,13 +35,9 @@ use std::sync::Arc; use tracing::*; use zeroize::Zeroizing; -pub(crate) mod client_handling; -pub(crate) mod internal_service_providers; -mod stale_data_cleaner; - -use crate::config::Config; -use crate::node::internal_service_providers::authenticator::Authenticator; +pub use crate::node::upgrade_mode::watcher::UpgradeModeWatcher; pub use client_handling::active_clients::ActiveClientsStore; +pub use nym_credential_verification::upgrade_mode::UpgradeModeCheckRequestSender; pub use nym_gateway_stats_storage::PersistentStatsStorage; pub use nym_gateway_storage::{ error::GatewayStorageError, @@ -45,6 +46,11 @@ pub use nym_gateway_storage::{ }; pub use nym_sdk::{NymApiTopologyProvider, NymApiTopologyProviderConfig, UserAgent}; +pub(crate) mod client_handling; +pub(crate) mod internal_service_providers; +mod stale_data_cleaner; +pub(crate) mod upgrade_mode; + #[derive(Debug, Clone)] pub struct LocalNetworkRequesterOpts { pub config: nym_network_requester::Config, @@ -78,6 +84,8 @@ pub struct GatewayTasksBuilder { // TODO: combine with authenticator, since you have to start both wireguard_data: Option, + user_agent: UserAgent, + /// ed25519 keypair used to assert one's identity. identity_keypair: Arc, @@ -89,6 +97,8 @@ pub struct GatewayTasksBuilder { metrics: NymNodeMetrics, + upgrade_mode_state: UpgradeModeState, + mnemonic: Arc>, shutdown_tracker: ShutdownTracker, @@ -111,6 +121,8 @@ impl GatewayTasksBuilder { metrics_sender: MetricEventsSender, metrics: NymNodeMetrics, mnemonic: Arc>, + user_agent: UserAgent, + upgrade_mode_attester_public_key: ed25519::PublicKey, shutdown_tracker: ShutdownTracker, ) -> GatewayTasksBuilder { GatewayTasksBuilder { @@ -119,11 +131,13 @@ impl GatewayTasksBuilder { ip_packet_router_opts: None, authenticator_opts: None, wireguard_data: None, + user_agent, identity_keypair: identity, storage, mix_packet_sender, metrics_sender, metrics, + upgrade_mode_state: UpgradeModeState::new(upgrade_mode_attester_public_key), mnemonic, shutdown_tracker, ecash_manager: None, @@ -247,6 +261,7 @@ impl GatewayTasksBuilder { pub async fn build_websocket_listener( &mut self, active_clients_store: ActiveClientsStore, + upgrade_mode_common_state: UpgradeModeDetails, ) -> Result { let shared_state = websocket::CommonHandlerState { cfg: websocket::Config { @@ -261,6 +276,7 @@ impl GatewayTasksBuilder { metrics_sender: self.metrics_sender.clone(), outbound_mix_sender: self.mix_packet_sender.clone(), active_clients_store: active_clients_store.clone(), + upgrade_mode: upgrade_mode_common_state, }; Ok(websocket::Listener::new( @@ -407,6 +423,7 @@ impl GatewayTasksBuilder { pub async fn build_wireguard_authenticator( &mut self, + upgrade_mode_common: UpgradeModeDetails, topology_provider: Box, ) -> Result, GatewayError> { let ecash_manager = self.ecash_manager().await?; @@ -431,6 +448,7 @@ impl GatewayTasksBuilder { let mut authenticator_server = Authenticator::new( opts.config.clone(), + upgrade_mode_common, wireguard_data.inner.clone(), used_private_network_ips, ecash_manager, @@ -462,9 +480,47 @@ impl GatewayTasksBuilder { ) } + pub fn build_upgrade_mode_common_state( + &self, + request_checker: UpgradeModeCheckRequestSender, + ) -> UpgradeModeDetails { + UpgradeModeDetails::new( + UpgradeModeCheckConfig { + min_staleness_recheck: self.config.debug.upgrade_mode_min_staleness_recheck, + }, + request_checker, + self.upgrade_mode_state.clone(), + ) + } + + pub fn try_build_upgrade_mode_watcher(&self) -> Option { + if !self.config.upgrade_mode_watcher.enabled { + warn!("upgrade mode watcher is disabled"); + return None; + } + + Some(UpgradeModeWatcher::new( + self.config + .upgrade_mode_watcher + .debug + .regular_polling_interval, + self.config + .upgrade_mode_watcher + .debug + .expedited_poll_interval, + self.config.debug.upgrade_mode_min_staleness_recheck, + self.config.upgrade_mode_watcher.attestation_url.clone(), + self.upgrade_mode_state.clone(), + self.user_agent.clone(), + self.shutdown_tracker.clone_shutdown_token(), + )) + } + #[cfg(not(target_os = "linux"))] + #[allow(clippy::unimplemented)] pub async fn try_start_wireguard( &mut self, + _upgrade_mode_details: UpgradeModeDetails, ) -> Result, Box> { let _ = self.metrics.clone(); let _ = self.shutdown_tracker.clone(); @@ -474,6 +530,7 @@ impl GatewayTasksBuilder { #[cfg(target_os = "linux")] pub async fn try_start_wireguard( &mut self, + upgrade_mode_details: UpgradeModeDetails, ) -> Result< nym_wireguard_private_metadata_server::ShutdownHandles, Box, @@ -497,6 +554,7 @@ impl GatewayTasksBuilder { nym_wireguard_private_metadata_server::PeerControllerTransceiver::new( wireguard_data.inner.peer_tx().clone(), ), + upgrade_mode_details, )); let bind_address = std::net::SocketAddr::new( @@ -508,6 +566,7 @@ impl GatewayTasksBuilder { ecash_manager, self.metrics.clone(), all_peers, + self.upgrade_mode_state.upgrade_mode_status(), self.shutdown_tracker.clone_shutdown_token(), wireguard_data, ) diff --git a/gateway/src/node/upgrade_mode/mod.rs b/gateway/src/node/upgrade_mode/mod.rs new file mode 100644 index 0000000000..c8df59133b --- /dev/null +++ b/gateway/src/node/upgrade_mode/mod.rs @@ -0,0 +1,4 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +pub(crate) mod watcher; diff --git a/gateway/src/node/upgrade_mode/watcher.rs b/gateway/src/node/upgrade_mode/watcher.rs new file mode 100644 index 0000000000..c798ddc55b --- /dev/null +++ b/gateway/src/node/upgrade_mode/watcher.rs @@ -0,0 +1,146 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node::UserAgent; +use futures::channel::mpsc::unbounded; +use futures::StreamExt; +use nym_credential_verification::upgrade_mode::{ + CheckRequest, UpgradeModeCheckRequestReceiver, UpgradeModeCheckRequestSender, UpgradeModeState, +}; +use nym_task::ShutdownToken; +use nym_upgrade_mode_check::attempt_retrieve_attestation; +use std::time::Duration; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tracing::{debug, error, info, trace}; +use url::Url; + +pub struct UpgradeModeWatcher { + // default polling interval + regular_polling_interval: Duration, + + // expedited polling interval once upgrade mode is detected + expedited_poll_interval: Duration, + + min_staleness_recheck: Duration, + + attestation_url: Url, + + check_request_sender: UpgradeModeCheckRequestSender, + + check_request_receiver: UpgradeModeCheckRequestReceiver, + + upgrade_mode_state: UpgradeModeState, + + user_agent: UserAgent, + + shutdown_token: ShutdownToken, +} + +impl UpgradeModeWatcher { + pub(crate) fn new( + regular_polling_interval: Duration, + expedited_poll_interval: Duration, + min_staleness_recheck: Duration, + attestation_url: Url, + upgrade_mode_state: UpgradeModeState, + user_agent: UserAgent, + shutdown_token: ShutdownToken, + ) -> Self { + let (tx, rx) = unbounded(); + UpgradeModeWatcher { + regular_polling_interval, + expedited_poll_interval, + min_staleness_recheck, + attestation_url, + check_request_sender: UpgradeModeCheckRequestSender::new(tx), + check_request_receiver: rx, + upgrade_mode_state, + user_agent, + shutdown_token, + } + } + + pub fn request_sender(&self) -> UpgradeModeCheckRequestSender { + self.check_request_sender.clone() + } + + async fn try_update_state(&self) { + match attempt_retrieve_attestation( + self.attestation_url.as_str(), + Some(self.user_agent.clone()), + ) + .await + { + Err(err) => { + info!("upgrade mode attestation is not available at this time"); + debug!("retrieval error: {err}") + } + Ok(attestation) => { + self.upgrade_mode_state + .try_set_expected_attestation(attestation) + .await + } + } + } + + fn timer_reset_deadline(&self) -> Instant { + if self.upgrade_mode_state.upgrade_mode_enabled() { + Instant::now() + self.expedited_poll_interval + } else { + Instant::now() + self.regular_polling_interval + } + } + + async fn handle_check_request(&mut self, polled_request: CheckRequest) { + let mut requests = vec![polled_request]; + while let Ok(Some(queued_up)) = self.check_request_receiver.try_next() { + requests.push(queued_up); + } + + if self.upgrade_mode_state.since_last_query() > self.min_staleness_recheck { + self.try_update_state().await; + } + + for request in requests { + request.finalize() + } + } + + async fn run(&mut self) { + info!("starting the update mode watcher"); + + let check_wait = tokio::time::sleep(self.regular_polling_interval); + tokio::pin!(check_wait); + + loop { + tokio::select! { + biased; + _ = self.shutdown_token.cancelled() => { + trace!("UpdateModeWatcher: received shutdown"); + break; + } + polled_request = self.check_request_receiver.next() => { + let Some(request) = polled_request else { + // this should NEVER happen as `UpgradeModeWatcher` itself holds one sender instance + // but just in case, don't blow up + error!("UpgradeModeCheckRequestReceiver is finished even though we still hold one of the senders!"); + break; + }; + self.handle_check_request(request).await + } + + _ = &mut check_wait => { + self.try_update_state().await; + check_wait.as_mut().reset(self.timer_reset_deadline()); + } + } + } + + debug!("UpdateModeWatcher: Exiting"); + } + + pub fn start(mut self) -> JoinHandle<()> { + tokio::spawn(async move { self.run().await }) + } +} diff --git a/nym-authenticator-client/src/error.rs b/nym-authenticator-client/src/error.rs index 43598acdd8..142fbe6981 100644 --- a/nym-authenticator-client/src/error.rs +++ b/nym-authenticator-client/src/error.rs @@ -42,12 +42,15 @@ pub enum AuthenticationClientError { #[error("unknown authenticator version number")] UnsupportedAuthenticatorVersion, + + #[error("encountered an internal error")] + InternalError, } #[derive(thiserror::Error, Debug)] pub enum RegistrationError { #[error(transparent)] - NoCredentialSent(AuthenticationClientError), // This intentionnally doesn't use `from` to avoid random ? operator to land here when they shouldn't + NoCredentialSent(AuthenticationClientError), // This intentionally doesn't use `from` to avoid random ? operator to land here when they shouldn't #[error("an error occured after a credential was sent : {source}")] CredentialSent { diff --git a/nym-authenticator-client/src/lib.rs b/nym-authenticator-client/src/lib.rs index ab811ef79d..10b651b184 100644 --- a/nym-authenticator-client/src/lib.rs +++ b/nym-authenticator-client/src/lib.rs @@ -12,18 +12,21 @@ use tracing::{debug, error, trace}; use crate::error::Result; use crate::mixnet_listener::{MixnetMessageBroadcastReceiver, MixnetMessageInputSender}; +use crate::types::{AvailableBandwidthClientResponse, TopUpClientResponse}; +use nym_authenticator_requests::traits::UpgradeModeStatus; use nym_authenticator_requests::{ AuthenticatorVersion, client_message::ClientMessage, response::AuthenticatorResponse, - traits::Id, v2, v3, v4, v5, + traits::Id, v2, v3, v4, v5, v6, }; use nym_credentials_interface::{CredentialSpendingData, TicketType}; -use nym_sdk::mixnet::{IncludedSurbs, Recipient}; +use nym_sdk::mixnet::{IncludedSurbs, Recipient, ReconstructedMessage}; use nym_service_provider_requests_common::{Protocol, ServiceProviderTypeExt}; use nym_wireguard_types::PeerPublicKey; mod error; mod helpers; mod mixnet_listener; +pub mod types; pub use crate::error::{AuthenticationClientError, RegistrationError}; pub use crate::mixnet_listener::{AuthClientMixnetListener, AuthClientMixnetListenerHandle}; @@ -61,6 +64,10 @@ impl AuthenticatorClient { } } + fn peer_public_key(&self) -> PeerPublicKey { + PeerPublicKey::from(self.keypair.public_key().inner()) + } + pub async fn send_and_wait_for_response( &mut self, message: &ClientMessage, @@ -72,7 +79,9 @@ impl AuthenticatorClient { } async fn send_request(&self, message: &ClientMessage) -> Result { - let (data, request_id) = message.bytes(self.our_nym_address)?; + let serialised = message.bytes(self.our_nym_address)?; + let data = serialised.bytes; + let request_id = serialised.request_id; // We use 20 surbs for the connect request because typically the // authenticator mixnet client on the nym-node is configured to have a min @@ -96,6 +105,81 @@ impl AuthenticatorClient { Ok(request_id) } + fn handle_response( + &self, + msg: Arc, + request_id: u64, + ) -> Option> { + let Some(header) = msg.message.first_chunk::<2>() else { + debug!( + "received too short message that couldn't have been from the authenticator while waiting for connect response" + ); + return None; + }; + + let Ok(protocol) = Protocol::try_from(header) else { + debug!( + "received a message not meant to any service provider while waiting for connect response" + ); + return None; + }; + + if !protocol.service_provider_type.is_authenticator() { + debug!("Received non-authenticator message while waiting for connect response"); + return None; + } + // Confirm that the version is correct + let version = AuthenticatorVersion::from(protocol.version); + + // Then we deserialize the message + debug!( + "AuthClient: got message while waiting for connect response with version {version:?}" + ); + let ret: Result = match version { + AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => { + return Some(Err( + AuthenticationClientError::UnsupportedAuthenticatorVersion, + )); + } + AuthenticatorVersion::V2 => { + v2::response::AuthenticatorResponse::from_reconstructed_message(&msg) + .map(Into::into) + .map_err(Into::into) + } + AuthenticatorVersion::V3 => { + v3::response::AuthenticatorResponse::from_reconstructed_message(&msg) + .map(Into::into) + .map_err(Into::into) + } + AuthenticatorVersion::V4 => { + v4::response::AuthenticatorResponse::from_reconstructed_message(&msg) + .map(Into::into) + .map_err(Into::into) + } + AuthenticatorVersion::V5 => { + v5::response::AuthenticatorResponse::from_reconstructed_message(&msg) + .map(Into::into) + .map_err(Into::into) + } + AuthenticatorVersion::V6 => { + v6::response::AuthenticatorResponse::from_reconstructed_message(&msg) + .map(Into::into) + .map_err(Into::into) + } + }; + let Ok(response) = ret else { + // This is ok, it's likely just one of our self-pings + debug!("Failed to deserialize reconstructed message"); + return None; + }; + + if response.id() == request_id { + debug!("Got response with matching id"); + return Some(Ok(response)); + } + None + } + async fn listen_for_response(&mut self, request_id: u64) -> Result { let timeout = tokio::time::sleep(Duration::from_secs(10)); tokio::pin!(timeout); @@ -111,42 +195,9 @@ impl AuthenticatorClient { return Err(AuthenticationClientError::NoMixnetMessagesReceived); } Ok(msg) => { - let Some(header) = msg.message.first_chunk::<2>() else { - debug!("received too short message that couldn't have been from the authenticator while waiting for connect response"); - continue; - }; - - let Ok(protocol) = Protocol::try_from(header) else { - debug!("received a message not meant to any service provider while waiting for connect response"); - continue; - }; - - if !protocol.service_provider_type.is_authenticator() { - debug!("Received non-authenticator message while waiting for connect response"); - continue; - } - // Confirm that the version is correct - let version = AuthenticatorVersion::from(protocol.version); - - // Then we deserialize the message - debug!("AuthClient: got message while waiting for connect response with version {version:?}"); - let ret: Result = match version { - AuthenticatorVersion::V1 => Err(AuthenticationClientError::UnsupportedVersion), - AuthenticatorVersion::V2 => v2::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into), - AuthenticatorVersion::V3 => v3::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into), - AuthenticatorVersion::V4 => v4::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into), - AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into), - AuthenticatorVersion::UNKNOWN => Err(AuthenticationClientError::UnknownVersion), - }; - let Ok(response) = ret else { - // This is ok, it's likely just one of our self-pings - debug!("Failed to deserialize reconstructed message"); - continue; - }; - - if response.id() == request_id { - debug!("Got response with matching id"); - return Ok(response); + match self.handle_response(msg, request_id) { + None => continue, + Some(res) => return res, } } } @@ -160,6 +211,8 @@ impl AuthenticatorClient { ticketbook_type: TicketType, ) -> std::result::Result { debug!("Registering with the wg gateway..."); + let pub_key = self.peer_public_key(); + let init_message = match self.auth_version { AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => { return Err(RegistrationError::NoCredentialSent( @@ -167,24 +220,19 @@ impl AuthenticatorClient { )); } AuthenticatorVersion::V2 => { - ClientMessage::Initial(Box::new(v2::registration::InitMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - })) + ClientMessage::Initial(Box::new(v2::registration::InitMessage { pub_key })) } AuthenticatorVersion::V3 => { - ClientMessage::Initial(Box::new(v3::registration::InitMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - })) + ClientMessage::Initial(Box::new(v3::registration::InitMessage { pub_key })) } AuthenticatorVersion::V4 => { - ClientMessage::Initial(Box::new(v4::registration::InitMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - })) + ClientMessage::Initial(Box::new(v4::registration::InitMessage { pub_key })) } AuthenticatorVersion::V5 => { - ClientMessage::Initial(Box::new(v5::registration::InitMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - })) + ClientMessage::Initial(Box::new(v5::registration::InitMessage { pub_key })) + } + AuthenticatorVersion::V6 => { + ClientMessage::Initial(Box::new(v6::registration::InitMessage { pub_key })) } }; trace!("sending init msg to {}: {:?}", &self.ip_addr, &init_message); @@ -224,65 +272,22 @@ impl AuthenticatorClient { })? .data, ); + let credential = credential + .map(TryInto::try_into) + .transpose() + .inspect_err(|err| error!("failed to convert {ticketbook_type} ticket to a valid BandwidthClaim: {err}")) + .map_err(|_| RegistrationError::CredentialSent { + source: AuthenticationClientError::InternalError, + })?; - let finalized_message = match self.auth_version { - AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => { - return Err(RegistrationError::CredentialSent { - source: AuthenticationClientError::UnsupportedAuthenticatorVersion, - }); - } - AuthenticatorVersion::V2 => { - ClientMessage::Final(Box::new(v2::registration::FinalMessage { - gateway_client: v2::registration::GatewayClient::new( - self.keypair.private_key(), - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().ipv4.into(), - pending_registration_response.nonce(), - ), - credential, - })) - } - AuthenticatorVersion::V3 => { - ClientMessage::Final(Box::new(v3::registration::FinalMessage { - gateway_client: v3::registration::GatewayClient::new( - self.keypair.private_key(), - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().ipv4.into(), - pending_registration_response.nonce(), - ), - credential, - })) - } - AuthenticatorVersion::V4 => { - ClientMessage::Final(Box::new(v4::registration::FinalMessage { - gateway_client: v4::registration::GatewayClient::new( - self.keypair.private_key(), - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().into(), - pending_registration_response.nonce(), - ), - credential, - })) - } - AuthenticatorVersion::V5 => { - ClientMessage::Final(Box::new(v5::registration::FinalMessage { - gateway_client: v5::registration::GatewayClient::new( - self.keypair.private_key(), - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips(), - pending_registration_response.nonce(), - ), - credential, - })) - } - }; - trace!( - "sending final msg to {}: {:?}", - &self.ip_addr, &finalized_message - ); + let finalized_message = pending_registration_response + .finalise_registration(self.keypair.private_key(), credential); + let client_message = ClientMessage::Final(finalized_message); + + trace!("sending final msg to {}: {client_message:?}", &self.ip_addr); let response = self - .send_and_wait_for_response(&finalized_message) + .send_and_wait_for_response(&client_message) .await .map_err(|source| RegistrationError::CredentialSent { source })?; let AuthenticatorResponse::Registered(registered_response) = response else { @@ -316,32 +321,24 @@ impl AuthenticatorClient { } // This is up to the caller to know nothing is ever spent there - pub async fn query_bandwidth(&mut self) -> Result> { + pub async fn query_bandwidth(&mut self) -> Result { + let pub_key = self.peer_public_key(); + let version = self.auth_version; + let query_message = match self.auth_version { - AuthenticatorVersion::V1 => { + AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => { return Err(AuthenticationClientError::UnsupportedAuthenticatorVersion); } - AuthenticatorVersion::V2 => ClientMessage::Query(Box::new(QueryMessageImpl { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - version: AuthenticatorVersion::V2, - })), - AuthenticatorVersion::V3 => ClientMessage::Query(Box::new(QueryMessageImpl { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - version: AuthenticatorVersion::V3, - })), - AuthenticatorVersion::V4 => ClientMessage::Query(Box::new(QueryMessageImpl { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - version: AuthenticatorVersion::V4, - })), - AuthenticatorVersion::V5 => ClientMessage::Query(Box::new(QueryMessageImpl { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), - version: AuthenticatorVersion::V5, - })), - AuthenticatorVersion::UNKNOWN => { - return Err(AuthenticationClientError::UnsupportedAuthenticatorVersion); + AuthenticatorVersion::V2 + | AuthenticatorVersion::V3 + | AuthenticatorVersion::V4 + | AuthenticatorVersion::V5 + | AuthenticatorVersion::V6 => { + ClientMessage::Query(Box::new(QueryMessageImpl { pub_key, version })) } }; let response = self.send_and_wait_for_response(&query_message).await?; + let current_upgrade_mode_status = response.upgrade_mode_status(); let available_bandwidth = match response { AuthenticatorResponse::RemainingBandwidth(remaining_bandwidth_response) => { @@ -350,7 +347,10 @@ impl AuthenticatorClient { { available_bandwidth } else { - return Ok(None); + return Ok(AvailableBandwidthClientResponse { + available_bandwidth_bytes: None, + current_upgrade_mode_status, + }); } } _ => return Err(AuthenticationClientError::InvalidGatewayAuthResponse), @@ -371,24 +371,35 @@ impl AuthenticatorClient { "Remaining bandwidth is under 1 MB. The wireguard mode will get suspended after that until tomorrow, UTC time. The client might shutdown with timeout soon" ); } - Ok(Some(available_bandwidth)) + Ok(AvailableBandwidthClientResponse { + available_bandwidth_bytes: Some(available_bandwidth), + current_upgrade_mode_status, + }) } // Since the caller provides the credential, it knows it is spent - pub async fn top_up(&mut self, credential: CredentialSpendingData) -> Result { + pub async fn top_up( + &mut self, + credential: CredentialSpendingData, + ) -> Result { + let pub_key = self.peer_public_key(); let top_up_message = match self.auth_version { AuthenticatorVersion::V3 => ClientMessage::TopUp(Box::new(v3::topup::TopUpMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), + pub_key, credential, })), // NOTE: looks like a bug here using v3. But we're leaving it as is since it's working // and V4 is deprecated in favour of V5 AuthenticatorVersion::V4 => ClientMessage::TopUp(Box::new(v4::topup::TopUpMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), + pub_key, credential, })), AuthenticatorVersion::V5 => ClientMessage::TopUp(Box::new(v5::topup::TopUpMessage { - pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()), + pub_key, + credential, + })), + AuthenticatorVersion::V6 => ClientMessage::TopUp(Box::new(v6::topup::TopUpMessage { + pub_key, credential, })), AuthenticatorVersion::V1 | AuthenticatorVersion::V2 | AuthenticatorVersion::UNKNOWN => { @@ -396,14 +407,18 @@ impl AuthenticatorClient { } }; let response = self.send_and_wait_for_response(&top_up_message).await?; + let current_upgrade_mode_status = response.upgrade_mode_status(); - let remaining_bandwidth = match response { + let remaining_bandwidth_bytes = match response { AuthenticatorResponse::TopUpBandwidth(top_up_bandwidth_response) => { top_up_bandwidth_response.available_bandwidth() } _ => return Err(AuthenticationClientError::InvalidGatewayAuthResponse), }; - Ok(remaining_bandwidth) + Ok(TopUpClientResponse { + remaining_bandwidth_bytes, + current_upgrade_mode_status, + }) } } diff --git a/nym-authenticator-client/src/types.rs b/nym-authenticator-client/src/types.rs new file mode 100644 index 0000000000..6ed3188fd0 --- /dev/null +++ b/nym-authenticator-client/src/types.rs @@ -0,0 +1,16 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub use nym_authenticator_requests::models::CurrentUpgradeModeStatus; + +#[derive(Debug, Clone, Copy)] +pub struct TopUpClientResponse { + pub remaining_bandwidth_bytes: i64, + pub current_upgrade_mode_status: CurrentUpgradeModeStatus, +} + +#[derive(Debug, Clone, Copy)] +pub struct AvailableBandwidthClientResponse { + pub available_bandwidth_bytes: Option, + pub current_upgrade_mode_status: CurrentUpgradeModeStatus, +} diff --git a/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs b/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs index 6ff65c9fda..54ba8126b5 100644 --- a/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs +++ b/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs @@ -5,8 +5,6 @@ pub mod api; pub mod client; mod helpers; -pub const CREDENTIAL_PROXY_JWT_ISSUER: &str = "nym-credential-proxy"; - macro_rules! absolute_route { ( $name:ident, $parent:expr, $suffix:expr ) => { pub fn $name() -> String { diff --git a/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs index e0ff97ac05..b544f80792 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs @@ -21,6 +21,8 @@ pub struct AttestationWatcher { attestation_url: Url, + expected_attester_public_key: ed25519::PublicKey, + jwt_signing_keys: ed25519::KeyPair, jwt_validity: Duration, @@ -32,6 +34,7 @@ impl AttestationWatcher { pub(crate) fn new( regular_polling_interval: Duration, expedited_poll_interval: Duration, + expected_attester_public_key: ed25519::PublicKey, attestation_url: Url, jwt_signing_keys: ed25519::KeyPair, jwt_validity: Duration, @@ -40,6 +43,7 @@ impl AttestationWatcher { regular_polling_interval, expedited_poll_interval, attestation_url, + expected_attester_public_key, jwt_signing_keys, jwt_validity, upgrade_mode_state: UpgradeModeState { @@ -65,7 +69,12 @@ impl AttestationWatcher { } Ok(attestation) => { self.upgrade_mode_state - .update(attestation, &self.jwt_signing_keys, self.jwt_validity) + .update( + attestation, + self.expected_attester_public_key, + &self.jwt_signing_keys, + self.jwt_validity, + ) .await } } @@ -74,7 +83,7 @@ impl AttestationWatcher { pub async fn run_forever(self, cancellation_token: CancellationToken) { info!("starting the attestation watcher task"); - let check_wait = tokio::time::sleep(self.regular_polling_interval); + let check_wait = tokio::time::sleep(Duration::new(0, 0)); tokio::pin!(check_wait); loop { diff --git a/nym-credential-proxy/nym-credential-proxy/src/cli.rs b/nym-credential-proxy/nym-credential-proxy/src/cli.rs index d5c27a7730..5ab32daf32 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/cli.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/cli.rs @@ -159,6 +159,10 @@ pub struct UpgradeModeConfig { #[clap(long, env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL")] pub(crate) attestation_check_url: Option, + /// Base58-encoded expected upgrade mode attestation ed25519 public key. + #[clap(long, env = "NYM_CREDENTIAL_PROXY_ATTESTER_PUBKEY")] + pub(crate) attester_pubkey: Option, + /// Default polling interval of the upgrade mode endpoint. #[clap( long, diff --git a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs index cd061d8962..7044112d79 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs @@ -79,6 +79,29 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { } }; + let attester_pubkey = match cli.upgrade_mode.attester_pubkey { + Some(pubkey) => pubkey, + None => { + // argument hasn't been provided and env is not configured + if std::env::var(CONFIGURED).is_err() { + return Err(CredentialProxyError::AttesterPublicKeyNotSet); + } + // argument hasn't been provided and the relevant env value hasn't been set + // (technically this shouldn't be possible) + let Ok(env_key) = std::env::var(var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY) + else { + return Err(CredentialProxyError::AttesterPublicKeyNotSet); + }; + + match env_key.parse() { + Ok(key) => key, + Err(err) => { + return Err(CredentialProxyError::MalformedAttesterPublicKey { source: err }); + } + } + } + }; + let ticketbook_manager = TicketbookManager::new( build_sha_short(), cli.quorum_check_interval, @@ -94,6 +117,7 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { cli.upgrade_mode.attestation_check_regular_polling_interval, cli.upgrade_mode .attestation_check_expedited_polling_interval, + attester_pubkey, upgrade_mode_attestation_check_url, jwt_signing_keys, cli.upgrade_mode.upgrade_mode_jwt_validity, diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs b/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs index 80c94a055f..eed655d695 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs @@ -1,14 +1,15 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_credential_proxy_requests::CREDENTIAL_PROXY_JWT_ISSUER; -use nym_credential_proxy_requests::api::v1::ticketbook::models::UpgradeModeAttestation; use nym_crypto::asymmetric::ed25519; -use nym_upgrade_mode_check::generate_jwt_for_upgrade_mode_attestation; +use nym_upgrade_mode_check::{ + CREDENTIAL_PROXY_JWT_ISSUER, UpgradeModeAttestation, generate_jwt_for_upgrade_mode_attestation, +}; use std::sync::Arc; use std::time::Duration; use time::OffsetDateTime; use tokio::sync::RwLock; +use tracing::error; #[derive(Debug, Clone)] pub(crate) struct UpgradeModeState { @@ -23,6 +24,7 @@ impl UpgradeModeState { pub(crate) async fn update( &self, retrieved_attestation: Option, + expected_attester_public_key: ed25519::PublicKey, jwt_signing_keys: &ed25519::KeyPair, jwt_validity: Duration, ) { @@ -32,6 +34,14 @@ impl UpgradeModeState { return; }; + if attestation.content.attester_public_key != expected_attester_public_key { + error!( + "the retrieved attestation has been signed with an unexpected key! expected pubkey: {} actual: {}", + expected_attester_public_key, attestation.content.attester_public_key + ); + return; + } + match guard.as_mut() { None => { // no existing state - it's the first time we're going into upgrade mode, diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index cac1225a8c..76b8c5c567 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -16,7 +16,7 @@ use futures::StreamExt; use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; use nym_authenticator_requests::{ AuthenticatorVersion, client_message::ClientMessage, response::AuthenticatorResponse, v2, v3, - v4, v5, + v4, v5, v6, }; use nym_client_core::config::ForgetMe; use nym_config::defaults::{ @@ -467,6 +467,7 @@ async fn wg_probe( auth_version: AuthenticatorVersion, awg_args: String, netstack_args: NetstackArgs, + // TODO: update type credential: CredentialSpendingData, ) -> anyhow::Result { info!("attempting to use authenticator version {auth_version:?}"); @@ -493,6 +494,9 @@ async fn wg_probe( AuthenticatorVersion::V5 => ClientMessage::Initial(Box::new( v5::registration::InitMessage::new(authenticator_pub_key), )), + AuthenticatorVersion::V6 => ClientMessage::Initial(Box::new( + v6::registration::InitMessage::new(authenticator_pub_key), + )), AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => bail!("unknown version number"), }; @@ -512,57 +516,17 @@ async fn wg_probe( debug!("Verifying data"); pending_registration_response.verify(&private_key)?; - let finalized_message = match auth_version { - AuthenticatorVersion::V2 => { - ClientMessage::Final(Box::new(v2::registration::FinalMessage { - gateway_client: v2::registration::GatewayClient::new( - &private_key, - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().ipv4.into(), - pending_registration_response.nonce(), - ), - credential: Some(credential), - })) - } - AuthenticatorVersion::V3 => { - ClientMessage::Final(Box::new(v3::registration::FinalMessage { - gateway_client: v3::registration::GatewayClient::new( - &private_key, - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().ipv4.into(), - pending_registration_response.nonce(), - ), - credential: Some(credential), - })) - } - AuthenticatorVersion::V4 => { - ClientMessage::Final(Box::new(v4::registration::FinalMessage { - gateway_client: v4::registration::GatewayClient::new( - &private_key, - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips().into(), - pending_registration_response.nonce(), - ), - credential: Some(credential), - })) - } - AuthenticatorVersion::V5 => { - ClientMessage::Final(Box::new(v5::registration::FinalMessage { - gateway_client: v5::registration::GatewayClient::new( - &private_key, - pending_registration_response.pub_key().inner(), - pending_registration_response.private_ips(), - pending_registration_response.nonce(), - ), - credential: Some(credential), - })) - } - AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => { - bail!("Unknown version number") - } - }; + let credential = credential + .try_into() + .inspect_err(|err| error!("invalid zk-nym data: {err}")) + .ok(); + + let finalized_message = + pending_registration_response.finalise_registration(&private_key, credential); + let client_message = ClientMessage::Final(finalized_message); + let response = auth_client - .send_and_wait_for_response(&finalized_message) + .send_and_wait_for_response(&client_message) .await?; let AuthenticatorResponse::Registered(registered_response) = response else { bail!("Unexpected response"); diff --git a/nym-node/src/cli/commands/run/args.rs b/nym-node/src/cli/commands/run/args.rs index 36117d0ba6..8c214f5e5e 100644 --- a/nym-node/src/cli/commands/run/args.rs +++ b/nym-node/src/cli/commands/run/args.rs @@ -159,7 +159,7 @@ impl Args { name: "id".to_string(), })?; - let config = ConfigBuilder::new(id, config_path.clone(), data_dir.clone()) + ConfigBuilder::new(id, config_path.clone(), data_dir.clone()) // the old default behaviour of running in mixnode mode if nothing is explicitly set .with_modes( self.custom_modes() @@ -172,11 +172,9 @@ impl Args { .with_storage_paths(NymNodePaths::new(&data_dir)) .with_verloc(self.verloc.build_config_section()) .with_metrics(self.metrics.build_config_section()) - .with_gateway_tasks(self.entry_gateway.build_config_section(&data_dir)) + .with_gateway_tasks(self.entry_gateway.build_config_section(&data_dir)?) .with_service_providers(self.exit_gateway.build_config_section(&data_dir)) - .build(); - - Ok(config) + .build() } pub(crate) fn override_config(self, mut config: Config) -> Config { diff --git a/nym-node/src/cli/helpers.rs b/nym-node/src/cli/helpers.rs index 5211696485..e7eb81623f 100644 --- a/nym-node/src/cli/helpers.rs +++ b/nym-node/src/cli/helpers.rs @@ -5,6 +5,7 @@ use super::DEFAULT_NYMNODE_ID; use crate::config; use crate::config::default_config_filepath; use crate::env::vars::*; +use crate::error::NymNodeError; use celes::Country; use clap::Args; use clap::builder::ArgPredicate; @@ -426,6 +427,14 @@ pub(crate) struct EntryGatewayArgs { env = NYMNODE_MNEMONIC_ARG )] pub(crate) mnemonic: Option, + + /// Endpoint to query to retrieve current upgrade mode attestation. + #[clap( + long, + env = NYMNODE_UPGRADE_MODE_ATTESTATION_URL_ARG + )] + #[zeroize(skip)] + pub(crate) upgrade_mode_attestation_url: Option, } impl EntryGatewayArgs { @@ -433,12 +442,12 @@ impl EntryGatewayArgs { pub(crate) fn build_config_section>( self, data_dir: P, - ) -> config::GatewayTasksConfig { - self.override_config_section(config::GatewayTasksConfig::new_default(data_dir)) + ) -> Result { + Ok(self.override_config_section(config::GatewayTasksConfig::new(data_dir)?)) } pub(crate) fn override_config_section( - self, + mut self, mut section: config::GatewayTasksConfig, ) -> config::GatewayTasksConfig { if let Some(bind_address) = self.entry_bind_address { @@ -453,6 +462,9 @@ impl EntryGatewayArgs { if let Some(enforce_zk_nyms) = self.enforce_zk_nyms { section.enforce_zk_nyms = enforce_zk_nyms } + if let Some(upgrade_mode_attestation_url) = self.upgrade_mode_attestation_url.take() { + section.upgrade_mode.attestation_url = upgrade_mode_attestation_url + } section } diff --git a/nym-node/src/config/gateway_tasks.rs b/nym-node/src/config/gateway_tasks.rs index 0fcf54d9dd..16517b77bc 100644 --- a/nym-node/src/config/gateway_tasks.rs +++ b/nym-node/src/config/gateway_tasks.rs @@ -1,14 +1,22 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::config::helpers::log_error_and_return; use crate::config::persistence::GatewayTasksPaths; -use nym_config::defaults::{DEFAULT_CLIENT_LISTENING_PORT, TICKETBOOK_VALIDITY_DAYS}; +use crate::error::NymNodeError; +use nym_config::defaults::{ + DEFAULT_CLIENT_LISTENING_PORT, TICKETBOOK_VALIDITY_DAYS, mainnet, var_names, +}; use nym_config::helpers::in6addr_any_init; use nym_config::serde_helpers::de_maybe_port; +use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey}; use serde::{Deserialize, Serialize}; +use std::env; use std::net::SocketAddr; use std::path::Path; use std::time::Duration; +use tracing::info; +use url::Url; pub const DEFAULT_WS_PORT: u16 = DEFAULT_CLIENT_LISTENING_PORT; @@ -36,6 +44,8 @@ pub struct GatewayTasksConfig { #[serde(deserialize_with = "de_maybe_port")] pub announce_wss_port: Option, + pub upgrade_mode: UpgradeModeWatcher, + #[serde(default)] pub debug: Debug, } @@ -63,6 +73,10 @@ pub struct Debug { pub client_bandwidth: ClientBandwidthDebug, pub zk_nym_tickets: ZkNymTicketHandlerDebug, + + /// The minimum duration since the last explicit check for the upgrade mode to allow creation of new requests. + #[serde(with = "humantime_serde")] + pub upgrade_mode_min_staleness_recheck: Duration, } impl Debug { @@ -70,6 +84,7 @@ impl Debug { pub const DEFAULT_MINIMUM_MIX_PERFORMANCE: u8 = 50; pub const DEFAULT_MAXIMUM_AUTH_REQUEST_TIMESTAMP_SKEW: Duration = Duration::from_secs(120); pub const DEFAULT_MAXIMUM_OPEN_CONNECTIONS: usize = 8192; + pub const DEFAULT_UPGRADE_MODE_MIN_STALENESS_RECHECK: Duration = Duration::from_secs(30); } impl Default for Debug { @@ -82,6 +97,7 @@ impl Default for Debug { stale_messages: Default::default(), client_bandwidth: Default::default(), zk_nym_tickets: Default::default(), + upgrade_mode_min_staleness_recheck: Self::DEFAULT_UPGRADE_MODE_MIN_STALENESS_RECHECK, } } } @@ -201,14 +217,149 @@ impl Default for StaleMessageDebug { } impl GatewayTasksConfig { - pub fn new_default>(data_dir: P) -> Self { - GatewayTasksConfig { + pub fn new>(data_dir: P) -> Result { + Ok(GatewayTasksConfig { storage_paths: GatewayTasksPaths::new(data_dir), enforce_zk_nyms: false, ws_bind_address: SocketAddr::new(in6addr_any_init(), DEFAULT_WS_PORT), announce_ws_port: None, announce_wss_port: None, + upgrade_mode: UpgradeModeWatcher::new()?, debug: Default::default(), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpgradeModeWatcher { + /// Specifies whether this gateway watches for upgrade mode changes + /// via the published attestation file. + pub enabled: bool, + + /// Endpoint to query to retrieve current upgrade mode attestation. + pub attestation_url: Url, + + /// Expected public key of the attester providing the upgrade mode attestation + /// on the specified endpoint + #[serde(with = "bs58_ed25519_pubkey")] + pub attester_public_key: ed25519::PublicKey, + + pub debug: UpgradeModeWatcherDebug, +} + +impl From for nym_gateway::config::UpgradeModeWatcher { + fn from(config: UpgradeModeWatcher) -> Self { + nym_gateway::config::UpgradeModeWatcher { + enabled: config.enabled, + attestation_url: config.attestation_url, + debug: nym_gateway::config::UpgradeModeWatcherDebug { + regular_polling_interval: config.debug.regular_polling_interval, + expedited_poll_interval: config.debug.expedited_poll_interval, + }, + } + } +} + +impl UpgradeModeWatcher { + pub fn new_mainnet() -> UpgradeModeWatcher { + info!("using mainnet configuration for the upgrade mode:"); + info!("\t- url: {}", mainnet::UPGRADE_MODE_ATTESTATION_URL); + info!( + "\t- attester public key: {}", + mainnet::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY + ); + + // SAFETY: + // our hardcoded values should always be valid + #[allow(clippy::expect_used)] + let attestation_url = mainnet::UPGRADE_MODE_ATTESTATION_URL + .parse() + .expect("invalid default upgrade mode attestation URL"); + + #[allow(clippy::expect_used)] + let attester_public_key = mainnet::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY + .parse() + .expect("invalid default upgrade mode attester public key"); + + UpgradeModeWatcher { + enabled: true, + attestation_url, + attester_public_key, + debug: UpgradeModeWatcherDebug::default(), + } + } + + pub fn new() -> Result { + // if env is configured, extract relevant values from there, otherwise fallback to mainnet + if env::var(var_names::CONFIGURED).is_err() { + return Ok(Self::new_mainnet()); + } + + // if env is configured, the relevant values should be set + let Ok(env_attestation_url) = env::var(var_names::UPGRADE_MODE_ATTESTATION_URL) else { + return log_error_and_return(format!( + "'{}' is not set whilst the env is set to be configured", + var_names::UPGRADE_MODE_ATTESTATION_URL + )); + }; + + let Ok(env_attester_pubkey) = + env::var(var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY) + else { + return log_error_and_return(format!( + "'{}' is not set whilst the env is set to be configured", + var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY + )); + }; + + let attestation_url = match env_attestation_url.parse() { + Ok(url) => url, + Err(err) => { + return log_error_and_return(format!( + "provided attestation url {env_attestation_url} is invalid: {err}!" + )); + } + }; + + let attester_public_key = match env_attester_pubkey.parse() { + Ok(public_key) => public_key, + Err(err) => { + return log_error_and_return(format!( + "provided attester public key {env_attester_pubkey} is invalid: {err}!" + )); + } + }; + + Ok(UpgradeModeWatcher { + enabled: true, + attestation_url, + attester_public_key, + debug: UpgradeModeWatcherDebug::default(), + }) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct UpgradeModeWatcherDebug { + /// Default polling interval + #[serde(with = "humantime_serde")] + pub regular_polling_interval: Duration, + + /// Expedited polling interval for once upgrade mode is detected + #[serde(with = "humantime_serde")] + pub expedited_poll_interval: Duration, +} + +impl UpgradeModeWatcherDebug { + const DEFAULT_REGULAR_POLLING_INTERVAL: Duration = Duration::from_secs(15 * 60); + const DEFAULT_EXPEDITED_POLL_INTERVAL: Duration = Duration::from_secs(2 * 60); +} + +impl Default for UpgradeModeWatcherDebug { + fn default() -> Self { + UpgradeModeWatcherDebug { + regular_polling_interval: Self::DEFAULT_REGULAR_POLLING_INTERVAL, + expedited_poll_interval: Self::DEFAULT_EXPEDITED_POLL_INTERVAL, } } } diff --git a/nym-node/src/config/helpers.rs b/nym-node/src/config/helpers.rs index cdbffb0f25..9605302aa2 100644 --- a/nym-node/src/config/helpers.rs +++ b/nym-node/src/config/helpers.rs @@ -3,11 +3,13 @@ use super::LocalWireguardOpts; use crate::config::Config; +use crate::error::NymNodeError; use clap::crate_version; use nym_gateway::node::{ LocalAuthenticatorOpts, LocalIpPacketRouterOpts, LocalNetworkRequesterOpts, }; use nym_gateway::nym_authenticator; +use tracing::error; // a temporary solution until further refactoring is made fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { @@ -24,6 +26,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { nym_gateway::config::IpPacketRouter { enabled: config.service_providers.network_requester.debug.enabled, }, + config.gateway_tasks.upgrade_mode.clone(), nym_gateway::config::Debug { client_bandwidth_max_flushing_rate: config .gateway_tasks @@ -62,6 +65,10 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { .maximum_time_between_redemption, }, max_request_timestamp_skew: config.gateway_tasks.debug.max_request_timestamp_skew, + upgrade_mode_min_staleness_recheck: config + .gateway_tasks + .debug + .upgrade_mode_min_staleness_recheck, }, ) } @@ -218,3 +225,9 @@ pub fn gateway_tasks_config(config: &Config) -> GatewayTasksConfig { wg_opts, } } + +pub(crate) fn log_error_and_return(msg: impl Into) -> Result { + let msg = msg.into(); + error!("{msg}"); + Err(NymNodeError::config_validation_failure(msg)) +} diff --git a/nym-node/src/config/mod.rs b/nym-node/src/config/mod.rs index 3a20022a15..edec2a80af 100644 --- a/nym-node/src/config/mod.rs +++ b/nym-node/src/config/mod.rs @@ -262,8 +262,13 @@ impl ConfigBuilder { self } - pub fn build(self) -> Config { - Config { + pub fn build(self) -> Result { + let gateway_tasks = match self.gateway_tasks { + Some(gateway_tasks) => gateway_tasks, + None => GatewayTasksConfig::new(&self.data_dir)?, + }; + + Ok(Config { id: self.id, modes: self.modes, host: self.host.unwrap_or_default(), @@ -279,16 +284,14 @@ impl ConfigBuilder { .storage_paths .unwrap_or_else(|| NymNodePaths::new(&self.data_dir)), metrics: self.metrics.unwrap_or_default(), - gateway_tasks: self - .gateway_tasks - .unwrap_or_else(|| GatewayTasksConfig::new_default(&self.data_dir)), + gateway_tasks, service_providers: self .service_providers .unwrap_or_else(|| ServiceProvidersConfig::new_default(&self.data_dir)), logging: self.logging.unwrap_or_default(), save_path: Some(self.config_path), debug: Default::default(), - } + }) } } diff --git a/nym-node/src/config/old_configs/old_config_v10.rs b/nym-node/src/config/old_configs/old_config_v10.rs index 3a395685f5..e45cca8dd2 100644 --- a/nym-node/src/config/old_configs/old_config_v10.rs +++ b/nym-node/src/config/old_configs/old_config_v10.rs @@ -3,7 +3,7 @@ use crate::config::authenticator::{Authenticator, AuthenticatorDebug}; use crate::config::gateway_tasks::{ - ClientBandwidthDebug, StaleMessageDebug, ZkNymTicketHandlerDebug, + ClientBandwidthDebug, StaleMessageDebug, UpgradeModeWatcher, ZkNymTicketHandlerDebug, }; use crate::config::persistence::{ AuthenticatorPaths, GatewayTasksPaths, IpPacketRouterPaths, KeysPaths, NetworkRequesterPaths, @@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::time::Duration; -use tracing::{debug, instrument}; +use tracing::{debug, error, instrument}; use url::Url; #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] @@ -1346,6 +1346,13 @@ pub async fn try_upgrade_config_v10>( ws_bind_address: old_cfg.gateway_tasks.ws_bind_address, announce_ws_port: old_cfg.gateway_tasks.announce_ws_port, announce_wss_port: old_cfg.gateway_tasks.announce_wss_port, + upgrade_mode: UpgradeModeWatcher::new() + .inspect_err(|_| { + error!( + "failed to set custom upgrade mode configuration - falling back to mainnet" + ) + }) + .unwrap_or(UpgradeModeWatcher::new_mainnet()), debug: gateway_tasks::Debug { message_retrieval_limit: old_cfg.gateway_tasks.debug.message_retrieval_limit, maximum_open_connections: old_cfg.gateway_tasks.debug.maximum_open_connections, @@ -1394,6 +1401,7 @@ pub async fn try_upgrade_config_v10>( .zk_nym_tickets .maximum_time_between_redemption, }, + ..Default::default() }, }, service_providers: ServiceProvidersConfig { diff --git a/nym-node/src/config/template.rs b/nym-node/src/config/template.rs index bf8ed72710..8177119780 100644 --- a/nym-node/src/config/template.rs +++ b/nym-node/src/config/template.rs @@ -201,6 +201,18 @@ announce_ws_port = {{#if gateway_tasks.announce_ws_port }} {{ gateway_tasks.anno # (default: 0 - disabled) announce_wss_port = {{#if gateway_tasks.announce_wss_port }} {{ gateway_tasks.announce_wss_port }} {{else}} 0 {{/if}} +[gateway_tasks.upgrade_mode] +# Specifies whether this gateway watches for upgrade mode changes +# via the published attestation file. +enabled = {{ gateway_tasks.upgrade_mode.enabled }} + +# Endpoint to query to retrieve current upgrade mode attestation. +# If not provided, it implicitly disables the watcher and upgrade-mode features +attestation_url = '{{ gateway_tasks.upgrade_mode.attestation_url }}' + +# Expected public key of the attester providing the upgrade mode attestation +# on the specified endpoint +attester_public_key = '{{ gateway_tasks.upgrade_mode.attester_public_key }}' [gateway_tasks.storage_paths] # Path to sqlite database containing all persistent data: messages for offline clients, diff --git a/nym-node/src/env.rs b/nym-node/src/env.rs index 13aedc7c0a..8f9a6d65b8 100644 --- a/nym-node/src/env.rs +++ b/nym-node/src/env.rs @@ -61,6 +61,8 @@ pub mod vars { pub const NYMNODE_ENTRY_ANNOUNCE_WSS_PORT_ARG: &str = "NYMNODE_ENTRY_ANNOUNCE_WSS_PORT"; pub const NYMNODE_ENFORCE_ZK_NYMS_ARG: &str = "NYMNODE_ENFORCE_ZK_NYMS"; pub const NYMNODE_MNEMONIC_ARG: &str = "NYMNODE_MNEMONIC"; + pub const NYMNODE_UPGRADE_MODE_ATTESTATION_URL_ARG: &str = + "NYMNODE_UPGRADE_MODE_ATTESTATION_URL"; // exit gateway: pub const NYMNODE_UPSTREAM_EXIT_POLICY_ARG: &str = "NYMNODE_UPSTREAM_EXIT_POLICY"; diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index 44d35b5d41..c0f94eb6da 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -40,7 +40,7 @@ use crate::node::shared_network::{ }; use nym_bin_common::bin_info; use nym_crypto::asymmetric::{ed25519, x25519}; -use nym_gateway::node::{ActiveClientsStore, GatewayTasksBuilder}; +use nym_gateway::node::{ActiveClientsStore, GatewayTasksBuilder, UpgradeModeCheckRequestSender}; use nym_mixnet_client::client::ActiveConnections; use nym_mixnet_client::forwarder::MixForwardingSender; use nym_network_requester::{ @@ -624,9 +624,27 @@ impl NymNode { metrics_sender, self.metrics.clone(), self.entry_gateway.mnemonic.clone(), + Self::user_agent(), + self.config.gateway_tasks.upgrade_mode.attester_public_key, self.shutdown_tracker().clone(), ); + // start task for watching the changes in upgrade mode attestation + let upgrade_check_request_sender = if let Some(upgrade_mode_watcher) = + gateway_tasks_builder.try_build_upgrade_mode_watcher() + { + let req_sender = upgrade_mode_watcher.request_sender(); + upgrade_mode_watcher.start(); + req_sender + } else { + UpgradeModeCheckRequestSender::new_empty() + }; + + // create the common state for subtasks relying on the upgrade mode information + // (i.e. everything that'd require ticket/bandwidth processing) + let upgrade_mode_common_state = + gateway_tasks_builder.build_upgrade_mode_common_state(upgrade_check_request_sender); + // if we're running in entry mode, start the websocket if self.modes().entry { info!( @@ -634,7 +652,10 @@ impl NymNode { self.config.gateway_tasks.ws_bind_address ); let mut websocket = gateway_tasks_builder - .build_websocket_listener(active_clients_store.clone()) + .build_websocket_listener( + active_clients_store.clone(), + upgrade_mode_common_state.clone(), + ) .await?; self.shutdown_tracker() .try_spawn_named(async move { websocket.run().await }, "EntryWebsocket"); @@ -682,7 +703,7 @@ impl NymNode { gateway_tasks_builder.set_wireguard_data(wg_data.into()); let authenticator = gateway_tasks_builder - .build_wireguard_authenticator(topology_provider) + .build_wireguard_authenticator(upgrade_mode_common_state.clone(), topology_provider) .await?; let started_authenticator = authenticator.start_service_provider().await?; active_clients_store.insert_embedded(started_authenticator.handle); @@ -693,7 +714,7 @@ impl NymNode { ); gateway_tasks_builder - .try_start_wireguard() + .try_start_wireguard(upgrade_mode_common_state) .await .map_err(NymNodeError::GatewayTasksStartupFailure)?; } else { diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 26df1b52e6..120e1cc969 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -385,6 +385,18 @@ dependencies = [ "rayon", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -650,6 +662,12 @@ dependencies = [ "serde", ] +[[package]] +name = "binstring" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" + [[package]] name = "bip32" version = "0.5.3" @@ -721,6 +739,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2b_simd" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -1069,6 +1098,17 @@ dependencies = [ "error-code", ] +[[package]] +name = "coarsetime" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -1127,6 +1167,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1421,6 +1467,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "ct-codecs" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" + [[package]] name = "ctor" version = "0.2.9" @@ -1624,6 +1676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1869,6 +1922,16 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-compact" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b3460f44bea8cd47f45a0c70892f1eff856d97cd55358b2f73f663789f6190" +dependencies = [ + "ct-codecs", + "getrandom 0.2.15", +] + [[package]] name = "ed25519-consensus" version = "2.1.0" @@ -1930,6 +1993,8 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2886,6 +2951,15 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2895,6 +2969,30 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac-sha1-compact" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18492c9f6f9a560e0d346369b665ad2bdbc89fa9bceca75796584e79042694c3" + +[[package]] +name = "hmac-sha256" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hostname" version = "0.4.0" @@ -3560,6 +3658,32 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jwt-simple" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731011e9647a71ff4f8474176ff6ce6e0d2de87a0173f15613af3a84c3e3401a" +dependencies = [ + "anyhow", + "binstring", + "blake2b_simd", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand 0.8.5", + "serde", + "serde_json", + "superboring", + "thiserror 2.0.12", + "zeroize", +] + [[package]] name = "k256" version = "0.13.4" @@ -3603,6 +3727,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -3644,6 +3771,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.3" @@ -3936,6 +4069,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3951,6 +4101,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3958,6 +4119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4111,6 +4273,7 @@ dependencies = [ "nym-compact-ecash", "nym-ecash-time", "nym-network-defaults", + "nym-upgrade-mode-check", "rand 0.8.5", "serde", "strum", @@ -4127,6 +4290,7 @@ dependencies = [ "base64 0.22.1", "bs58", "ed25519-dalek", + "jwt-simple", "nym-pemstore", "nym-sphinx-types", "rand 0.8.5", @@ -4429,6 +4593,21 @@ dependencies = [ "x25519-dalek", ] +[[package]] +name = "nym-upgrade-mode-check" +version = "0.1.0" +dependencies = [ + "jwt-simple", + "nym-crypto", + "nym-http-api-client", + "reqwest 0.12.15", + "serde", + "serde_json", + "thiserror 2.0.12", + "time", + "tracing", +] + [[package]] name = "nym-validator-client" version = "0.1.0" @@ -4529,9 +4708,7 @@ name = "nym-wireguard-types" version = "0.1.0" dependencies = [ "base64 0.22.1", - "log", - "nym-config", - "nym-network-defaults", + "nym-crypto", "serde", "thiserror 2.0.12", "x25519-dalek", @@ -4900,6 +5077,18 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pairing" version = "0.23.0" @@ -5024,6 +5213,15 @@ dependencies = [ "regex", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -5262,6 +5460,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -5944,6 +6153,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -6619,6 +6849,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -6714,6 +6950,19 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" +[[package]] +name = "superboring" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515cce34a781d7250b8a65706e0f2a5b99236ea605cb235d4baed6685820478f" +dependencies = [ + "getrandom 0.2.15", + "hmac-sha256", + "hmac-sha512", + "rand 0.8.5", + "rsa", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -8079,6 +8328,15 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasix" +version = "0.12.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d" +dependencies = [ + "wasi 0.11.0+wasi-snapshot-preview1", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" From d126d8e5a0e69a0da013b65e5845054ff98da1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Nov 2025 13:34:36 +0000 Subject: [PATCH 18/23] feat: upgrade mode: VPN adjustments (#6189) * placeholder handling of wg registration with upgrade mode token * include upgrade mode credentials as part of credential storage * introduce helper for decoding JWT payload * expose methods for removing emergency credentials from the storage * don't allow duplicate emergency credentials with the same content * added authenticator ClientMessage for upgrade mode check * retrieve credentials with longest expiration first * post rebasing fixes * fixed gateway config * feat: allow specifying minimum node performance for client init * nym-node UM improvements * fixed upgrade mode bandwidth on initial authentication * fix: logs and thresholds * expose attestation information from nym-node http api * additional logs * post rebasing fixes * make @simonwicky happy by removing empty lines in emergency_credential table definition * chore: remove '_' prefix for internal counters within in-mem ecash storage * improved import of 'UpgradeModeState' within the nym-node * use explicit time dependency within credential-storage * re-order imports within the gateway-client * moved 'AvailableBandwidth' definition to the monorepo --- Cargo.lock | 10 +- .../src/client_message.rs | 29 ++++- common/authenticator-requests/src/models.rs | 6 + common/bandwidth-controller/Cargo.toml | 7 +- common/bandwidth-controller/src/error.rs | 3 + common/bandwidth-controller/src/lib.rs | 15 ++- common/bandwidth-controller/src/traits.rs | 17 +++ .../src/cli_helpers/client_init.rs | 10 +- .../gateway-client/src/socket_state.rs | 16 ++- common/credential-storage/Cargo.toml | 1 + .../20251030120000_upgrade_mode_token.sql | 17 +++ .../credential-storage/src/backends/memory.rs | 67 +++++++++- .../credential-storage/src/backends/sqlite.rs | 73 ++++++++++- .../src/ephemeral_storage.rs | 37 +++++- common/credential-storage/src/models.rs | 18 +++ .../src/persistent_storage/mod.rs | 33 +++++ common/credential-storage/src/storage.rs | 22 +++- common/credential-verification/src/lib.rs | 1 + .../src/upgrade_mode.rs | 11 +- common/upgrade-mode-check/src/error.rs | 3 + common/upgrade-mode-check/src/jwt.rs | 73 ++++++++++- common/upgrade-mode-check/src/lib.rs | 4 +- .../shared/src/lib.rs | 4 +- .../shared/src/models/interface.rs | 42 ++++++ .../websocket/connection_handler/fresh.rs | 50 +++++--- gateway/src/node/mod.rs | 6 +- gateway/src/node/upgrade_mode/watcher.rs | 34 ++++- nym-authenticator-client/src/error.rs | 6 + nym-authenticator-client/src/lib.rs | 120 ++++++++++++++---- .../src/attestation_watcher.rs | 31 ++++- nym-node/Cargo.toml | 1 + nym-node/nym-node-requests/Cargo.toml | 3 +- nym-node/nym-node-requests/src/api/v1/mod.rs | 1 + .../nym-node-requests/src/api/v1/network.rs | 4 + .../src/api/v1/network/models.rs | 28 ++++ nym-node/nym-node-requests/src/lib.rs | 13 ++ nym-node/src/cli/commands/run/mod.rs | 2 +- nym-node/src/cli/helpers.rs | 14 ++ nym-node/src/config/gateway_tasks.rs | 1 + nym-node/src/config/mod.rs | 4 + nym-node/src/env.rs | 2 + nym-node/src/node/http/router/api/v1/mod.rs | 2 + .../node/http/router/api/v1/network/mod.rs | 14 ++ .../router/api/v1/network/upgrade_mode.rs | 39 ++++++ .../src/node/http/router/api/v1/openapi.rs | 45 ++++--- nym-node/src/node/http/state/mod.rs | 17 ++- nym-node/src/node/mod.rs | 16 ++- nym-wallet/Cargo.lock | 4 +- tools/echo-server/Cargo.toml | 2 +- .../src/manager/local_client.rs | 14 +- .../src/manager/local_nodes.rs | 4 +- 51 files changed, 877 insertions(+), 119 deletions(-) create mode 100644 common/credential-storage/migrations/20251030120000_upgrade_mode_token.sql create mode 100644 nym-node/nym-node-requests/src/api/v1/network.rs create mode 100644 nym-node/nym-node-requests/src/api/v1/network/models.rs create mode 100644 nym-node/src/node/http/router/api/v1/network/mod.rs create mode 100644 nym-node/src/node/http/router/api/v1/network/upgrade_mode.rs diff --git a/Cargo.lock b/Cargo.lock index b9bcc7eb58..7ec3531bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5005,21 +5005,16 @@ name = "nym-bandwidth-controller" version = "0.1.0" dependencies = [ "async-trait", - "bip39", "log", "nym-credential-storage", "nym-credentials", "nym-credentials-interface", "nym-crypto", - "nym-ecash-contract-common", "nym-ecash-time", - "nym-network-defaults", "nym-task", "nym-validator-client", "rand 0.8.5", "thiserror 2.0.12", - "url", - "zeroize", ] [[package]] @@ -5572,6 +5567,7 @@ dependencies = [ "sqlx", "sqlx-pool-guard", "thiserror 2.0.12", + "time", "tokio", "zeroize", ] @@ -6443,6 +6439,7 @@ dependencies = [ "nym-bin-common", "nym-client-core-config-types", "nym-config", + "nym-credential-verification", "nym-crypto", "nym-gateway", "nym-gateway-stats-storage", @@ -6515,13 +6512,13 @@ version = "0.1.0" dependencies = [ "async-trait", "celes", - "humantime", "humantime-serde", "nym-bin-common", "nym-crypto", "nym-exit-policy", "nym-http-api-client", "nym-noise-keys", + "nym-upgrade-mode-check", "nym-wireguard-types", "rand_chacha 0.3.1", "schemars 0.8.22", @@ -6532,6 +6529,7 @@ dependencies = [ "thiserror 2.0.12", "time", "tokio", + "url", "utoipa", ] diff --git a/common/authenticator-requests/src/client_message.rs b/common/authenticator-requests/src/client_message.rs index 23bdd13ab0..45f7481a46 100644 --- a/common/authenticator-requests/src/client_message.rs +++ b/common/authenticator-requests/src/client_message.rs @@ -6,7 +6,10 @@ use nym_wireguard_types::PeerPublicKey; use crate::{ AuthenticatorVersion, Error, - traits::{FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage, Versionable}, + traits::{ + FinalMessage, InitMessage, QueryBandwidthMessage, TopUpMessage, UpgradeModeMessage, + Versionable, + }, v2, v3, v4, v5, v6, }; @@ -18,6 +21,7 @@ pub enum ClientMessage { Final(Box), Query(Box), TopUp(Box), + UpgradeModeCheck(Box), } pub struct SerialisedRequest { @@ -131,6 +135,7 @@ impl ClientMessage { ); Ok(SerialisedRequest::new(req.to_bytes()?, id)) } + _ => Err(Error::UnsupportedMessage), } } @@ -189,6 +194,7 @@ impl ClientMessage { ); Ok(SerialisedRequest::new(req.to_bytes()?, id)) } + _ => Err(Error::UnsupportedMessage), } } @@ -237,6 +243,7 @@ impl ClientMessage { }); Ok(SerialisedRequest::new(req.to_bytes()?, id)) } + _ => Err(Error::UnsupportedMessage), } } @@ -245,6 +252,7 @@ impl ClientMessage { registration::{ClientMac, FinalMessage, GatewayClient, InitMessage, IpPair}, request::AuthenticatorRequest, topup::TopUpMessage, + upgrade_mode_check::UpgradeModeCheckRequest, }; match self { ClientMessage::Initial(init_message) => { @@ -282,6 +290,22 @@ impl ClientMessage { }); Ok(SerialisedRequest::new(req.to_bytes()?, id)) } + ClientMessage::UpgradeModeCheck(upgrade_mode_check) => { + // currently JWT is the only emergency credential option + let Some(upgrade_mode_jwt) = + upgrade_mode_check.upgrade_mode_global_attestation_jwt() + else { + return Err(Error::conversion( + "no valid known upgrade mode check variants", + )); + }; + let msg = UpgradeModeCheckRequest::UpgradeModeJwt { + token: upgrade_mode_jwt, + }; + + let (req, id) = AuthenticatorRequest::new_upgrade_mode_check_request(msg); + Ok(SerialisedRequest::new(req.to_bytes()?, id)) + } } } } @@ -292,7 +316,7 @@ impl ClientMessage { match self { Self::Final(msg) => msg.credential().is_some(), Self::TopUp(_) => true, - Self::Initial(_) | Self::Query(_) => false, + Self::Initial(_) | Self::Query(_) | Self::UpgradeModeCheck(_) => false, } } @@ -302,6 +326,7 @@ impl ClientMessage { ClientMessage::Final(msg) => msg.version(), ClientMessage::Query(msg) => msg.version(), ClientMessage::TopUp(msg) => msg.version(), + ClientMessage::UpgradeModeCheck(msg) => msg.version(), } } diff --git a/common/authenticator-requests/src/models.rs b/common/authenticator-requests/src/models.rs index dc870df4d5..c01eec98f4 100644 --- a/common/authenticator-requests/src/models.rs +++ b/common/authenticator-requests/src/models.rs @@ -14,6 +14,12 @@ pub enum CurrentUpgradeModeStatus { Unknown, } +impl CurrentUpgradeModeStatus { + pub fn is_enabled(&self) -> bool { + matches!(self, CurrentUpgradeModeStatus::Enabled) + } +} + impl From for CurrentUpgradeModeStatus { fn from(value: bool) -> Self { if value { diff --git a/common/bandwidth-controller/Cargo.toml b/common/bandwidth-controller/Cargo.toml index 075c1702ae..a94abcc1cb 100644 --- a/common/bandwidth-controller/Cargo.toml +++ b/common/bandwidth-controller/Cargo.toml @@ -7,21 +7,16 @@ license.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { workspace = true } -bip39 = { workspace = true } +async-trait = { workspace = true } log = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } -url = { workspace = true } -zeroize = { workspace = true } nym-credential-storage = { path = "../credential-storage" } nym-credentials = { path = "../credentials" } nym-credentials-interface = { path = "../credentials-interface" } nym-crypto = { path = "../crypto", features = ["rand", "asymmetric", "stream_cipher", "aes", "hashing"] } -nym-ecash-contract-common = { path = "../cosmwasm-smart-contracts/ecash-contract" } nym-ecash-time = { path = "../ecash-time" } -nym-network-defaults = { path = "../network-defaults" } nym-task = { path = "../task" } nym-validator-client = { path = "../client-libs/validator-client", default-features = false } diff --git a/common/bandwidth-controller/src/error.rs b/common/bandwidth-controller/src/error.rs index 4fcff3731a..c5721df092 100644 --- a/common/bandwidth-controller/src/error.rs +++ b/common/bandwidth-controller/src/error.rs @@ -21,6 +21,9 @@ pub enum BandwidthControllerError { #[error("There was a credential storage error - {0}")] CredentialStorageError(Box), + #[error("retrieved upgrade mode token is not a valid String")] + MalformedUpgradeModeToken, + #[error("the credential storage does not contain any usable credentials")] NoCredentialsAvailable, diff --git a/common/bandwidth-controller/src/lib.rs b/common/bandwidth-controller/src/lib.rs index 05be75cbdc..8002e77631 100644 --- a/common/bandwidth-controller/src/lib.rs +++ b/common/bandwidth-controller/src/lib.rs @@ -12,7 +12,7 @@ use crate::utils::{ ApiClientsWrapper, }; use log::error; -use nym_credential_storage::models::RetrievedTicketbook; +use nym_credential_storage::models::{EmergencyCredential, RetrievedTicketbook}; use nym_credential_storage::storage::Storage; use nym_credentials::ecash::bandwidth::CredentialSpendingData; use nym_credentials_interface::{ @@ -220,6 +220,19 @@ impl BandwidthController { } } } + + pub async fn get_emergency_credential( + &self, + typ: &str, + ) -> Result, BandwidthControllerError> + where + ::StorageError: Send + Sync + 'static, + { + self.storage + .get_emergency_credential(typ) + .await + .map_err(BandwidthControllerError::credential_storage_error) + } } impl Clone for BandwidthController diff --git a/common/bandwidth-controller/src/traits.rs b/common/bandwidth-controller/src/traits.rs index 8ff508f3f3..d611649004 100644 --- a/common/bandwidth-controller/src/traits.rs +++ b/common/bandwidth-controller/src/traits.rs @@ -11,6 +11,9 @@ use crate::{error::BandwidthControllerError, BandwidthController, PreparedCreden pub const DEFAULT_TICKETS_TO_SPEND: u32 = 1; +// TODO: this does not really belong here +pub const UPGRADE_MODE_JWT_TYPE: &str = "UPGRADE_MODE_JWT"; + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait BandwidthTicketProvider: Send + Sync { @@ -20,6 +23,8 @@ pub trait BandwidthTicketProvider: Send + Sync { gateway_id: ed25519::PublicKey, tickets_to_spend: u32, ) -> Result; + + async fn get_upgrade_mode_token(&self) -> Result, BandwidthControllerError>; } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -39,4 +44,16 @@ where self.prepare_ecash_ticket(ticket_type, gateway_id.to_bytes(), tickets_to_spend) .await } + + async fn get_upgrade_mode_token(&self) -> Result, BandwidthControllerError> { + let Some(emergency_credential) = + self.get_emergency_credential(UPGRADE_MODE_JWT_TYPE).await? + else { + return Ok(None); + }; + // upgrade mode credential is just a simple stringified JWT + let token = String::from_utf8(emergency_credential.data.content) + .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?; + Ok(Some(token)) + } } diff --git a/common/client-core/src/cli_helpers/client_init.rs b/common/client-core/src/cli_helpers/client_init.rs index feda3ab8d1..57f800c8cf 100644 --- a/common/client-core/src/cli_helpers/client_init.rs +++ b/common/client-core/src/cli_helpers/client_init.rs @@ -81,6 +81,10 @@ pub struct CommonClientInitArgs { #[cfg_attr(feature = "cli", clap(long, hide = true))] pub enabled_credentials_mode: Option, + /// Change the default minimum node performance used during initial node selection filtering. + #[cfg_attr(feature = "cli", clap(long, hide = true))] + pub minimum_gateway_performance: Option, + /// Mostly debug-related option to increase default traffic rate so that you would not need to /// modify config post init #[cfg_attr(feature = "cli", clap(long, hide = true))] @@ -173,10 +177,14 @@ where })?; hardcoded_topology.entry_capable_nodes().cloned().collect() } else { + let minimum_performance = common_args + .minimum_gateway_performance + .unwrap_or(core.debug.topology.minimum_gateway_performance); + crate::init::helpers::gateways_for_init( &core.client.nym_api_urls, user_agent, - core.debug.topology.minimum_gateway_performance, + minimum_performance, core.debug.topology.ignore_ingress_epoch_role, None, ) diff --git a/common/client-libs/gateway-client/src/socket_state.rs b/common/client-libs/gateway-client/src/socket_state.rs index 151ef4842e..af98d1b797 100644 --- a/common/client-libs/gateway-client/src/socket_state.rs +++ b/common/client-libs/gateway-client/src/socket_state.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::bandwidth::ClientBandwidth; +use crate::client::config::BandwidthTickets; use crate::error::GatewayClientError; use crate::packet_router::PacketRouter; use crate::traits::GatewayPacketRouter; @@ -177,7 +178,20 @@ impl PartiallyDelegatedRouter { let available_bi2 = bibytes2(available as f64); let required_bi2 = bibytes2(required as f64); warn!("run out of bandwidth when attempting to send the message! we got {available_bi2} available, but needed at least {required_bi2} to send the previous message"); - self.client_bandwidth.update_and_log(available, None); + // if we run out of bandwidth (and tried to send reasonable amount of data), + // the upgrade mode is implicitly disabled, as otherwise we would have been + // to proceed + let upgrade_mode = if available + < BandwidthTickets::DEFAULT_REMAINING_BANDWIDTH_THRESHOLD + { + Some(false) + } else { + // we were attempting to send a lot of data at once + // - we have no certainty about upgrade mode at this point + None + }; + self.client_bandwidth + .update_and_log(available, upgrade_mode); // UNIMPLEMENTED: we should stop sending messages until we recover bandwidth Ok(()) } diff --git a/common/credential-storage/Cargo.toml b/common/credential-storage/Cargo.toml index 1b0539dda4..9ca0eab6f3 100644 --- a/common/credential-storage/Cargo.toml +++ b/common/credential-storage/Cargo.toml @@ -14,6 +14,7 @@ bincode = { workspace = true, optional = true } log = { workspace = true } thiserror = { workspace = true } serde = { workspace = true, features = ["derive"], optional = true } +time = { workspace = true } tokio = { workspace = true, features = ["sync"] } zeroize = { workspace = true, features = ["zeroize_derive"] } diff --git a/common/credential-storage/migrations/20251030120000_upgrade_mode_token.sql b/common/credential-storage/migrations/20251030120000_upgrade_mode_token.sql new file mode 100644 index 0000000000..7f4c6f5ec8 --- /dev/null +++ b/common/credential-storage/migrations/20251030120000_upgrade_mode_token.sql @@ -0,0 +1,17 @@ +/* + * Copyright 2025 - Nym Technologies SA + * SPDX-License-Identifier: Apache-2.0 + */ + +CREATE TABLE emergency_credential +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + -- don't define any strict schema on the content as it might be implementation-dependant + content BLOB NOT NULL, + expiration TIMESTAMP WITHOUT TIME ZONE +); + +-- no point in allowing duplicate data +CREATE UNIQUE INDEX emergency_credential_unique_type_content + ON emergency_credential (type, content); diff --git a/common/credential-storage/src/backends/memory.rs b/common/credential-storage/src/backends/memory.rs index 53207a4060..28b99e4196 100644 --- a/common/credential-storage/src/backends/memory.rs +++ b/common/credential-storage/src/backends/memory.rs @@ -1,7 +1,10 @@ // Copyright 2023-2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::models::{BasicTicketbookInformation, RetrievedPendingTicketbook, RetrievedTicketbook}; +use crate::models::{ + BasicTicketbookInformation, EmergencyCredential, EmergencyCredentialContent, + RetrievedPendingTicketbook, RetrievedTicketbook, +}; use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature; use nym_compact_ecash::scheme::expiration_date_signatures::AnnotatedExpirationDateSignature; use nym_compact_ecash::VerificationKeyAuth; @@ -22,6 +25,12 @@ pub struct MemoryEcachTicketbookManager { inner: Arc>, } +#[derive(Default)] +struct InternalIdCounters { + next_ticketbook_id: i64, + next_emergency_credential_id: i64, +} + #[derive(Default)] struct EcashCredentialManagerInner { ticketbooks: HashMap, @@ -29,13 +38,22 @@ struct EcashCredentialManagerInner { master_vk: HashMap, coin_indices_sigs: HashMap>, expiration_date_sigs: HashMap<(u64, Date), Vec>, - _next_id: i64, + emergency_credentials: HashMap>, + + // internal counters emulating assignment of an increasing id to new inserted database entries + internal_counters: InternalIdCounters, } impl EcashCredentialManagerInner { - fn next_id(&mut self) -> i64 { - let next = self._next_id; - self._next_id += 1; + fn next_ticketbook_id(&mut self) -> i64 { + let next = self.internal_counters.next_ticketbook_id; + self.internal_counters.next_ticketbook_id += 1; + next + } + + fn next_emergency_credential_id(&mut self) -> i64 { + let next = self.internal_counters.next_emergency_credential_id; + self.internal_counters.next_emergency_credential_id += 1; next } } @@ -170,7 +188,7 @@ impl MemoryEcachTicketbookManager { used_tickets: u32, ) { let mut guard = self.inner.write().await; - let id = guard.next_id(); + let id = guard.next_ticketbook_id(); #[allow(clippy::unwrap_used)] let mut nasty_clone = hack_clone_ticketbook(ticketbook); @@ -277,4 +295,41 @@ impl MemoryEcachTicketbookManager { sigs.signatures.clone(), ); } + + pub(crate) async fn get_emergency_credential(&self, typ: &str) -> Option { + let guard = self.inner.read().await; + + guard.emergency_credentials.get(typ)?.first().cloned() + } + + pub(crate) async fn insert_emergency_credential( + &self, + credential: &EmergencyCredentialContent, + ) { + let mut guard = self.inner.write().await; + let id = guard.next_emergency_credential_id(); + + guard + .emergency_credentials + .entry(credential.typ.clone()) + .or_default() + .push(EmergencyCredential { + id, + data: credential.clone(), + }); + } + + pub(crate) async fn remove_emergency_credential(&self, id: i64) { + let mut guard = self.inner.write().await; + + guard.emergency_credentials.retain(|_, credentials| { + credentials.retain(|c| c.id != id); + !credentials.is_empty() + }) + } + + pub(crate) async fn remove_emergency_credentials_of_type(&self, typ: &str) { + let mut guard = self.inner.write().await; + guard.emergency_credentials.remove(typ); + } } diff --git a/common/credential-storage/src/backends/sqlite.rs b/common/credential-storage/src/backends/sqlite.rs index d196cad049..cb1cddf980 100644 --- a/common/credential-storage/src/backends/sqlite.rs +++ b/common/credential-storage/src/backends/sqlite.rs @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use crate::models::{ - BasicTicketbookInformation, RawCoinIndexSignatures, RawExpirationDateSignatures, - RawVerificationKey, StoredIssuedTicketbook, StoredPendingTicketbook, + BasicTicketbookInformation, EmergencyCredential, EmergencyCredentialContent, + RawCoinIndexSignatures, RawExpirationDateSignatures, RawVerificationKey, + StoredIssuedTicketbook, StoredPendingTicketbook, }; use nym_ecash_time::Date; use sqlx::{Executor, Sqlite, Transaction}; @@ -305,6 +306,74 @@ impl SqliteEcashTicketbookManager { .await?; Ok(()) } + + pub(crate) async fn get_emergency_credential( + &self, + typ: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as( + r#" + SELECT * + FROM emergency_credential + WHERE type = ? + AND (expiration IS NULL OR expiration > CURRENT_TIMESTAMP) + ORDER BY expiration DESC NULLS LAST + LIMIT 1 + "#, + ) + .bind(typ) + .fetch_optional(&*self.connection_pool) + .await + } + + pub(crate) async fn insert_emergency_credential( + &self, + credential: &EmergencyCredentialContent, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO emergency_credential + (type, content, expiration) + VALUES (?, ?, ?) + ON CONFLICT(type, content) DO NOTHING; + "#, + credential.typ, + credential.content, + credential.expiration, + ) + .execute(&*self.connection_pool) + .await?; + Ok(()) + } + + pub(crate) async fn remove_emergency_credential(&self, id: i64) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM emergency_credential + WHERE id = ? + "#, + id + ) + .execute(&*self.connection_pool) + .await?; + Ok(()) + } + + pub(crate) async fn remove_emergency_credentials_of_type( + &self, + typ: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM emergency_credential + WHERE type = ? + "#, + typ + ) + .execute(&*self.connection_pool) + .await?; + Ok(()) + } } pub(crate) async fn get_next_unspent_ticketbook<'a, E>( diff --git a/common/credential-storage/src/ephemeral_storage.rs b/common/credential-storage/src/ephemeral_storage.rs index 4c11a456e9..cd65bf3cca 100644 --- a/common/credential-storage/src/ephemeral_storage.rs +++ b/common/credential-storage/src/ephemeral_storage.rs @@ -3,7 +3,10 @@ use crate::backends::memory::MemoryEcachTicketbookManager; use crate::error::StorageError; -use crate::models::{BasicTicketbookInformation, RetrievedPendingTicketbook, RetrievedTicketbook}; +use crate::models::{ + BasicTicketbookInformation, EmergencyCredential, EmergencyCredentialContent, + RetrievedPendingTicketbook, RetrievedTicketbook, +}; use crate::storage::Storage; use async_trait::async_trait; use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature; @@ -218,6 +221,38 @@ impl Storage for EphemeralStorage { .await; Ok(()) } + + async fn get_emergency_credential( + &self, + typ: &str, + ) -> Result, Self::StorageError> { + Ok(self.storage_manager.get_emergency_credential(typ).await) + } + + async fn insert_emergency_credential( + &self, + credential: &EmergencyCredentialContent, + ) -> Result<(), Self::StorageError> { + self.storage_manager + .insert_emergency_credential(credential) + .await; + Ok(()) + } + + async fn remove_emergency_credential(&self, id: i64) -> Result<(), Self::StorageError> { + self.storage_manager.remove_emergency_credential(id).await; + Ok(()) + } + + async fn remove_emergency_credentials_of_type( + &self, + typ: &str, + ) -> Result<(), Self::StorageError> { + self.storage_manager + .remove_emergency_credentials_of_type(typ) + .await; + Ok(()) + } } #[cfg(test)] diff --git a/common/credential-storage/src/models.rs b/common/credential-storage/src/models.rs index 5be1c45d29..f98bcd1f09 100644 --- a/common/credential-storage/src/models.rs +++ b/common/credential-storage/src/models.rs @@ -3,6 +3,7 @@ use nym_credentials::{IssuanceTicketBook, IssuedTicketBook}; use nym_ecash_time::Date; +use time::OffsetDateTime; use zeroize::{Zeroize, ZeroizeOnDrop}; pub struct RetrievedTicketbook { @@ -78,3 +79,20 @@ pub struct RawVerificationKey { pub serialised_key: Vec, pub serialization_revision: u8, } + +#[derive(Clone, Debug)] +#[cfg_attr(not(target_arch = "wasm32"), derive(sqlx::FromRow))] +pub struct EmergencyCredential { + pub id: i64, + #[cfg_attr(not(target_arch = "wasm32"), sqlx(flatten))] + pub data: EmergencyCredentialContent, +} + +#[derive(Clone, Debug)] +#[cfg_attr(not(target_arch = "wasm32"), derive(sqlx::FromRow))] +pub struct EmergencyCredentialContent { + #[cfg_attr(not(target_arch = "wasm32"), sqlx(rename = "type"))] + pub typ: String, + pub content: Vec, + pub expiration: Option, +} diff --git a/common/credential-storage/src/persistent_storage/mod.rs b/common/credential-storage/src/persistent_storage/mod.rs index b3302983ee..debef28093 100644 --- a/common/credential-storage/src/persistent_storage/mod.rs +++ b/common/credential-storage/src/persistent_storage/mod.rs @@ -3,6 +3,7 @@ mod legacy_helpers; +use crate::models::{EmergencyCredential, EmergencyCredentialContent}; use crate::{ backends::sqlite::{ get_next_unspent_ticketbook, increase_used_ticketbook_tickets, SqliteEcashTicketbookManager, @@ -401,4 +402,36 @@ impl Storage for PersistentStorage { .await?; Ok(()) } + + async fn get_emergency_credential( + &self, + typ: &str, + ) -> Result, Self::StorageError> { + Ok(self.storage_manager.get_emergency_credential(typ).await?) + } + + async fn insert_emergency_credential( + &self, + credential: &EmergencyCredentialContent, + ) -> Result<(), Self::StorageError> { + self.storage_manager + .insert_emergency_credential(credential) + .await?; + Ok(()) + } + + async fn remove_emergency_credential(&self, id: i64) -> Result<(), Self::StorageError> { + self.storage_manager.remove_emergency_credential(id).await?; + Ok(()) + } + + async fn remove_emergency_credentials_of_type( + &self, + typ: &str, + ) -> Result<(), Self::StorageError> { + self.storage_manager + .remove_emergency_credentials_of_type(typ) + .await?; + Ok(()) + } } diff --git a/common/credential-storage/src/storage.rs b/common/credential-storage/src/storage.rs index add8131e12..483a4571c5 100644 --- a/common/credential-storage/src/storage.rs +++ b/common/credential-storage/src/storage.rs @@ -1,7 +1,10 @@ // Copyright 2022-2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::models::{BasicTicketbookInformation, RetrievedPendingTicketbook, RetrievedTicketbook}; +use crate::models::{ + BasicTicketbookInformation, EmergencyCredential, EmergencyCredentialContent, + RetrievedPendingTicketbook, RetrievedTicketbook, +}; use async_trait::async_trait; use nym_compact_ecash::VerificationKeyAuth; use nym_credentials::ecash::bandwidth::serialiser::keys::EpochVerificationKey; @@ -108,4 +111,21 @@ pub trait Storage: Clone + Send + Sync { &self, signatures: &AggregatedExpirationDateSignatures, ) -> Result<(), Self::StorageError>; + + async fn get_emergency_credential( + &self, + typ: &str, + ) -> Result, Self::StorageError>; + + async fn insert_emergency_credential( + &self, + credential: &EmergencyCredentialContent, + ) -> Result<(), Self::StorageError>; + + async fn remove_emergency_credential(&self, id: i64) -> Result<(), Self::StorageError>; + + async fn remove_emergency_credentials_of_type( + &self, + typ: &str, + ) -> Result<(), Self::StorageError>; } diff --git a/common/credential-verification/src/lib.rs b/common/credential-verification/src/lib.rs index 3f2b77f840..430674c991 100644 --- a/common/credential-verification/src/lib.rs +++ b/common/credential-verification/src/lib.rs @@ -13,6 +13,7 @@ use tracing::*; pub use client_bandwidth::*; pub use error::*; +pub use upgrade_mode::UpgradeModeState; pub mod bandwidth_storage_manager; mod client_bandwidth; diff --git a/common/credential-verification/src/upgrade_mode.rs b/common/credential-verification/src/upgrade_mode.rs index 81385bece1..7dec1010f7 100644 --- a/common/credential-verification/src/upgrade_mode.rs +++ b/common/credential-verification/src/upgrade_mode.rs @@ -12,7 +12,7 @@ use std::time::Duration; use thiserror::Error; use time::OffsetDateTime; use tokio::sync::{Notify, RwLock}; -use tracing::{debug, error}; +use tracing::{debug, error, info}; #[derive(Debug, Error)] pub enum UpgradeModeEnableError { @@ -101,6 +101,10 @@ impl UpgradeModeDetails { } } + pub fn state(&self) -> &UpgradeModeState { + &self.state + } + pub fn enabled(&self) -> bool { self.state.upgrade_mode_enabled() } @@ -156,6 +160,7 @@ impl UpgradeModeDetails { // note: if attestation has been returned, it means we're definitely in upgrade mode // (otherwise it wouldn't have existed in the state) + info!("managed to initialise upgrade mode through received JWT"); Ok(()) } @@ -198,6 +203,10 @@ impl UpgradeModeState { } } + pub fn attester_pubkey(&self) -> ed25519::PublicKey { + self.inner.expected_attester_public_key + } + pub async fn attestation(&self) -> Option { self.inner.expected_attestation.read().await.clone() } diff --git a/common/upgrade-mode-check/src/error.rs b/common/upgrade-mode-check/src/error.rs index 3b2f2b5b12..acb9ec198c 100644 --- a/common/upgrade-mode-check/src/error.rs +++ b/common/upgrade-mode-check/src/error.rs @@ -9,6 +9,9 @@ pub enum UpgradeModeCheckError { #[error("failed to decode jwt metadata: {source}")] TokenMetadataDecodeFailure { source: jwt_simple::Error }, + #[error("the upgrade mode JWT is malformed")] + MalformedToken, + #[error("the jwt metadata didn't contain explicit public key")] MissingTokenPublicKey, diff --git a/common/upgrade-mode-check/src/jwt.rs b/common/upgrade-mode-check/src/jwt.rs index 64c436b23e..42329f80a5 100644 --- a/common/upgrade-mode-check/src/jwt.rs +++ b/common/upgrade-mode-check/src/jwt.rs @@ -4,7 +4,10 @@ use crate::{UpgradeModeAttestation, UpgradeModeCheckError}; use jwt_simple::claims::Claims; use jwt_simple::common::{KeyMetadata, VerificationOptions}; -use jwt_simple::prelude::{EdDSAKeyPairLike, EdDSAPublicKeyLike}; +use jwt_simple::prelude::{ + Base64UrlSafeNoPadding, EdDSAKeyPairLike, EdDSAPublicKeyLike, JWTClaims, +}; +use jwt_simple::reexports::ct_codecs::Decoder; use jwt_simple::token::Token; use nym_crypto::asymmetric::ed25519; use std::collections::HashSet; @@ -76,12 +79,26 @@ pub fn validate_upgrade_mode_jwt( Ok(attestation) } +/// Attempt to extract the upgrade mode JWT payload from the provided token +pub fn try_decode_upgrade_mode_jwt_claims( + token: &str, +) -> Result, UpgradeModeCheckError> { + let mut parts = token.split('.'); + let _header = parts.next().ok_or(UpgradeModeCheckError::MalformedToken)?; + let claims_b64 = parts.next().ok_or(UpgradeModeCheckError::MalformedToken)?; + let claims_bytes = Base64UrlSafeNoPadding::decode_to_vec(claims_b64, None) + .map_err(|_| UpgradeModeCheckError::MalformedToken)?; + + serde_json::from_slice(&claims_bytes).map_err(|_| UpgradeModeCheckError::MalformedToken) +} + #[cfg(test)] mod tests { use super::*; use crate::generate_new_attestation; use nym_crypto::asymmetric::ed25519; use nym_test_utils::helpers::deterministic_rng; + use time::OffsetDateTime; #[test] fn generate_and_validate_jwt() { @@ -142,4 +159,58 @@ mod tests { // we don't care about issuer assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, None).is_ok()); } + + #[test] + fn decode_upgrade_mode_claims() { + let invalid_jwts = [ + "", + "invalidSections", + "also.invalid.sections", + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImp3ayI6IkZCdWsxS2lqS3ZwQ3VrU1Zhc0xoN1k1REZTZEdnVzU5WThQOUhWTDh2Mzk5In0.eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImp3ayI6IkZCdWsxS2lqS3ZwQ3VrU1Zhc0xoN1k1REZTZEdnVzU5WThQOUhWTDh2Mzk5In0.eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImp3ayI6IkZCdWsxS2lqS3ZwQ3VrU1Zhc0xoN1k1REZTZEdnVzU5WThQOUhWTDh2Mzk5In0", + ]; + + let attestation_key = ed25519::PrivateKey::from_bytes(&[ + 108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248, + 163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161, + ]) + .unwrap(); + let jwt_key = ed25519::PrivateKey::from_bytes(&[ + 152, 17, 144, 255, 213, 219, 246, 208, 109, 33, 100, 73, 1, 141, 32, 63, 141, 89, 167, + 2, 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32, + ]) + .unwrap(); + let jwt_keys = ed25519::KeyPair::from(jwt_key); + + let validity = Duration::from_secs(60 * 60); + let attestation = generate_new_attestation(&attestation_key, vec![*jwt_keys.public_key()]); + let valid_jwt = generate_jwt_for_upgrade_mode_attestation( + attestation.clone(), + validity, + &jwt_keys, + Some("nym-credential-proxy"), + ); + + for invalid in invalid_jwts { + assert!(try_decode_upgrade_mode_jwt_claims(invalid).is_err()) + } + + let decoded = try_decode_upgrade_mode_jwt_claims(&valid_jwt).unwrap(); + assert_eq!(decoded.issuer.unwrap(), "nym-credential-proxy"); + assert_eq!(decoded.custom, attestation); + + // unfortunately we can't inject current time when constructing the JWT so the best we can do is ensure its within error margin + let margin = Duration::from_secs(10); + let now = OffsetDateTime::now_utc(); + let min = now - margin; + let max = now + margin; + let issued = decoded.issued_at.unwrap(); + let issued_time = OffsetDateTime::from_unix_timestamp(issued.as_secs() as i64).unwrap(); + assert!(issued_time >= min && issued_time <= max); + + let min = now - margin + validity; + let max = now + margin + validity; + let expires = decoded.expires_at.unwrap(); + let expires_time = OffsetDateTime::from_unix_timestamp(expires.as_secs() as i64).unwrap(); + assert!(expires_time >= min && expires_time <= max); + } } diff --git a/common/upgrade-mode-check/src/lib.rs b/common/upgrade-mode-check/src/lib.rs index c2ab50284b..7a2e246e2d 100644 --- a/common/upgrade-mode-check/src/lib.rs +++ b/common/upgrade-mode-check/src/lib.rs @@ -11,8 +11,10 @@ pub use attestation::{ pub use error::UpgradeModeCheckError; pub use jwt::{ CREDENTIAL_PROXY_JWT_ISSUER, generate_jwt_for_upgrade_mode_attestation, - validate_upgrade_mode_jwt, + try_decode_upgrade_mode_jwt_claims, validate_upgrade_mode_jwt, }; #[cfg(not(target_arch = "wasm32"))] pub use attestation::attempt_retrieve_attestation; + +pub const UPGRADE_MODE_CREDENTIAL_TYPE: &str = "upgrade_mode_jwt"; diff --git a/common/wireguard-private-metadata/shared/src/lib.rs b/common/wireguard-private-metadata/shared/src/lib.rs index c0d711ac42..6a8eadcc24 100644 --- a/common/wireguard-private-metadata/shared/src/lib.rs +++ b/common/wireguard-private-metadata/shared/src/lib.rs @@ -10,7 +10,9 @@ pub mod routes; pub use models::v0; pub use models::{ AxumErrorResponse, AxumResult, Construct, ErrorResponse, Extract, Request, Response, Version, - error::Error as ModelError, interface, latest, v1, v2, + error::Error as ModelError, + interface::{self, AvailableBandwidth}, + latest, v1, v2, }; fn make_bincode_serializer() -> impl bincode::Options { diff --git a/common/wireguard-private-metadata/shared/src/models/interface.rs b/common/wireguard-private-metadata/shared/src/models/interface.rs index c97db77d2a..317e6680ca 100644 --- a/common/wireguard-private-metadata/shared/src/models/interface.rs +++ b/common/wireguard-private-metadata/shared/src/models/interface.rs @@ -229,3 +229,45 @@ impl Extract for Response { } } } + +#[derive(Debug, Clone, Copy)] +pub struct AvailableBandwidth { + pub bandwidth_bytes: i64, + pub upgrade_mode: Option, +} + +impl From for AvailableBandwidth { + fn from(value: v1::AvailableBandwidthResponse) -> Self { + AvailableBandwidth { + bandwidth_bytes: value.available_bandwidth, + upgrade_mode: None, + } + } +} + +impl From for AvailableBandwidth { + fn from(value: v2::AvailableBandwidthResponse) -> Self { + AvailableBandwidth { + bandwidth_bytes: value.available_bandwidth, + upgrade_mode: Some(value.upgrade_mode), + } + } +} + +impl From for AvailableBandwidth { + fn from(value: v1::TopUpResponse) -> Self { + AvailableBandwidth { + bandwidth_bytes: value.available_bandwidth, + upgrade_mode: None, + } + } +} + +impl From for AvailableBandwidth { + fn from(value: v2::TopUpResponse) -> Self { + AvailableBandwidth { + bandwidth_bytes: value.available_bandwidth, + upgrade_mode: Some(value.upgrade_mode), + } + } +} diff --git a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs index 0eb18a602a..ead0fb18f3 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs @@ -13,7 +13,7 @@ use futures::{ channel::{mpsc, oneshot}, SinkExt, StreamExt, }; -use nym_credentials_interface::AvailableBandwidth; +use nym_credentials_interface::{AvailableBandwidth, DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD}; use nym_crypto::aes::cipher::crypto_common::rand_core::RngCore; use nym_crypto::asymmetric::ed25519; use nym_gateway_requests::authenticate::AuthenticateRequest; @@ -35,6 +35,7 @@ use nym_node_metrics::events::MetricsEvent; use nym_sphinx::DestinationAddressBytes; use nym_task::ShutdownToken; use rand::CryptoRng; +use std::cmp::max; use std::net::SocketAddr; use std::time::Duration; use thiserror::Error; @@ -528,6 +529,35 @@ impl FreshHandler { } } + /// Determine the amount of remaining bandwidth the authenticated client should see. + /// This depends on whether the bandwidth stored persistently had already expired (in which case it's set back to 0) + /// and whether the upgrade mode is enabled. In that case the minimum constant amount is returned. + async fn authenticated_bandwidth_bytes( + &self, + client_id: i64, + ) -> Result { + // 1. get the actual registered bandwidth + let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?; + + // 2. check if it had already expired + let true_remaining_bandwidth = if available_bandwidth.expired() { + self.shared_state.storage.reset_bandwidth(client_id).await?; + 0 + } else { + available_bandwidth.bytes + }; + + // 3. perform upgrade mode adjustments + if self.upgrade_mode_enabled() { + Ok(max( + true_remaining_bandwidth, + DEFAULT_MIXNET_REQUEST_BANDWIDTH_THRESHOLD + 1, + )) + } else { + Ok(true_remaining_bandwidth) + } + } + /// Tries to handle the received authentication request by checking correctness of the received data. /// /// # Arguments @@ -594,14 +624,7 @@ impl FreshHandler { .await?; // check the bandwidth - let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?; - - let bandwidth_remaining = if available_bandwidth.expired() { - self.shared_state.storage.reset_bandwidth(client_id).await?; - 0 - } else { - available_bandwidth.bytes - }; + let bandwidth_remaining = self.authenticated_bandwidth_bytes(client_id).await?; Ok(InitialAuthResult::new( Some(ClientDetails::new( @@ -681,14 +704,7 @@ impl FreshHandler { .await?; // finally check and retrieve client's bandwidth - let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?; - - let bandwidth_remaining = if available_bandwidth.expired() { - self.shared_state.storage.reset_bandwidth(client_id).await?; - 0 - } else { - available_bandwidth.bytes - }; + let bandwidth_remaining = self.authenticated_bandwidth_bytes(client_id).await?; Ok(InitialAuthResult::new( Some(ClientDetails::new( diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 7f08a5e52c..ba891bd716 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -49,7 +49,7 @@ pub use nym_sdk::{NymApiTopologyProvider, NymApiTopologyProviderConfig, UserAgen pub(crate) mod client_handling; pub(crate) mod internal_service_providers; mod stale_data_cleaner; -pub(crate) mod upgrade_mode; +pub mod upgrade_mode; #[derive(Debug, Clone)] pub struct LocalNetworkRequesterOpts { @@ -122,7 +122,7 @@ impl GatewayTasksBuilder { metrics: NymNodeMetrics, mnemonic: Arc>, user_agent: UserAgent, - upgrade_mode_attester_public_key: ed25519::PublicKey, + upgrade_mode_state: UpgradeModeState, shutdown_tracker: ShutdownTracker, ) -> GatewayTasksBuilder { GatewayTasksBuilder { @@ -137,7 +137,7 @@ impl GatewayTasksBuilder { mix_packet_sender, metrics_sender, metrics, - upgrade_mode_state: UpgradeModeState::new(upgrade_mode_attester_public_key), + upgrade_mode_state, mnemonic, shutdown_tracker, ecash_manager: None, diff --git a/gateway/src/node/upgrade_mode/watcher.rs b/gateway/src/node/upgrade_mode/watcher.rs index c798ddc55b..509e89c5f5 100644 --- a/gateway/src/node/upgrade_mode/watcher.rs +++ b/gateway/src/node/upgrade_mode/watcher.rs @@ -10,11 +10,17 @@ use nym_credential_verification::upgrade_mode::{ use nym_task::ShutdownToken; use nym_upgrade_mode_check::attempt_retrieve_attestation; use std::time::Duration; +use time::OffsetDateTime; use tokio::task::JoinHandle; use tokio::time::Instant; use tracing::{debug, error, info, trace}; use url::Url; +/// Specifies the threshold for retrieval failures that will trigger disabling upgrade mode. +/// This assumes the file has been removed incorrectly and has been replaced by some placeholder 404 +/// page that does not deserialise correctly +const FAILURE_THRESHOLD: usize = 5; + pub struct UpgradeModeWatcher { // default polling interval regular_polling_interval: Duration, @@ -35,6 +41,8 @@ pub struct UpgradeModeWatcher { user_agent: UserAgent, shutdown_token: ShutdownToken, + + consecutive_retrieval_failures: usize, } impl UpgradeModeWatcher { @@ -58,6 +66,7 @@ impl UpgradeModeWatcher { upgrade_mode_state, user_agent, shutdown_token, + consecutive_retrieval_failures: 0, } } @@ -65,7 +74,7 @@ impl UpgradeModeWatcher { self.check_request_sender.clone() } - async fn try_update_state(&self) { + async fn try_update_state(&mut self) { match attempt_retrieve_attestation( self.attestation_url.as_str(), Some(self.user_agent.clone()), @@ -73,10 +82,28 @@ impl UpgradeModeWatcher { .await { Err(err) => { + self.consecutive_retrieval_failures += 1; info!("upgrade mode attestation is not available at this time"); - debug!("retrieval error: {err}") + debug!("retrieval error: {err}"); + + if self.upgrade_mode_state.upgrade_mode_enabled() + && self.consecutive_retrieval_failures > FAILURE_THRESHOLD + { + self.upgrade_mode_state + .try_set_expected_attestation(None) + .await + } else { + self.upgrade_mode_state + .update_last_queried(OffsetDateTime::now_utc()); + } } Ok(attestation) => { + self.consecutive_retrieval_failures = 0; + if attestation.is_some() { + info!("retrieved valid attestation: attempting to begin upgrade mode") + } else { + info!("attempting to disable upgrade mode") + } self.upgrade_mode_state .try_set_expected_attestation(attestation) .await @@ -110,7 +137,8 @@ impl UpgradeModeWatcher { async fn run(&mut self) { info!("starting the update mode watcher"); - let check_wait = tokio::time::sleep(self.regular_polling_interval); + // make sure the first check happens immediately + let check_wait = tokio::time::sleep(Duration::new(0, 0)); tokio::pin!(check_wait); loop { diff --git a/nym-authenticator-client/src/error.rs b/nym-authenticator-client/src/error.rs index 142fbe6981..abf18e47ca 100644 --- a/nym-authenticator-client/src/error.rs +++ b/nym-authenticator-client/src/error.rs @@ -40,6 +40,12 @@ pub enum AuthenticationClientError { source: nym_bandwidth_controller::error::BandwidthControllerError, }, + #[error("failed to retrieve upgrade mode token")] + UpgradeModeToken { + #[source] + source: nym_bandwidth_controller::error::BandwidthControllerError, + }, + #[error("unknown authenticator version number")] UnsupportedAuthenticatorVersion, diff --git a/nym-authenticator-client/src/lib.rs b/nym-authenticator-client/src/lib.rs index 10b651b184..c09a766696 100644 --- a/nym-authenticator-client/src/lib.rs +++ b/nym-authenticator-client/src/lib.rs @@ -8,17 +8,18 @@ use nym_registration_common::GatewayData; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use tracing::{debug, error, trace}; +use tracing::{debug, error, trace, warn}; use crate::error::Result; use crate::mixnet_listener::{MixnetMessageBroadcastReceiver, MixnetMessageInputSender}; use crate::types::{AvailableBandwidthClientResponse, TopUpClientResponse}; +use nym_authenticator_requests::models::BandwidthClaim; use nym_authenticator_requests::traits::UpgradeModeStatus; use nym_authenticator_requests::{ AuthenticatorVersion, client_message::ClientMessage, response::AuthenticatorResponse, traits::Id, v2, v3, v4, v5, v6, }; -use nym_credentials_interface::{CredentialSpendingData, TicketType}; +use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData, TicketType}; use nym_sdk::mixnet::{IncludedSurbs, Recipient, ReconstructedMessage}; use nym_service_provider_requests_common::{Protocol, ServiceProviderTypeExt}; use nym_wireguard_types::PeerPublicKey; @@ -205,6 +206,57 @@ impl AuthenticatorClient { } } + async fn produce_bandwidth_claim( + &self, + controller: &dyn BandwidthTicketProvider, + upgrade_mode_enabled: bool, + ticketbook_type: TicketType, + ) -> Result { + if upgrade_mode_enabled { + match controller + .get_upgrade_mode_token() + .await + .map_err(|source| AuthenticationClientError::UpgradeModeToken { source })? + { + None => warn!( + "the wireguard node is in the upgrade mode, whilst we do not have an upgrade mode token - we will have to use normal ZK nym instead" + ), + + Some(upgrade_mode_token) => { + return Ok(BandwidthClaim { + credential: BandwidthCredential::UpgradeModeJWT { + token: upgrade_mode_token, + }, + kind: ticketbook_type, + }); + } + } + } + + let credential = controller + .get_ecash_ticket( + ticketbook_type, + self.auth_recipient.gateway(), + DEFAULT_TICKETS_TO_SPEND, + ) + .await + .map_err(|source| AuthenticationClientError::GetTicket { + ticketbook_type, + source, + })? + .data; + + let credential = credential + .try_into() + .inspect_err(|err| { + error!( + "failed to convert {ticketbook_type} ticket to a valid BandwidthClaim: {err}" + ) + }) + .map_err(|_| AuthenticationClientError::InternalError)?; + Ok(credential) + } + pub async fn register_wireguard( &mut self, controller: &dyn BandwidthTicketProvider, @@ -255,33 +307,19 @@ impl AuthenticatorClient { &self.ip_addr, &pending_registration_response ); - // This call takes care of updating the credential count in storage, so failure of this must be counted as credential waste - let credential = Some( - controller - .get_ecash_ticket( - ticketbook_type, - self.auth_recipient.gateway(), - DEFAULT_TICKETS_TO_SPEND, - ) - .await - .map_err(|source| RegistrationError::CredentialSent { - source: AuthenticationClientError::GetTicket { - ticketbook_type, - source, - }, - })? - .data, - ); - let credential = credential - .map(TryInto::try_into) - .transpose() - .inspect_err(|err| error!("failed to convert {ticketbook_type} ticket to a valid BandwidthClaim: {err}")) - .map_err(|_| RegistrationError::CredentialSent { - source: AuthenticationClientError::InternalError, - })?; + // if the node reports upgrade mode, we can use the corresponding token for registration + // instead of spending zk-nym ticket + let upgrade_mode_enabled = pending_registration_response + .upgrade_mode_status() + .is_enabled(); + + let bandwidth_claim = self + .produce_bandwidth_claim(controller, upgrade_mode_enabled, ticketbook_type) + .await + .map_err(|source| RegistrationError::CredentialSent { source })?; let finalized_message = pending_registration_response - .finalise_registration(self.keypair.private_key(), credential); + .finalise_registration(self.keypair.private_key(), Some(bandwidth_claim)); let client_message = ClientMessage::Final(finalized_message); trace!("sending final msg to {}: {client_message:?}", &self.ip_addr); @@ -421,4 +459,32 @@ impl AuthenticatorClient { current_upgrade_mode_status, }) } + + pub async fn check_upgrade_mode(&mut self, upgrade_mode_jwt: String) -> Result { + let check_um_message = match self.auth_version { + AuthenticatorVersion::V1 + | AuthenticatorVersion::V2 + | AuthenticatorVersion::V3 + | AuthenticatorVersion::V4 + | AuthenticatorVersion::V5 + | AuthenticatorVersion::UNKNOWN => { + return Err(AuthenticationClientError::UnsupportedAuthenticatorVersion); + } + + AuthenticatorVersion::V6 => ClientMessage::UpgradeModeCheck(Box::new( + v6::upgrade_mode_check::UpgradeModeCheckRequest::UpgradeModeJwt { + token: upgrade_mode_jwt, + }, + )), + }; + + let response = self.send_and_wait_for_response(&check_um_message).await?; + let AuthenticatorResponse::UpgradeMode(upgrade_mode_check_response) = response else { + return Err(AuthenticationClientError::InvalidGatewayAuthResponse); + }; + + Ok(upgrade_mode_check_response + .upgrade_mode_status() + .is_enabled()) + } } diff --git a/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs index b544f80792..06931852fc 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs @@ -12,6 +12,11 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info}; use url::Url; +/// Specifies the threshold for retrieval failures that will trigger disabling upgrade mode. +/// This assumes the file has been removed incorrectly and has been replaced by some placeholder 404 +/// page that does not deserialise correctly +const FAILURE_THRESHOLD: usize = 5; + pub struct AttestationWatcher { // default polling interval regular_polling_interval: Duration, @@ -28,6 +33,8 @@ pub struct AttestationWatcher { jwt_validity: Duration, upgrade_mode_state: UpgradeModeState, + + consecutive_retrieval_failures: usize, } impl AttestationWatcher { @@ -49,6 +56,7 @@ impl AttestationWatcher { upgrade_mode_state: UpgradeModeState { inner: Arc::new(Default::default()), }, + consecutive_retrieval_failures: 0, } } @@ -56,7 +64,7 @@ impl AttestationWatcher { self.upgrade_mode_state.clone() } - async fn try_update_state(&self) { + async fn try_update_state(&mut self) { match attempt_retrieve_attestation( self.attestation_url.as_str(), Some(generate_user_agent!()), @@ -64,10 +72,26 @@ impl AttestationWatcher { .await { Err(err) => { + self.consecutive_retrieval_failures += 1; info!("upgrade mode attestation is not available at this time"); - debug!("retrieval error: {err}") + debug!("retrieval error: {err}"); + + if self.upgrade_mode_state.has_attestation().await + && self.consecutive_retrieval_failures > FAILURE_THRESHOLD + { + self.upgrade_mode_state + .update( + None, + self.expected_attester_public_key, + &self.jwt_signing_keys, + self.jwt_validity, + ) + .await + } } Ok(attestation) => { + self.consecutive_retrieval_failures = 0; + self.upgrade_mode_state .update( attestation, @@ -80,9 +104,10 @@ impl AttestationWatcher { } } - pub async fn run_forever(self, cancellation_token: CancellationToken) { + pub async fn run_forever(mut self, cancellation_token: CancellationToken) { info!("starting the attestation watcher task"); + // make sure the first check happens immediately let check_wait = tokio::time::sleep(Duration::new(0, 0)); tokio::pin!(check_wait); diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index 6d43b0aa97..f431886ebf 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -57,6 +57,7 @@ nym-client-core-config-types = { path = "../common/client-core/config-types", fe "disk-persistence", ] } nym-config = { path = "../common/config" } +nym-credential-verification = { path = "../common/credential-verification" } nym-crypto = { path = "../common/crypto", features = ["asymmetric", "rand"] } nym-nonexhaustive-delayqueue = { path = "../common/nonexhaustive-delayqueue" } nym-mixnet-client = { path = "../common/client-libs/mixnet-client" } diff --git a/nym-node/nym-node-requests/Cargo.toml b/nym-node/nym-node-requests/Cargo.toml index 82119f47c8..ad1ed3013a 100644 --- a/nym-node/nym-node-requests/Cargo.toml +++ b/nym-node/nym-node-requests/Cargo.toml @@ -12,7 +12,6 @@ license.workspace = true [dependencies] celes = { workspace = true } # country codes -humantime = { workspace = true } humantime-serde = { workspace = true } schemars = { workspace = true, features = ["preserve_order"] } serde = { workspace = true, features = ["derive"] } @@ -21,6 +20,7 @@ strum = { workspace = true, features = ["derive"] } strum_macros = { workspace = true } time = { workspace = true, features = ["serde", "formatting", "parsing"] } thiserror = { workspace = true } +url = { workspace = true, features = ["serde"] } nym-crypto = { path = "../../common/crypto", features = [ "asymmetric", @@ -29,6 +29,7 @@ nym-crypto = { path = "../../common/crypto", features = [ nym-exit-policy = { path = "../../common/exit-policy" } nym-noise-keys = { path = "../../common/nymnoise/keys" } nym-wireguard-types = { path = "../../common/wireguard-types", default-features = false } +nym-upgrade-mode-check = { path = "../../common/upgrade-mode-check", features = ["openapi"] } # feature-specific dependencies: diff --git a/nym-node/nym-node-requests/src/api/v1/mod.rs b/nym-node/nym-node-requests/src/api/v1/mod.rs index 544633944f..e19b611f1b 100644 --- a/nym-node/nym-node-requests/src/api/v1/mod.rs +++ b/nym-node/nym-node-requests/src/api/v1/mod.rs @@ -7,6 +7,7 @@ pub mod health; pub mod ip_packet_router; pub mod metrics; pub mod mixnode; +pub mod network; pub mod network_requester; pub mod node; pub mod node_load; diff --git a/nym-node/nym-node-requests/src/api/v1/network.rs b/nym-node/nym-node-requests/src/api/v1/network.rs new file mode 100644 index 0000000000..13811deb67 --- /dev/null +++ b/nym-node/nym-node-requests/src/api/v1/network.rs @@ -0,0 +1,4 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod models; diff --git a/nym-node/nym-node-requests/src/api/v1/network/models.rs b/nym-node/nym-node-requests/src/api/v1/network/models.rs new file mode 100644 index 0000000000..be5d01b198 --- /dev/null +++ b/nym-node/nym-node-requests/src/api/v1/network/models.rs @@ -0,0 +1,28 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey; +use nym_upgrade_mode_check::UpgradeModeAttestation; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use url::Url; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct UpgradeModeStatus { + pub enabled: bool, + + #[serde(with = "time::serde::rfc3339")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub last_queried: OffsetDateTime, + + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub attestation_provider: Url, + + #[serde(with = "bs58_ed25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub attester_pubkey: ed25519::PublicKey, + + pub published_attestation: Option, +} diff --git a/nym-node/nym-node-requests/src/lib.rs b/nym-node/nym-node-requests/src/lib.rs index 2b539f831e..aaf63baa25 100644 --- a/nym-node/nym-node-requests/src/lib.rs +++ b/nym-node/nym-node-requests/src/lib.rs @@ -36,6 +36,7 @@ pub mod routes { pub const AUXILIARY: &str = "/auxiliary-details"; pub const HEALTH: &str = "/health"; pub const LOAD: &str = "/load"; + pub const NETWORK: &str = "/network"; pub const SWAGGER: &str = "/swagger"; pub const GATEWAY: &str = "/gateway"; @@ -49,6 +50,7 @@ pub mod routes { // define helper functions to get absolute routes absolute_route!(health_absolute, v1_absolute(), HEALTH); absolute_route!(load_absolute, v1_absolute(), LOAD); + absolute_route!(network_absolute, v1_absolute(), NETWORK); absolute_route!(roles_absolute, v1_absolute(), ROLES); absolute_route!(build_info_absolute, v1_absolute(), BUILD_INFO); absolute_route!(host_info_absolute, v1_absolute(), HOST_INFO); @@ -131,6 +133,17 @@ pub mod routes { pub mod ip_packet_router { // use super::*; } + + pub mod network { + use super::*; + pub const UPGRADE_MODE_STATUS: &str = "/upgrade-mode-status"; + + absolute_route!( + upgrade_mode_status_absolute, + network_absolute(), + UPGRADE_MODE_STATUS + ); + } } } } diff --git a/nym-node/src/cli/commands/run/mod.rs b/nym-node/src/cli/commands/run/mod.rs index 36f77c7b29..450a88a761 100644 --- a/nym-node/src/cli/commands/run/mod.rs +++ b/nym-node/src/cli/commands/run/mod.rs @@ -29,7 +29,7 @@ fn check_public_ips(ips: &[IpAddr], local: bool) -> Result<(), NymNodeError> { warn!("\n##### WARNING #####"); for ip in suspicious_ip { warn!( - "The 'public' IP address you're trying to announce: {ip} may not be accessible to other clients.\ + "The 'public' IP address you're trying to announce: {ip} may not be accessible to other clients. \ Please make sure this is what you intended to announce.\ You can ignore this warning if you're running setup on a local network " ) diff --git a/nym-node/src/cli/helpers.rs b/nym-node/src/cli/helpers.rs index e7eb81623f..08ccef0857 100644 --- a/nym-node/src/cli/helpers.rs +++ b/nym-node/src/cli/helpers.rs @@ -9,6 +9,7 @@ use crate::error::NymNodeError; use celes::Country; use clap::Args; use clap::builder::ArgPredicate; +use nym_crypto::asymmetric::ed25519; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use url::Url; @@ -429,12 +430,22 @@ pub(crate) struct EntryGatewayArgs { pub(crate) mnemonic: Option, /// Endpoint to query to retrieve current upgrade mode attestation. + /// This argument should never be set outside testnets and local networks. #[clap( long, env = NYMNODE_UPGRADE_MODE_ATTESTATION_URL_ARG )] #[zeroize(skip)] pub(crate) upgrade_mode_attestation_url: Option, + + /// Expected public key of the entity signing the published attestation. + /// This argument should never be set outside testnets and local networks. + #[clap( + long, + env = NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY_ARG + )] + #[zeroize(skip)] + pub(crate) upgrade_mode_attester_public_key: Option, } impl EntryGatewayArgs { @@ -465,6 +476,9 @@ impl EntryGatewayArgs { if let Some(upgrade_mode_attestation_url) = self.upgrade_mode_attestation_url.take() { section.upgrade_mode.attestation_url = upgrade_mode_attestation_url } + if let Some(upgrade_mode_attester_public_key) = self.upgrade_mode_attester_public_key { + section.upgrade_mode.attester_public_key = upgrade_mode_attester_public_key + } section } diff --git a/nym-node/src/config/gateway_tasks.rs b/nym-node/src/config/gateway_tasks.rs index 16517b77bc..ca47c15538 100644 --- a/nym-node/src/config/gateway_tasks.rs +++ b/nym-node/src/config/gateway_tasks.rs @@ -244,6 +244,7 @@ pub struct UpgradeModeWatcher { #[serde(with = "bs58_ed25519_pubkey")] pub attester_public_key: ed25519::PublicKey, + #[serde(default)] pub debug: UpgradeModeWatcherDebug, } diff --git a/nym-node/src/config/mod.rs b/nym-node/src/config/mod.rs index edec2a80af..08b578760e 100644 --- a/nym-node/src/config/mod.rs +++ b/nym-node/src/config/mod.rs @@ -695,6 +695,10 @@ impl ReplayProtectionDebug { pub const DEFAULT_BLOOMFILTER_MINIMUM_PACKETS_PER_SECOND_SIZE: usize = 200; pub fn validate(&self) -> Result<(), NymNodeError> { + if self.unsafe_disabled { + return Ok(()); + } + if self.false_positive_rate >= 1.0 || self.false_positive_rate <= 0.0 { return Err(NymNodeError::config_validation_failure( "false positive rate for replay detection can't be larger than (or equal to) 1 or smaller than (or equal to) 0", diff --git a/nym-node/src/env.rs b/nym-node/src/env.rs index 8f9a6d65b8..1564d087a4 100644 --- a/nym-node/src/env.rs +++ b/nym-node/src/env.rs @@ -63,6 +63,8 @@ pub mod vars { pub const NYMNODE_MNEMONIC_ARG: &str = "NYMNODE_MNEMONIC"; pub const NYMNODE_UPGRADE_MODE_ATTESTATION_URL_ARG: &str = "NYMNODE_UPGRADE_MODE_ATTESTATION_URL"; + pub const NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY_ARG: &str = + "NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY"; // exit gateway: pub const NYMNODE_UPSTREAM_EXIT_POLICY_ARG: &str = "NYMNODE_UPSTREAM_EXIT_POLICY"; diff --git a/nym-node/src/node/http/router/api/v1/mod.rs b/nym-node/src/node/http/router/api/v1/mod.rs index d0f2239f1d..b9a74b27bd 100644 --- a/nym-node/src/node/http/router/api/v1/mod.rs +++ b/nym-node/src/node/http/router/api/v1/mod.rs @@ -14,6 +14,7 @@ pub mod ip_packet_router; pub mod load; pub mod metrics; pub mod mixnode; +pub mod network; pub mod network_requester; pub mod node; pub mod openapi; @@ -34,6 +35,7 @@ pub(super) fn routes(config: Config) -> Router { Router::new() .route(v1::HEALTH, get(health::root_health)) .route(v1::LOAD, get(load::root_load)) + .nest(v1::NETWORK, network::routes()) .nest(v1::METRICS, metrics::routes(config.metrics)) .nest(v1::BRIDGES, bridges::routes(config.bridges)) .nest(v1::GATEWAY, gateway::routes(config.gateway)) diff --git a/nym-node/src/node/http/router/api/v1/network/mod.rs b/nym-node/src/node/http/router/api/v1/network/mod.rs new file mode 100644 index 0000000000..2d619eba3c --- /dev/null +++ b/nym-node/src/node/http/router/api/v1/network/mod.rs @@ -0,0 +1,14 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node::http::api::v1::network::upgrade_mode::upgrade_mode_status; +use crate::node::http::state::AppState; +use axum::Router; +use axum::routing::get; +use nym_node_requests::routes::api::v1::network; + +pub mod upgrade_mode; + +pub(crate) fn routes() -> Router { + Router::new().route(network::UPGRADE_MODE_STATUS, get(upgrade_mode_status)) +} diff --git a/nym-node/src/node/http/router/api/v1/network/upgrade_mode.rs b/nym-node/src/node/http/router/api/v1/network/upgrade_mode.rs new file mode 100644 index 0000000000..f487c9a4fe --- /dev/null +++ b/nym-node/src/node/http/router/api/v1/network/upgrade_mode.rs @@ -0,0 +1,39 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node::http::state::AppState; +use axum::extract::{Query, State}; +use nym_http_api_common::{FormattedResponse, OutputParams}; +use nym_node_requests::api::v1::network::models::UpgradeModeStatus; + +/// Returns current upgrade mode information as perceived by this node. +#[utoipa::path( + get, + path = "/upgrade-mode-status", + context_path = "/api/v1/network", + tag = "Network", + responses( + (status = 200, content( + (UpgradeModeStatus = "application/json"), + (UpgradeModeStatus = "application/yaml"), + (UpgradeModeStatus = "application/bincode") + )) + ), + params(OutputParams) +)] +pub(crate) async fn upgrade_mode_status( + Query(output): Query, + State(state): State, +) -> UpgradeModeStatusResponse { + let output = output.get_output(); + let um_state = &state.upgrade_mode_state.node_state; + output.to_response(UpgradeModeStatus { + enabled: um_state.upgrade_mode_enabled(), + last_queried: um_state.last_queried(), + attestation_provider: state.upgrade_mode_state.attestation_url.clone(), + attester_pubkey: um_state.attester_pubkey(), + published_attestation: um_state.attestation().await, + }) +} + +pub type UpgradeModeStatusResponse = FormattedResponse; diff --git a/nym-node/src/node/http/router/api/v1/openapi.rs b/nym-node/src/node/http/router/api/v1/openapi.rs index 98835e4c59..ac410aba5f 100644 --- a/nym-node/src/node/http/router/api/v1/openapi.rs +++ b/nym-node/src/node/http/router/api/v1/openapi.rs @@ -1,7 +1,6 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::node::http::router::api; use axum::Router; use nym_node_requests::api as api_requests; use nym_node_requests::routes::api::{v1, v1_absolute}; @@ -13,25 +12,31 @@ use utoipa_swagger_ui::SwaggerUi; #[openapi( info(title = "NymNode API"), paths( - api::v1::node::build_information::build_information, - api::v1::node::host_information::host_information, - api::v1::node::roles::roles, - api::v1::node::hardware::host_system, - api::v1::node::description::description, - api::v1::node::auxiliary::auxiliary, - api::v1::metrics::legacy_mixing::legacy_mixing_stats, - api::v1::metrics::packets_stats::packets_stats, - api::v1::metrics::verloc::verloc_stats, - api::v1::metrics::prometheus::prometheus_metrics, - api::v1::health::root_health, - api::v1::load::root_load, - api::v1::gateway::root::root_gateway, - api::v1::gateway::client_interfaces::client_interfaces, - api::v1::gateway::client_interfaces::mixnet_websockets, - api::v1::mixnode::root::root_mixnode, - api::v1::network_requester::root::root_network_requester, - api::v1::network_requester::exit_policy::node_exit_policy, - api::v1::ip_packet_router::root::root_ip_packet_router, + crate::node::http::router::api::v1::metrics::verloc::verloc_stats, + crate::node::http::router::api::v1::metrics::legacy_mixing::legacy_mixing_stats, + crate::node::http::router::api::v1::metrics::wireguard::wireguard_stats, + crate::node::http::router::api::v1::metrics::sessions::sessions_stats, + crate::node::http::router::api::v1::metrics::packets_stats::packets_stats, + crate::node::http::router::api::v1::metrics::prometheus::prometheus_metrics, + crate::node::http::router::api::v1::ip_packet_router::root::root_ip_packet_router, + crate::node::http::router::api::v1::health::root_health, + crate::node::http::router::api::v1::network::upgrade_mode::upgrade_mode_status, + crate::node::http::router::api::v1::mixnode::root::root_mixnode, + crate::node::http::router::api::v1::load::root_load, + crate::node::http::router::api::v1::node::host_information::host_information, + crate::node::http::router::api::v1::node::description::description, + crate::node::http::router::api::v1::node::roles::roles, + crate::node::http::router::api::v1::node::auxiliary::auxiliary, + crate::node::http::router::api::v1::node::build_information::build_information, + crate::node::http::router::api::v1::node::hardware::host_system, + crate::node::http::router::api::v1::authenticator::root::root_authenticator, + crate::node::http::router::api::v1::network_requester::exit_policy::node_exit_policy, + crate::node::http::router::api::v1::network_requester::root::root_network_requester, + crate::node::http::router::api::v1::gateway::client_interfaces::client_interfaces, + crate::node::http::router::api::v1::gateway::client_interfaces::mixnet_websockets, + crate::node::http::router::api::v1::gateway::client_interfaces::wireguard_details, + crate::node::http::router::api::v1::gateway::root::root_gateway, + ), components( schemas( diff --git a/nym-node/src/node/http/state/mod.rs b/nym-node/src/node/http/state/mod.rs index 9cd3348cb6..8ed756aa60 100644 --- a/nym-node/src/node/http/state/mod.rs +++ b/nym-node/src/node/http/state/mod.rs @@ -4,6 +4,7 @@ use crate::node::http::state::load::CachedNodeLoad; use crate::node::http::state::metrics::MetricsAppState; use crate::node::key_rotation::active_keys::ActiveSphinxKeys; +use nym_credential_verification::UpgradeModeState; use nym_crypto::asymmetric::ed25519; use nym_node_metrics::NymNodeMetrics; use nym_noise_keys::VersionedNoiseKey; @@ -12,6 +13,7 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; use tokio::time::Instant; +use url::Url; pub mod load; pub mod metrics; @@ -23,6 +25,12 @@ pub(crate) struct StaticNodeInformation { pub(crate) hostname: Option, } +#[derive(Clone)] +pub(crate) struct UpgradeModeApiState { + pub(crate) node_state: UpgradeModeState, + pub(crate) attestation_url: Url, +} + #[derive(Clone)] pub(crate) struct AppState { pub(crate) startup_time: Instant, @@ -34,15 +42,18 @@ pub(crate) struct AppState { pub(crate) cached_load: CachedNodeLoad, pub(crate) metrics: MetricsAppState, + + pub(crate) upgrade_mode_state: UpgradeModeApiState, } impl AppState { - #[allow(clippy::new_without_default)] pub(crate) fn new( static_information: StaticNodeInformation, x25519_sphinx_keys: ActiveSphinxKeys, metrics: NymNodeMetrics, verloc: SharedVerlocStats, + upgrade_mode_attestation_url: Url, + upgrade_mode_state: UpgradeModeState, load_cache_ttl: Duration, ) -> Self { AppState { @@ -56,6 +67,10 @@ impl AppState { startup_time: Instant::now(), cached_load: CachedNodeLoad::new(load_cache_ttl), metrics: MetricsAppState { metrics, verloc }, + upgrade_mode_state: UpgradeModeApiState { + node_state: upgrade_mode_state, + attestation_url: upgrade_mode_attestation_url, + }, } } } diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index c0f94eb6da..8ba968ca29 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -39,6 +39,7 @@ use crate::node::shared_network::{ CachedNetwork, CachedTopologyProvider, LocalGatewayNode, NetworkRefresher, }; use nym_bin_common::bin_info; +use nym_credential_verification::UpgradeModeState; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_gateway::node::{ActiveClientsStore, GatewayTasksBuilder, UpgradeModeCheckRequestSender}; use nym_mixnet_client::client::ActiveConnections; @@ -373,6 +374,8 @@ pub(crate) struct NymNode { entry_gateway: GatewayTasksData, + upgrade_mode_state: UpgradeModeState, + #[allow(dead_code)] service_providers: ServiceProvidersData, @@ -461,6 +464,9 @@ impl NymNode { metrics: NymNodeMetrics::new(), verloc_stats: Default::default(), entry_gateway: GatewayTasksData::new(&config.gateway_tasks).await?, + upgrade_mode_state: UpgradeModeState::new( + config.gateway_tasks.upgrade_mode.attester_public_key, + ), service_providers: ServiceProvidersData::new(&config.service_providers)?, wireguard: Some(wireguard_data), config, @@ -625,7 +631,7 @@ impl NymNode { self.metrics.clone(), self.entry_gateway.mnemonic.clone(), Self::user_agent(), - self.config.gateway_tasks.upgrade_mode.attester_public_key, + self.upgrade_mode_state.clone(), self.shutdown_tracker().clone(), ); @@ -861,6 +867,12 @@ impl NymNode { self.active_sphinx_keys()?.clone(), self.metrics.clone(), self.verloc_stats.clone(), + self.config + .gateway_tasks + .upgrade_mode + .attestation_url + .clone(), + self.upgrade_mode_state.clone(), self.config.http.node_load_cache_ttl, ); @@ -1152,7 +1164,7 @@ impl NymNode { } pub(crate) async fn run_minimal_mixnet_processing(mut self) -> Result<(), NymNodeError> { - let noise_config = nym_noise::config::NoiseConfig::new( + let noise_config = NoiseConfig::new( self.x25519_noise_keys.clone(), NoiseNetworkView::new_empty(), self.config.mixnet.debug.initial_connection_timeout, diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 120e1cc969..4f910e1a15 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4466,13 +4466,13 @@ version = "0.1.0" dependencies = [ "async-trait", "celes", - "humantime 2.2.0", "humantime-serde", "nym-bin-common", "nym-crypto", "nym-exit-policy", "nym-http-api-client", "nym-noise-keys", + "nym-upgrade-mode-check", "nym-wireguard-types", "schemars", "serde", @@ -4481,6 +4481,7 @@ dependencies = [ "strum_macros", "thiserror 2.0.12", "time", + "url", "utoipa", ] @@ -4606,6 +4607,7 @@ dependencies = [ "thiserror 2.0.12", "time", "tracing", + "utoipa", ] [[package]] diff --git a/tools/echo-server/Cargo.toml b/tools/echo-server/Cargo.toml index 390f746bcc..f58a0e46b3 100644 --- a/tools/echo-server/Cargo.toml +++ b/tools/echo-server/Cargo.toml @@ -26,7 +26,7 @@ serde = { workspace = true, features = ["derive"] } tracing.workspace = true tracing-subscriber = { workspace = true } bytecodec = { workspace = true } -nym-sdk = { path = "../../sdk/rust/nym-sdk/" } +nym-sdk = { path = "../../sdk/rust/nym-sdk" } bytes.workspace = true dirs.workspace = true clap.workspace = true diff --git a/tools/internal/testnet-manager/src/manager/local_client.rs b/tools/internal/testnet-manager/src/manager/local_client.rs index 8e3aff09c0..3969bfea33 100644 --- a/tools/internal/testnet-manager/src/manager/local_client.rs +++ b/tools/internal/testnet-manager/src/manager/local_client.rs @@ -213,12 +213,14 @@ impl NetworkManager { id, "--enabled-credentials-mode", "true", + "--minimum-gateway-performance", + "0", "--port", &port.to_string(), ]) - .stdout(Stdio::null()) + // .stdout(Stdio::null()) .stdin(Stdio::null()) - .stderr(Stdio::null()) + // .stderr(Stdio::null()) .kill_on_drop(true); if let Some(gateway) = &ctx.gateway { @@ -243,10 +245,10 @@ impl NetworkManager { config_file, r#" -[debug.topology] -minimum_mixnode_performance = 0 -minimum_gateway_performance = 0 -"# + [debug.topology] + minimum_mixnode_performance = 0 + minimum_gateway_performance = 0 + "# )?; ctx.println(format!("\t✅client {id} is ready to use!")); diff --git a/tools/internal/testnet-manager/src/manager/local_nodes.rs b/tools/internal/testnet-manager/src/manager/local_nodes.rs index f710939248..3ed0af70b3 100644 --- a/tools/internal/testnet-manager/src/manager/local_nodes.rs +++ b/tools/internal/testnet-manager/src/manager/local_nodes.rs @@ -577,7 +577,7 @@ impl NetworkManager { )); let id = ctx.nym_node_id(mixnode); cmds.push(format!( - "{bin_canon_display} -c {env_canon_display} run --id {id} --local --unsafe-disable-noise" + "{bin_canon_display} -c {env_canon_display} run --id {id} --local --unsafe-disable-noise --unsafe-disable-replay-protection" )); } @@ -588,7 +588,7 @@ impl NetworkManager { )); let id = ctx.nym_node_id(gateway); cmds.push(format!( - "{bin_canon_display} -c {env_canon_display} run --id {id} --local --unsafe-disable-noise" + "{bin_canon_display} -c {env_canon_display} run --id {id} --local --unsafe-disable-noise --unsafe-disable-replay-protection" )); } From 440aadf1245d081be8f6bc8b9a9c7d2db6737403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Nov 2025 15:04:51 +0000 Subject: [PATCH 19/23] chore: updated default endpoint for retrieving attestation.json (#6207) --- common/network-defaults/src/mainnet.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 291f397482..8e253a77b7 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -55,7 +55,8 @@ pub const NYM_APIS: &[ApiUrlConst] = &[ pub const NYM_VPN_API: &str = "https://nymvpn.com/api/"; -pub const UPGRADE_MODE_ATTESTATION_URL: &str = "https://nym.com/upgrade-mode/attestation.json"; +pub const UPGRADE_MODE_ATTESTATION_URL: &str = + "https://nymtech.net/.wellknown/upgrade-mode/attestation.json"; pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = "3bgffBYcfFkTTXc2npNNn9MkddFZ3H2LrPjXDmnJzrqd"; From d73b7b712753a77d37c85b58074e812cd58003c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Nov 2025 15:04:59 +0000 Subject: [PATCH 20/23] chore: remove support for legacy mixnode within the performance contract (#6205) --- contracts/performance/src/helpers.rs | 23 +---- contracts/performance/src/storage.rs | 105 -------------------- contracts/performance/src/testing/mod.rs | 116 +---------------------- 3 files changed, 6 insertions(+), 238 deletions(-) diff --git a/contracts/performance/src/helpers.rs b/contracts/performance/src/helpers.rs index 51ccb4f4b2..8219910787 100644 --- a/contracts/performance/src/helpers.rs +++ b/contracts/performance/src/helpers.rs @@ -3,7 +3,7 @@ use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdError, StdResult}; use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; -use nym_mixnet_contract_common::{Interval, MixNodeBond, NymNodeBond}; +use nym_mixnet_contract_common::{Interval, NymNodeBond}; use nym_performance_contract_common::{EpochId, NodeId}; use serde::de::DeserializeOwned; use std::ops::Deref; @@ -51,15 +51,10 @@ pub(crate) trait MixnetContractQuerier { fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { let mixnet_contract_address = address.into(); - // 1. check if it's a nym-node if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { return Ok(!nym_node.is_unbonding); } - // 2. try a legacy mixnode - if let Some(nym_node) = self.query_mixnode_bond(mixnet_contract_address, node_id)? { - return Ok(!nym_node.is_unbonding); - } Ok(false) } @@ -78,22 +73,6 @@ pub(crate) trait MixnetContractQuerier { self.query_mixnet_contract_storage_value(address, storage_key) } - - fn query_mixnode_bond( - &self, - address: impl Into, - node_id: NodeId, - ) -> StdResult> { - // construct proper map key - let pk_namespace = "mnn"; - let path: Path = Path::new( - Namespace::from_static_str(pk_namespace).as_slice(), - &node_id.key().iter().map(Key::as_ref).collect::>(), - ); - let storage_key = path.deref(); - - self.query_mixnet_contract_storage_value(address, storage_key) - } } impl MixnetContractQuerier for QuerierWrapper<'_, C> diff --git a/contracts/performance/src/storage.rs b/contracts/performance/src/storage.rs index acba90762b..e392fb78ba 100644 --- a/contracts/performance/src/storage.rs +++ b/contracts/performance/src/storage.rs @@ -1261,28 +1261,6 @@ mod tests { } ); - // bonded legacy mix-node - let node_id = tester.bond_dummy_legacy_mixnode()?; - let perf = NodePerformance { - node_id, - performance: Default::default(), - }; - let res = - storage.submit_performance_data(tester.deps_mut(), env.clone(), &nm, 0, perf); - assert!(res.is_ok()); - - // unbonded - tester.unbond_legacy_mixnode(node_id)?; - - let res = storage - .submit_performance_data(tester.deps_mut(), env.clone(), &nm, 0, dummy_perf) - .unwrap_err(); - assert_eq!( - res, - NymPerformanceContractError::NodeNotBonded { - node_id: dummy_perf.node_id - } - ); Ok(()) } } @@ -1984,11 +1962,6 @@ mod tests { tester.unbond_nymnode(nym_node_between)?; let nym_node2 = tester.bond_dummy_nymnode()?; - let mix_node1 = tester.bond_dummy_legacy_mixnode()?; - let mixnode_between = tester.bond_dummy_legacy_mixnode()?; - tester.unbond_legacy_mixnode(mixnode_between)?; - let mix_node2 = tester.bond_dummy_legacy_mixnode()?; - let env = tester.env(); // single id - nothing bonded @@ -2053,84 +2026,6 @@ mod tests { assert_eq!(res.accepted_scores, 2); assert_eq!(res.non_existent_nodes, vec![2, nym_node_between]); - // MIXNODES - - // one bonded mixnode, one not bonded - let res = storage.batch_submit_performance_results( - tester.deps_mut(), - env.clone(), - &nm, - 3, - vec![ - NodePerformance { - node_id: mix_node1, - performance: Default::default(), - }, - NodePerformance { - node_id: 999999, - performance: Default::default(), - }, - ], - )?; - assert_eq!(res.accepted_scores, 1); - assert_eq!(res.non_existent_nodes, vec![999999]); - - // not-bonded, bonded, not-bonded, bonded - let res = storage.batch_submit_performance_results( - tester.deps_mut(), - env.clone(), - &nm, - 4, - vec![ - NodePerformance { - node_id: 2, - performance: Default::default(), - }, - NodePerformance { - node_id: mix_node1, - performance: Default::default(), - }, - NodePerformance { - node_id: mixnode_between, - performance: Default::default(), - }, - NodePerformance { - node_id: mix_node2, - performance: Default::default(), - }, - ], - )?; - assert_eq!(res.accepted_scores, 2); - assert_eq!(res.non_existent_nodes, vec![2, mixnode_between]); - - // nym-node, not bonded, mixnode - let res = storage.batch_submit_performance_results( - tester.deps_mut(), - env.clone(), - &nm, - 5, - vec![ - NodePerformance { - node_id: 3, - performance: Default::default(), - }, - NodePerformance { - node_id: nym_node1, - performance: Default::default(), - }, - NodePerformance { - node_id: nym_node_between, - performance: Default::default(), - }, - NodePerformance { - node_id: mix_node2, - performance: Default::default(), - }, - ], - )?; - assert_eq!(res.accepted_scores, 2); - assert_eq!(res.non_existent_nodes, vec![3, nym_node_between]); - Ok(()) } } diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs index 614982acc3..33e90beaf3 100644 --- a/contracts/performance/src/testing/mod.rs +++ b/contracts/performance/src/testing/mod.rs @@ -6,10 +6,9 @@ use crate::helpers::MixnetContractQuerier; use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; use cosmwasm_std::testing::{message_info, mock_env, MockApi}; use cosmwasm_std::{ - coin, coins, Addr, Binary, ContractInfo, Deps, DepsMut, Env, MessageInfo, QuerierWrapper, - StdError, StdResult, + coin, coins, Addr, ContractInfo, Deps, DepsMut, Env, MessageInfo, QuerierWrapper, StdError, + StdResult, }; -use cw_storage_plus::PrimaryKey; use mixnet_contract::testable_mixnet_contract::MixnetContract; use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; use nym_contracts_common::Percent; @@ -22,9 +21,8 @@ use nym_contracts_common_testing::{ use nym_crypto::asymmetric::ed25519; use nym_mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; use nym_mixnet_contract_common::{ - CurrentIntervalResponse, EpochId, Interval, MixNode, MixNodeBond, MixnodeDetailsResponse, - NodeCostParams, NodeRewarding, NymNode, NymNodeBondingPayload, RoleAssignment, - SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, + CurrentIntervalResponse, EpochId, Interval, NodeCostParams, NymNode, NymNodeBondingPayload, + RoleAssignment, SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, DEFAULT_PROFIT_MARGIN_PERCENT, }; use nym_performance_contract_common::constants::storage_keys; @@ -33,7 +31,7 @@ use nym_performance_contract_common::{ NymPerformanceContractError, QueryMsg, }; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::str::FromStr; pub struct PerformanceContract; @@ -499,110 +497,6 @@ pub(crate) trait PerformanceContractTesterExt: self.advance_mixnet_epoch()?; Ok(()) } - - fn bond_dummy_legacy_mixnode(&mut self) -> Result { - #[derive(Deserialize, Serialize)] - pub(crate) struct UniqueRef { - // note, we collapse the pk - combining everything under the namespace - even if it is composite - pk: Binary, - value: T, - } - - // there's no proper Execute flow for this anymore, so we have to "hack" the storage a bit, - // ensuring all invariants still hold - let owner = self.generate_account_with_balance(); - - let mixnode = MixNode { - host: "1.2.3.4".to_string(), - mix_port: 123, - verloc_port: 123, - http_api_port: 123, - sphinx_key: "aaaa".to_string(), - identity_key: "bbbbb".to_string(), - version: "ccc".to_string(), - }; - let cost_params = NodeCostParams { - profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) - .unwrap(), - interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), - }; - - // adjust node counter - let node_id_counter: u32 = self.read_from_mixnet_contract_storage("nic")?; - let node_id = node_id_counter + 1; - self.write_to_mixnet_contract_storage_value("nic", &node_id)?; - - let current_epoch = self.current_mixnet_epoch()?; - let pledge = coin(100_000000, TEST_DENOM); - let mixnode_rewarding = - NodeRewarding::initialise_new(cost_params, &pledge, current_epoch).unwrap(); - let env = self.env(); - let mixnode_bond = MixNodeBond { - mix_id: node_id, - owner, - original_pledge: pledge, - mix_node: mixnode, - proxy: None, - bonding_height: env.block.height, - is_unbonding: false, - }; - - // save to the main mixnode storage - self.set_contract_map_value( - self.mixnet_contract_address()?, - "mnn", - node_id, - &mixnode_bond, - )?; - // update indices - let pk = node_id.joined_key(); - let unique_ref = UniqueRef { - pk: pk.into(), - value: mixnode_bond.clone(), - }; - - // owner index - let idx = mixnode_bond.owner.clone(); - self.set_contract_map_value(self.mixnet_contract_address()?, "mno", idx, &unique_ref)?; - - // identity key index - let idx = mixnode_bond.mix_node.identity_key.clone(); - self.set_contract_map_value(self.mixnet_contract_address()?, "mni", idx, &unique_ref)?; - - // sphinx key index - let idx = mixnode_bond.mix_node.sphinx_key.clone(); - self.set_contract_map_value(self.mixnet_contract_address()?, "mns", idx, &unique_ref)?; - - // update rewarding data - self.set_contract_map_value( - self.mixnet_contract_address()?, - "mnr", - node_id, - &mixnode_rewarding, - )?; - - Ok(node_id) - } - - fn unbond_legacy_mixnode( - &mut self, - node_id: NodeId, - ) -> Result<(), NymPerformanceContractError> { - let bond: MixnodeDetailsResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetMixnodeDetails { mix_id: node_id }, - )?; - - let node_owner = bond.mixnode_details.unwrap().bond_information.owner; - - self.execute_mixnet_contract( - message_info(&node_owner, &[]), - &nym_mixnet_contract_common::ExecuteMsg::UnbondMixnode {}, - )?; - - self.advance_mixnet_epoch()?; - Ok(()) - } } impl PerformanceContractTesterExt for ContractTester {} From 96e3ff2af9f537b9baba1dcea2d79f92b0e7a33d Mon Sep 17 00:00:00 2001 From: Mark Sinclair <14054343+mmsinclair@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:18:01 +0000 Subject: [PATCH 21/23] Node Status UI (#6210) Co-authored-by: Mark Sinclair --- .../nym-node-status-ui/package.json | 2 + .../nym-node-status-ui/pnpm-lock.yaml | 11 + .../src/app/dvpn/GatewaysTable.tsx | 379 +++++++ .../nym-node-status-ui/src/app/dvpn/page.tsx | 15 + .../nym-node-status-ui/src/app/favicon.ico | Bin 0 -> 25931 bytes .../nym-node-status-ui/src/app/layout.tsx | 22 +- .../src/app/nodes/NodesTable.tsx | 124 +++ .../nym-node-status-ui/src/app/nodes/page.tsx | 15 + .../src/app/nym-vpn/GatewaysTable.tsx | 115 +++ .../src/app/nym-vpn/page.tsx | 15 + .../nym-node-status-ui/src/app/page.tsx | 56 +- .../src/app/socks5/page.tsx | 11 + .../src/app/validators/page.tsx | 11 + .../src/app/zk-nym-signers/page.tsx | 11 + .../src/client/@tanstack/react-query.gen.ts | 936 +++++++++++++++++ .../src/client/client.gen.ts | 28 + .../src/client/client/client.ts | 195 ++++ .../src/client/client/index.ts | 22 + .../src/client/client/types.ts | 219 ++++ .../src/client/client/utils.ts | 417 ++++++++ .../src/client/core/auth.ts | 40 + .../src/client/core/bodySerializer.ts | 88 ++ .../src/client/core/params.ts | 141 +++ .../src/client/core/pathSerializer.ts | 179 ++++ .../src/client/core/types.ts | 104 ++ .../nym-node-status-ui/src/client/index.ts | 3 + .../nym-node-status-ui/src/client/sdk.gen.ts | 395 ++++++++ .../src/client/types.gen.ts | 940 ++++++++++++++++++ .../src/components/CardAlert.tsx | 25 + .../src/components/Copyright.tsx | 24 + .../src/components/GraphCard.tsx | 23 + .../src/components/HighlightedCard.tsx | 42 + .../src/components/ScoreIcon.tsx | 36 + .../src/components/StatCard.tsx | 130 +++ .../graphs/GatewayCanQueryMetadataTopup.tsx | 58 ++ .../graphs/GatewayDownloadSpeeds.tsx | 43 + .../src/components/graphs/GatewayLoads.tsx | 45 + .../graphs/GatewayPingPercentage.tsx | 43 + .../src/components/graphs/GatewayScores.tsx | 45 + .../graphs/GatewayUptimePercentage.tsx | 41 + .../src/components/graphs/GatewayVersions.tsx | 49 + .../src/components/nav/AppNavbar.tsx | 77 ++ .../components/nav/ColorModeIconDropdown.tsx | 91 ++ .../src/components/nav/ColorModeSelect.tsx | 29 + .../src/components/nav/Header.tsx | 33 + .../src/components/nav/MenuButton.tsx | 23 + .../src/components/nav/MenuContent.tsx | 60 ++ .../src/components/nav/NavbarBreadcrumbs.tsx | 37 + .../src/components/nav/OptionsMenu.tsx | 81 ++ .../src/components/nav/Search.tsx | 27 + .../src/components/nav/SelectContent.tsx | 107 ++ .../src/components/nav/SideMenu.tsx | 56 ++ .../src/components/nav/SideMenuMobile.tsx | 43 + .../src/components/nav/SiteLogo.tsx | 15 + .../src/context/queryContext.tsx | 45 + .../nym-node-status-ui/src/hooks/types.ts | 4 + .../src/hooks/useAllNymNodes.ts | 39 + .../src/hooks/useGateways.ts | 20 + .../src/hooks/useGatewaysTransformed.ts | 47 + .../src/hooks/useNymNodes.ts | 52 + .../src/layouts/LayoutWithNav.tsx | 29 + .../src/layouts/NestedLayoutWithHeader.tsx | 23 + .../src/theme/ColorModeIconDropdown.tsx | 91 ++ .../nym-node-status-ui/src/theme/index.tsx | 27 + 64 files changed, 6152 insertions(+), 2 deletions(-) create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/favicon.ico create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/client/client.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/client/index.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/client/types.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/core/params.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/core/types.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/index.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/hooks/types.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts create mode 100644 nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx create mode 100644 nym-node-status-api/nym-node-status-ui/src/theme/index.tsx diff --git a/nym-node-status-api/nym-node-status-ui/package.json b/nym-node-status-api/nym-node-status-ui/package.json index 610a288446..d738ed90dc 100644 --- a/nym-node-status-api/nym-node-status-ui/package.json +++ b/nym-node-status-api/nym-node-status-ui/package.json @@ -19,6 +19,7 @@ "@mui/x-charts": "^8.8.0", "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.83.0", + "d3-array": "^3.2.4", "dayjs": "^1.11.13", "material-react-table": "^3.2.1", "next": "^15.4.1", @@ -30,6 +31,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@tanstack/react-query-devtools": "^5.83.0", + "@types/d3-array": "^3.2.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml b/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml index ab4a48acac..e42724493a 100644 --- a/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml +++ b/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-query': specifier: ^5.83.0 version: 5.83.0(react@19.0.0) + d3-array: + specifier: ^3.2.4 + version: 3.2.4 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -60,6 +63,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.83.0 version: 5.83.0(@tanstack/react-query@5.83.0(react@19.0.0))(react@19.0.0) + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 '@types/node': specifier: ^20 version: 20.17.14 @@ -659,6 +665,9 @@ packages: '@tanstack/virtual-core@3.11.2': resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -1723,6 +1732,8 @@ snapshots: '@tanstack/virtual-core@3.11.2': {} + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.3': {} '@types/d3-delaunay@6.0.4': {} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx new file mode 100644 index 0000000000..30a389cd80 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx @@ -0,0 +1,379 @@ +import type { DVpnGateway } from "@/client"; +import { ReverseScoreIcon, ScoreIcon } from "@/components/ScoreIcon"; +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; +import ReactCountryFlag from "react-country-flag"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const regionNamesInEnglish = new Intl.DisplayNames(["en"], { type: "region" }); + +const staleGatewayBinWidthMinutes = 15; + +interface StaleGatewayStats { + bins: number[]; + average: number; + sum: number; + count: number; +} + +export default function GatewaysTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useDVpnGatewaysTransformed().query; + + const staleGateways = useMemo( + () => + (data || []).reduce( + (acc, g) => { + const last_updated_utc = g.last_probe + ? dayjs(g.last_probe.last_updated_utc) + : null; + if (!last_updated_utc) return acc; + const diff = dayjs().diff(last_updated_utc, "minutes"); + const bin = Math.floor(diff / staleGatewayBinWidthMinutes); + if (!acc.bins[bin]) { + acc.bins[bin] = 0; + } + acc.bins[bin] += 1; + acc.sum += diff; + acc.count += 1; + acc.average = acc.sum / acc.count; + return acc; + }, + { + bins: [], + average: 0, + sum: 0, + count: 0, + } as StaleGatewayStats, + ), + [data], + ); + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "location.two_letter_iso_country_code", + header: "Country", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + {value} + + {regionNamesInEnglish.of(value)} + + + ); + }, + }, + { + accessorKey: "location.region", + header: "City / Region", + Cell: ({ row }) => { + return ( + <> + + {(row.original.location as any).city}/ + {(row.original.location as any).region} + + + ); + }, + }, + { + accessorKey: "performance_v2.score", + width: 20, + header: "Score", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + + + {value || "-"} + + + ); + }, + }, + { + accessorKey: "performance_v2.load", + width: 20, + header: "Load", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + + + {value || "-"} + + + ); + }, + }, + { + accessorKey: "extra.downloadSpeedMBPerSec", + header: "Download Speed ipv4 (MB/sec)", + Cell: ({ renderedCellValue, cell }) => { + if (!cell.getValue()) { + return null; + } + return ( + + {renderedCellValue} MB/sec + + ); + }, + }, + { + accessorKey: "extra.downloadSpeedIpv6MBPerSec", + header: "Download Speed ipv6 (MB/sec)", + Cell: ({ renderedCellValue, cell }) => { + if (!cell.getValue()) { + return null; + } + return ( + + {renderedCellValue} MB/sec + + ); + }, + }, + { + accessorKey: "last_probe.outcome.wg.ping_ips_performance_v4", + header: "Probe pings (IPV4)", + Cell: ({ cell }) => { + const value = Math.floor( + Number.parseFloat(cell.getValue() || "0") * 100, + ); + return ( + <> + + {value}% + + + ); + }, + }, + { + accessorKey: "performance_v2.uptime_percentage_last_24_hours", + width: 20, + header: "Uptime", + Cell: ({ cell, row }) => { + const value: number = + ((row.original as any).performance_v2 + ?.uptime_percentage_last_24_hours || 0) * 100; + // const value = Math.floor(Number.parseFloat(cell.getValue()) * 100); + return ( + <> + + {value}% + + + ); + }, + }, + { + accessorKey: "last_probe.outcome.wg.can_query_metadata_v4", + header: "Can query metadata?", + Cell: ({ cell }) => { + const wg = cell.row.original.last_probe?.outcome.wg as any; + const can_query_metadata_v4 = wg?.can_query_metadata_v4; + return ( + <> + + {can_query_metadata_v4 === null || + (can_query_metadata_v4 === undefined && -)} + {can_query_metadata_v4 === true && } + {can_query_metadata_v4 === false && } + + + ); + }, + }, + { + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed At", + Cell: ({ cell }) => { + const parsed = dayjs(cell.getValue()); + return ( + +

+
+ ({parsed.fromNow()}) +
+ + ); + }, + }, + { + id: "last_probe_age", + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed Age", + Cell: ({ cell, row }) => { + const value = row.original.last_probe?.last_updated_utc; + if (!value) { + return "-"; + } + const parsed = dayjs(value); + const age = dayjs().diff(parsed, "minutes"); + return <>{age} minutes; + }, + }, + { + accessorKey: "build_information.build_version", + header: "Version", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { + pageIndex: 0, + pageSize: 100, + }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length || 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ( + <> + +

Gateway probe age

+ + Average age is {Math.round(staleGateways.average * 10) / 10} minutes old + + + + + + Age + Gateways + + + + {staleGateways.bins.map((r, i) => ( + + + {(i + 1) * staleGatewayBinWidthMinutes} mins old + + {r} + + ))} + +
+
+ + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx new file mode 100644 index 0000000000..e5b5b8fbcf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import GatewaysTable from "@/app/dvpn/GatewaysTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/favicon.ico b/nym-node-status-api/nym-node-status-ui/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx b/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx index 83d23fa86d..7eaf4f838a 100644 --- a/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx +++ b/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx @@ -1,10 +1,30 @@ "use client"; +import { QueryContextProvider } from "@/context/queryContext"; +import LayoutWithNav from "@/layouts/LayoutWithNav"; +import AppTheme from "@/theme"; +import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; +import CssBaseline from "@mui/material/CssBaseline"; +import InitColorSchemeScript from "@mui/material/InitColorSchemeScript"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; + export default function RootLayout(props: { children: React.ReactNode }) { return ( - {props.children} + + + + {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} + + + + {props.children} + + + + ); diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx new file mode 100644 index 0000000000..6ee28b2bcf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx @@ -0,0 +1,124 @@ +import { useAllNymNodes } from "@/hooks/useAllNymNodes"; +import type { NymNode } from "@/hooks/useNymNodes"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { IconButton, Tooltip } from "@mui/material"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; + +export default function NodesTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useAllNymNodes().query; + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "node_id", + header: "Node Id", + size: 25, + }, + { + accessorKey: "description.moniker", + header: "Moniker", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "node_type", + header: "Node Type", + }, + { + accessorKey: "bonded", + header: "Bonded", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "⛔️"), + }, + { + accessorKey: "geoip.country", + header: "Country", + }, + { + accessorKey: "geoip.city", + header: "City", + }, + { + accessorKey: "self_description.build_information.build_version", + header: "Version", + }, + { + accessorKey: "self_description.declared_role.entry", + header: "Entry gateway", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit", + header: "Exit gateway", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.mixnode", + header: "Mixnode", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit_ipr", + header: "Runs IPR", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit_nr", + header: "Runs SOCKS5 NR", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.host_information.ip_address", + header: "IP Address", + }, + { + accessorKey: "uptime", + header: "Uptime", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { pageIndex: 0, pageSize: 100 }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length ?? 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx new file mode 100644 index 0000000000..608f32869c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import NodesTable from "@/app/nodes/NodesTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx new file mode 100644 index 0000000000..6c1de054d0 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx @@ -0,0 +1,115 @@ +import type { DVpnGateway } from "@/client"; +import { useDVpnGateways } from "@/hooks/useGateways"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { IconButton, Tooltip } from "@mui/material"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; +import ReactCountryFlag from "react-country-flag"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const regionNamesInEnglish = new Intl.DisplayNames(["en"], { type: "region" }); + +export default function GatewaysTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useDVpnGateways().query; + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "location.two_letter_iso_country_code", + header: "Country", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + {value} + + {regionNamesInEnglish.of(value)} + + + ); + }, + }, + { + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed At", + Cell: ({ cell }) => { + const parsed = dayjs(cell.getValue()); + return ( + +
+ {parsed.format()} +
+
+ ({parsed.fromNow()}) +
+
+ ); + }, + }, + { + accessorKey: "build_information.build_version", + header: "Version", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { + pageIndex: 0, + pageSize: 100, + }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length || 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx new file mode 100644 index 0000000000..6f55cf7dba --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import GatewaysTable from "@/app/dvpn/GatewaysTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/page.tsx index b77b535926..f3157b0289 100644 --- a/nym-node-status-api/nym-node-status-ui/src/app/page.tsx +++ b/nym-node-status-api/nym-node-status-ui/src/app/page.tsx @@ -1,7 +1,61 @@ "use client"; +import GraphCard from "@/components/GraphCard"; +import { GatewayCanQueryMetadataTopup } from "@/components/graphs/GatewayCanQueryMetadataTopup"; +import { GatewayDownloadSpeeds } from "@/components/graphs/GatewayDownloadSpeeds"; +import { GatewayLoads } from "@/components/graphs/GatewayLoads"; +import { GatewayPingPercentage } from "@/components/graphs/GatewayPingPercentage"; +import { GatewayScores } from "@/components/graphs/GatewayScores"; +import { GatewayUptimePercentage } from "@/components/graphs/GatewayUptimePercentage"; +import { GatewayVersions } from "@/components/graphs/GatewayVersions"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Grid from "@mui/material/Grid"; + export default function Home() { return ( -
TODO
+ + theme.spacing(2) }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx new file mode 100644 index 0000000000..7cd5172647 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
SOCKS5
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx new file mode 100644 index 0000000000..6ae3f8edda --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
Validators
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx new file mode 100644 index 0000000000..089eca1d97 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
zk-nym signers
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts new file mode 100644 index 0000000000..5e672b9bb7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts @@ -0,0 +1,936 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type DefaultError, + type InfiniteData, + infiniteQueryOptions, + queryOptions, +} from "@tanstack/react-query"; +import { client as _heyApiClient } from "../client.gen"; +import { + type Options, + buildInformation, + gateways, + gatewaysSkinny, + getAllSessions, + getEntryGatewayCountries, + getEntryGateways, + getEntryGatewaysByCountry, + getExitGatewayCountries, + getExitGateways, + getExitGatewaysByCountry, + getGateway, + getGatewayCountries, + getGateways, + getGatewaysByCountry, + getMixnodes, + getStats, + health, + mixnodes, + mixnodes2, + nodeDelegations, + nymNodes, + summary, + summaryHistory, +} from "../sdk.gen"; +import type { + BuildInformationData, + GatewaysData, + GatewaysResponse, + GatewaysSkinnyData, + GatewaysSkinnyResponse, + GetAllSessionsData, + GetAllSessionsResponse, + GetEntryGatewayCountriesData, + GetEntryGatewaysByCountryData, + GetEntryGatewaysData, + GetExitGatewayCountriesData, + GetExitGatewaysByCountryData, + GetExitGatewaysData, + GetGatewayCountriesData, + GetGatewayData, + GetGatewaysByCountryData, + GetGatewaysData, + GetMixnodesData, + GetStatsData, + GetStatsResponse, + HealthData, + Mixnodes2Data, + Mixnodes2Response, + MixnodesData, + MixnodesResponse, + NodeDelegationsData, + NymNodesData, + NymNodesResponse, + SummaryData, + SummaryHistoryData, +} from "../types.gen"; + +export type QueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + }, +]; + +const createQueryKey = ( + id: string, + options?: TOptions, + infinite?: boolean, +): [QueryKey[0]] => { + const params: QueryKey[0] = { + _id: id, + baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl, + } as QueryKey[0]; + if (infinite) { + params._infinite = infinite; + } + if (options?.body) { + params.body = options.body; + } + if (options?.headers) { + params.headers = options.headers; + } + if (options?.path) { + params.path = options.path; + } + if (options?.query) { + params.query = options.query; + } + return [params]; +}; + +export const getGatewaysQueryKey = (options?: Options) => + createQueryKey("getGateways", options); + +/** + * Gets available entry and exit gateways from the Nym network directory + */ +export const getGatewaysOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewaysQueryKey(options), + }); +}; + +export const getGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getGatewayCountries", options); + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewayCountriesQueryKey(options), + }); +}; + +export const getGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getGatewaysByCountry", options); + +/** + * Gets available gateways from the Nym network directory by country + */ +export const getGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewaysByCountryQueryKey(options), + }); +}; + +export const getEntryGatewaysQueryKey = ( + options?: Options, +) => createQueryKey("getEntryGateways", options); + +/** + * Gets available entry gateways from the Nym network directory + */ +export const getEntryGatewaysOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewaysQueryKey(options), + }); +}; + +export const getEntryGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getEntryGatewayCountries", options); + +/** + * Gets available entry gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getEntryGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewayCountriesQueryKey(options), + }); +}; + +export const getEntryGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getEntryGatewaysByCountry", options); + +/** + * Gets available entry gateways from the Nym network directory by country + */ +export const getEntryGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewaysByCountryQueryKey(options), + }); +}; + +export const getExitGatewaysQueryKey = ( + options?: Options, +) => createQueryKey("getExitGateways", options); + +/** + * Gets available exit gateways from the Nym network directory + */ +export const getExitGatewaysOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewaysQueryKey(options), + }); +}; + +export const getExitGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getExitGatewayCountries", options); + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getExitGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewayCountriesQueryKey(options), + }); +}; + +export const getExitGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getExitGatewaysByCountry", options); + +/** + * Gets available exit gateways from the Nym network directory by country + */ +export const getExitGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewaysByCountryQueryKey(options), + }); +}; + +export const nymNodesQueryKey = (options?: Options) => + createQueryKey("nymNodes", options); + +export const nymNodesOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await nymNodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nymNodesQueryKey(options), + }); +}; + +const createInfiniteParams = < + K extends Pick[0], "body" | "headers" | "path" | "query">, +>( + queryKey: QueryKey, + page: K, +) => { + const params = { + ...queryKey[0], + }; + if (page.body) { + params.body = { + ...(queryKey[0].body as any), + ...(page.body as any), + }; + } + if (page.headers) { + params.headers = { + ...queryKey[0].headers, + ...page.headers, + }; + } + if (page.path) { + params.path = { + ...(queryKey[0].path as any), + ...(page.path as any), + }; + } + if (page.query) { + params.query = { + ...(queryKey[0].query as any), + ...(page.query as any), + }; + } + return params as unknown as typeof page; +}; + +export const nymNodesInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("nymNodes", options, true); + +export const nymNodesInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + NymNodesResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await nymNodes({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nymNodesInfiniteQueryKey(options), + }, + ); +}; + +export const nodeDelegationsQueryKey = ( + options: Options, +) => createQueryKey("nodeDelegations", options); + +export const nodeDelegationsOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await nodeDelegations({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nodeDelegationsQueryKey(options), + }); +}; + +export const gatewaysQueryKey = (options?: Options) => + createQueryKey("gateways", options); + +export const gatewaysOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await gateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysQueryKey(options), + }); +}; + +export const gatewaysInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("gateways", options, true); + +export const gatewaysInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GatewaysResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await gateways({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysInfiniteQueryKey(options), + }, + ); +}; + +export const gatewaysSkinnyQueryKey = (options?: Options) => + createQueryKey("gatewaysSkinny", options); + +export const gatewaysSkinnyOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await gatewaysSkinny({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysSkinnyQueryKey(options), + }); +}; + +export const gatewaysSkinnyInfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("gatewaysSkinny", options, true); + +export const gatewaysSkinnyInfiniteOptions = ( + options?: Options, +) => { + return infiniteQueryOptions< + GatewaysSkinnyResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await gatewaysSkinny({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysSkinnyInfiniteQueryKey(options), + }, + ); +}; + +export const getGatewayQueryKey = (options: Options) => + createQueryKey("getGateway", options); + +export const getGatewayOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGateway({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewayQueryKey(options), + }); +}; + +export const getAllSessionsQueryKey = (options?: Options) => + createQueryKey("getAllSessions", options); + +export const getAllSessionsOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAllSessions({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAllSessionsQueryKey(options), + }); +}; + +export const getAllSessionsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("getAllSessions", options, true); + +export const getAllSessionsInfiniteOptions = ( + options?: Options, +) => { + return infiniteQueryOptions< + GetAllSessionsResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getAllSessions({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAllSessionsInfiniteQueryKey(options), + }, + ); +}; + +export const mixnodesQueryKey = (options?: Options) => + createQueryKey("mixnodes", options); + +export const mixnodesOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await mixnodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodesQueryKey(options), + }); +}; + +export const mixnodesInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("mixnodes", options, true); + +export const mixnodesInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + MixnodesResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await mixnodes({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodesInfiniteQueryKey(options), + }, + ); +}; + +export const getStatsQueryKey = (options?: Options) => + createQueryKey("getStats", options); + +export const getStatsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getStats({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getStatsQueryKey(options), + }); +}; + +export const getStatsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("getStats", options, true); + +export const getStatsInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetStatsResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + offset: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getStats({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getStatsInfiniteQueryKey(options), + }, + ); +}; + +export const getMixnodesQueryKey = (options: Options) => + createQueryKey("getMixnodes", options); + +export const getMixnodesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getMixnodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getMixnodesQueryKey(options), + }); +}; + +export const mixnodes2QueryKey = (options?: Options) => + createQueryKey("mixnodes2", options); + +export const mixnodes2Options = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await mixnodes2({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodes2QueryKey(options), + }); +}; + +export const mixnodes2InfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("mixnodes2", options, true); + +export const mixnodes2InfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + Mixnodes2Response, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await mixnodes2({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodes2InfiniteQueryKey(options), + }, + ); +}; + +export const buildInformationQueryKey = ( + options?: Options, +) => createQueryKey("buildInformation", options); + +export const buildInformationOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await buildInformation({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: buildInformationQueryKey(options), + }); +}; + +export const healthQueryKey = (options?: Options) => + createQueryKey("health", options); + +export const healthOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await health({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: healthQueryKey(options), + }); +}; + +export const summaryQueryKey = (options?: Options) => + createQueryKey("summary", options); + +export const summaryOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await summary({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: summaryQueryKey(options), + }); +}; + +export const summaryHistoryQueryKey = (options?: Options) => + createQueryKey("summaryHistory", options); + +export const summaryHistoryOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await summaryHistory({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: summaryHistoryQueryKey(options), + }); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts new file mode 100644 index 0000000000..d4af1d498e --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts @@ -0,0 +1,28 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type Config, + type ClientOptions as DefaultClientOptions, + createClient, + createConfig, +} from "./client"; +import type { ClientOptions } from "./types.gen"; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = + ( + override?: Config, + ) => Config & T>; + +export const client = createClient( + createConfig({ + baseUrl: "https://mainnet-node-status-api.nymtech.cc", + }), +); diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts new file mode 100644 index 0000000000..748558f584 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts @@ -0,0 +1,195 @@ +import type { Client, Config, RequestOptions } from "./types"; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils"; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + RequestOptions + >(); + + const request: Client["request"] = async (options) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body && opts.bodySerializer) { + opts.body = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.body === "") { + opts.headers.delete("Content-Type"); + } + + const url = buildUrl(opts); + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request._fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response = await _fetch(request); + + for (const fn of interceptors.response._fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + if ( + response.status === 204 || + response.headers.get("Content-Length") === "0" + ) { + return opts.responseStyle === "data" + ? {} + : { + data: {}, + ...result, + }; + } + + const parseAs = + (opts.parseAs === "auto" + ? getParseAs(response.headers.get("Content-Type")) + : opts.parseAs) ?? "json"; + + let data: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "json": + case "text": + data = await response[parseAs](); + break; + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error._fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + }; + }; + + return { + buildUrl, + connect: (options) => request({ ...options, method: "CONNECT" }), + delete: (options) => request({ ...options, method: "DELETE" }), + get: (options) => request({ ...options, method: "GET" }), + getConfig, + head: (options) => request({ ...options, method: "HEAD" }), + interceptors, + options: (options) => request({ ...options, method: "OPTIONS" }), + patch: (options) => request({ ...options, method: "PATCH" }), + post: (options) => request({ ...options, method: "POST" }), + put: (options) => request({ ...options, method: "PUT" }), + request, + setConfig, + trace: (options) => request({ ...options, method: "TRACE" }), + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts new file mode 100644 index 0000000000..7b4add4180 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts @@ -0,0 +1,22 @@ +export type { Auth } from "../core/auth"; +export type { QuerySerializerOptions } from "../core/bodySerializer"; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer"; +export { buildClientParams } from "../core/params"; +export { createClient } from "./client"; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResponseStyle, + TDataShape, +} from "./types"; +export { createConfig, mergeHeaders } from "./utils"; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts new file mode 100644 index 0000000000..5344ff3d2a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts @@ -0,0 +1,219 @@ +import type { Auth } from "../core/auth"; +import type { Client as CoreClient, Config as CoreConfig } from "../core/types"; +import type { Middleware } from "./utils"; + +export type ResponseStyle = "data" | "fields"; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"]; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: (request: Request) => ReturnType; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | "arrayBuffer" + | "auto" + | "blob" + | "formData" + | "json" + | "stream" + | "text"; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"]; +} + +export interface RequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }> { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends "data" + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys< + RequestOptions, + "body" | "path" | "query" | "url" +> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "body" | "headers" | "url" + > & + TData + : OmitKeys, "body" | "url"> & + TData & + Pick, "headers"> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "headers" | "url" + > & + TData & + Pick, "body"> + : OmitKeys, "url"> & TData; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts new file mode 100644 index 0000000000..ff8112bd56 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts @@ -0,0 +1,417 @@ +import { getAuthToken } from "../core/auth"; +import type { + QuerySerializer, + QuerySerializerOptions, +} from "../core/bodySerializer"; +import { jsonBodySerializer } from "../core/bodySerializer"; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "../core/pathSerializer"; +import type { Client, ClientOptions, Config, RequestOptions } from "./types"; + +interface PathSerializer { + path: Record; + url: string; +} + +const PATH_PARAM_RE = /\{[^{}]+\}/g; + +type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +type MatrixStyle = "label" | "matrix" | "simple"; +type ArraySeparatorStyle = ArrayStyle | MatrixStyle; + +const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = "simple"; + + if (name.endsWith("*")) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith(".")) { + name = name.substring(1); + style = "label"; + } else if (name.startsWith(";")) { + name = name.substring(1); + style = "matrix"; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === "label" ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: "form", + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join("&"); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream"; + } + + const cleanContent = contentType.split(";")[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith("application/json") || + cleanContent.endsWith("+json") + ) { + return "json"; + } + + if (cleanContent === "multipart/form-data") { + return "formData"; + } + + if ( + ["application/", "audio/", "image/", "video/"].some((type) => + cleanContent.startsWith(type), + ) + ) { + return "blob"; + } + + if (cleanContent.startsWith("text/")) { + return "text"; + } + + return; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? "Authorization"; + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case "cookie": + options.headers.append("Cookie", `${name}=${token}`); + break; + case "header": + default: + options.headers.set(name, token); + break; + } + + return; + } +}; + +export const buildUrl: Client["buildUrl"] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}`; + let url = (baseUrl ?? "") + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ""; + if (search.startsWith("?")) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +export const mergeHeaders = ( + ...headers: Array["headers"] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header || typeof header !== "object") { + continue; + } + + const iterator = + header instanceof Headers ? header.entries() : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === "object" ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + _fns: (Interceptor | null)[]; + + constructor() { + this._fns = []; + } + + clear() { + this._fns = []; + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this._fns[id] ? id : -1; + } else { + return this._fns.indexOf(id); + } + } + exists(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + return !!this._fns[index]; + } + + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = null; + } + } + + update(id: number | Interceptor, fn: Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = fn; + return id; + } else { + return false; + } + } + + use(fn: Interceptor) { + this._fns = [...this._fns, fn]; + return this._fns.length - 1; + } +} + +// `createInterceptors()` response, meant for external use as it does not +// expose internals +export interface Middleware { + error: Pick< + Interceptors>, + "eject" | "use" + >; + request: Pick>, "eject" | "use">; + response: Pick< + Interceptors>, + "eject" | "use" + >; +} + +// do not add `Middleware` as return type so we can use _fns internally +export const createInterceptors = () => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}); + +const defaultHeaders = { + "Content-Type": "application/json", +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts new file mode 100644 index 0000000000..70e9d5dba5 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts @@ -0,0 +1,40 @@ +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie"; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: "basic" | "bearer"; + type: "apiKey" | "http"; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === "function" ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}`; + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts new file mode 100644 index 0000000000..35e2a17e1a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts @@ -0,0 +1,88 @@ +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from "./pathSerializer"; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === "string") { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts new file mode 100644 index 0000000000..0771542b14 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts @@ -0,0 +1,141 @@ +type Slot = "body" | "headers" | "path" | "query"; + +export type Field = + | { + in: Exclude; + key: string; + map?: string; + } + | { + in: Extract; + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts new file mode 100644 index 0000000000..4052ad1279 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts @@ -0,0 +1,179 @@ +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = "label" | "matrix" | "simple"; +export type ObjectStyle = "form" | "deepObject"; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return ","; + case "pipeDelimited": + return "|"; + case "spaceDelimited": + return "%20"; + default: + return ","; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + case "simple": + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== "deepObject" && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(","); + switch (style) { + case "form": + return `${name}=${joinedValues}`; + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts new file mode 100644 index 0000000000..940f22a77f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts @@ -0,0 +1,104 @@ +import type { Auth, AuthToken } from "./auth"; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from "./bodySerializer"; + +export interface Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, +> { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + connect: MethodFn; + delete: MethodFn; + get: MethodFn; + getConfig: () => Config; + head: MethodFn; + options: MethodFn; + patch: MethodFn; + post: MethodFn; + put: MethodFn; + request: RequestFn; + setConfig: (config: Config) => Config; + trace: MethodFn; +} + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: + | "CONNECT" + | "DELETE" + | "GET" + | "HEAD" + | "OPTIONS" + | "PATCH" + | "POST" + | "PUT" + | "TRACE"; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/client/index.ts b/nym-node-status-api/nym-node-status-ui/src/client/index.ts new file mode 100644 index 0000000000..da87079367 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from "./types.gen"; +export * from "./sdk.gen"; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts new file mode 100644 index 0000000000..b571278744 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts @@ -0,0 +1,395 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as ClientOptions, TDataShape } from "./client"; +import { client as _heyApiClient } from "./client.gen"; +import type { + BuildInformationData, + BuildInformationResponses, + GatewaysData, + GatewaysResponses, + GatewaysSkinnyData, + GatewaysSkinnyResponses, + GetAllSessionsData, + GetAllSessionsResponses, + GetEntryGatewayCountriesData, + GetEntryGatewayCountriesResponses, + GetEntryGatewaysByCountryData, + GetEntryGatewaysByCountryResponses, + GetEntryGatewaysData, + GetEntryGatewaysResponses, + GetExitGatewayCountriesData, + GetExitGatewayCountriesResponses, + GetExitGatewaysByCountryData, + GetExitGatewaysByCountryResponses, + GetExitGatewaysData, + GetExitGatewaysResponses, + GetGatewayCountriesData, + GetGatewayCountriesResponses, + GetGatewayData, + GetGatewayResponses, + GetGatewaysByCountryData, + GetGatewaysByCountryResponses, + GetGatewaysData, + GetGatewaysResponses, + GetMixnodesData, + GetMixnodesResponses, + GetStatsData, + GetStatsResponses, + HealthData, + HealthResponses, + Mixnodes2Data, + Mixnodes2Responses, + MixnodesData, + MixnodesResponses, + NodeDelegationsData, + NodeDelegationsResponses, + NymNodesData, + NymNodesResponses, + SummaryData, + SummaryHistoryData, + SummaryHistoryResponses, + SummaryResponses, +} from "./types.gen"; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = ClientOptions & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Gets available entry and exit gateways from the Nym network directory + */ +export const getGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways", + ...options, + }); +}; + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/countries", + ...options, + }); +}; + +/** + * Gets available gateways from the Nym network directory by country + */ +export const getGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/country/{two_letter_country_code}", + ...options, + }); +}; + +/** + * Gets available entry gateways from the Nym network directory + */ +export const getEntryGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetEntryGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry", + ...options, + }); +}; + +/** + * Gets available entry gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getEntryGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetEntryGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry/countries", + ...options, + }); +}; + +/** + * Gets available entry gateways from the Nym network directory by country + */ +export const getEntryGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetEntryGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry/country/{two_letter_country_code}", + ...options, + }); +}; + +/** + * Gets available exit gateways from the Nym network directory + */ +export const getExitGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetExitGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit", + ...options, + }); +}; + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getExitGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetExitGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit/countries", + ...options, + }); +}; + +/** + * Gets available exit gateways from the Nym network directory by country + */ +export const getExitGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetExitGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit/country/{two_letter_country_code}", + ...options, + }); +}; + +export const nymNodes = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + NymNodesResponses, + unknown, + ThrowOnError + >({ + url: "/explorer/v3/nym-nodes", + ...options, + }); +}; + +export const nodeDelegations = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + NodeDelegationsResponses, + unknown, + ThrowOnError + >({ + url: "/explorer/v3/nym-nodes/{node_id}/delegations", + ...options, + }); +}; + +export const gateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways", + ...options, + }); +}; + +export const gatewaysSkinny = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GatewaysSkinnyResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways/skinny", + ...options, + }); +}; + +export const getGateway = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetGatewayResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways/{identity_key}", + ...options, + }); +}; + +export const getAllSessions = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetAllSessionsResponses, + unknown, + ThrowOnError + >({ + url: "/v2/metrics/sessions", + ...options, + }); +}; + +export const mixnodes = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + MixnodesResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes", + ...options, + }); +}; + +export const getStats = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetStatsResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes/stats", + ...options, + }); +}; + +export const getMixnodes = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetMixnodesResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes/{mix_id}", + ...options, + }); +}; + +export const mixnodes2 = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + Mixnodes2Responses, + unknown, + ThrowOnError + >({ + url: "/v2/services", + ...options, + }); +}; + +export const buildInformation = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + BuildInformationResponses, + unknown, + ThrowOnError + >({ + url: "/v2/status/build_information", + ...options, + }); +}; + +export const health = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + HealthResponses, + unknown, + ThrowOnError + >({ + url: "/v2/status/health", + ...options, + }); +}; + +export const summary = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + SummaryResponses, + unknown, + ThrowOnError + >({ + url: "/v2/summary", + ...options, + }); +}; + +export const summaryHistory = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + SummaryHistoryResponses, + unknown, + ThrowOnError + >({ + url: "/v2/summary/history", + ...options, + }); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts new file mode 100644 index 0000000000..bb6b7f5839 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts @@ -0,0 +1,940 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AnnouncePorts = { + mix_port?: number | null; + verloc_port?: number | null; +}; + +export type AuthenticatorDetails = { + /** + * address of the embedded authenticator + */ + address: string; +}; + +/** + * Auxiliary details of the associated Nym Node. + */ +export type AuxiliaryDetails = { + /** + * Specifies whether this node operator has agreed to the terms and conditions + * as defined at + */ + accepted_operator_terms_and_conditions?: boolean; + announce_ports?: AnnouncePorts; + /** + * Optional ISO 3166 alpha-2 two-letter country code of the node's **physical** location + */ + location?: string | null; +}; + +export type BasicEntryInformation = { + hostname?: string | null; + ws_port: number; + wss_port?: number | null; +}; + +export type BinaryBuildInformationOwned = { + /** + * Provides the name of the binary, i.e. the content of `CARGO_PKG_NAME` environmental variable. + */ + binary_name: string; + /** + * Provides the build timestamp, for example `2021-02-23T20:14:46.558472672+00:00`. + */ + build_timestamp: string; + /** + * Provides the build version, for example `0.1.0-9-g46f83e1`. + */ + build_version: string; + /** + * Provides the cargo debug mode that was used for the build. + */ + cargo_profile: string; + /** + * Provides the cargo target triple that was used for the build. + */ + cargo_triple?: string; + /** + * Provides the name of the git branch that was used for the build, for example `master`. + */ + commit_branch: string; + /** + * Provides the hash of the commit that was used for the build, for example `46f83e112520533338245862d366f6a02cef07d4`. + */ + commit_sha: string; + /** + * Provides the timestamp of the commit that was used for the build, for example `2021-02-23T08:08:02-05:00`. + */ + commit_timestamp: string; + /** + * Provides the rustc channel that was used for the build, for example `nightly`. + */ + rustc_channel: string; + /** + * Provides the rustc version that was used for the build, for example `1.52.0-nightly`. + */ + rustc_version: string; +}; + +export type BuildInformation = { + build_version: string; + commit_branch: string; + commit_sha: string; +}; + +/** + * Coin + */ +export type CoinSchema = { + amount: string; + denom: string; +}; + +export type DVpnGateway = { + authenticator?: null | AuthenticatorDetails; + build_information: BinaryBuildInformationOwned; + entry?: null | BasicEntryInformation; + identity_key: string; + ip_addresses: Array; + ip_packet_router?: null | IpPacketRouterDetails; + last_probe?: null | DirectoryGwProbe; + location: Location; + mix_port: number; + name: string; + performance: string; + role: NodeRole; +}; + +export type DailyStats = { + date_utc: string; + total_packets_dropped: number; + total_packets_received: number; + total_packets_sent: number; + total_stake: number; +}; + +export type DeclaredRoles = { + entry: boolean; + exit_ipr: boolean; + exit_nr: boolean; + mixnode: boolean; +}; + +export type DescribedNodeType = + | "legacy_mixnode" + | "legacy_gateway" + | "nym_node"; + +export type DirectoryGwProbe = { + last_updated_utc: string; + outcome: ProbeOutcome; +}; + +export type Entry = EntryTestResult | null; + +export type EntryTestResult = { + can_connect: boolean; + can_route: boolean; +}; + +export type Exit = { + can_connect: boolean; + can_route_ip_external_v4: boolean; + can_route_ip_external_v6: boolean; + can_route_ip_v4: boolean; + can_route_ip_v6: boolean; +}; + +export type ExtendedNymNode = { + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; +}; + +export type Gateway = { + bonded: boolean; + config_score: number; + description: NodeDescription; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_log?: string | null; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; +}; + +export type GatewaySkinny = { + config_score: number; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; +}; + +export type GatewaySummary = { + bonded: GatewaySummaryBonded; + historical: GatewaySummaryHistorical; +}; + +export type GatewaySummaryBonded = { + count: number; + entry: number; + exit: number; + last_updated_utc: string; +}; + +export type GatewaySummaryHistorical = { + count: number; + last_updated_utc: string; +}; + +export type HealthInfo = { + uptime: number; +}; + +export type HostInformation = { + hostname?: string | null; + ip_address: Array; + keys: HostKeys; +}; + +export type HostKeys = { + ed25519: string; + x25519: string; + x25519_noise?: string; +}; + +export type IpPacketRouterDetails = { + /** + * address of the embedded ip packet router + */ + address: string; +}; + +/** + * based on + * https://github.com/nymtech/nym-vpn-client/blob/nym-vpn-core-v1.10.0/nym-vpn-core/crates/nym-gateway-probe/src/types.rs + * TODO: long term types should be moved into this repo because nym-vpn-client + * could pull it as a dependency and we'd have a single source of truth + */ +export type LastProbeResult = { + node: string; + outcome: ProbeOutcome; + used_entry: string; +}; + +export type Location = { + latitude: number; + longitude: number; + two_letter_iso_country_code: string; +}; + +export type MixingNodesSummary = { + count: number; + last_updated_utc: string; + legacy: number; + self_described: number; +}; + +export type Mixnode = { + bonded: boolean; + description: NodeDescription; + full_details?: unknown; + is_dp_delegatee: boolean; + last_updated_utc: string; + mix_id: number; + self_described?: unknown; + total_stake: number; +}; + +export type MixnodeSummary = { + bonded: MixingNodesSummary; + historical: MixnodeSummaryHistorical; +}; + +export type MixnodeSummaryHistorical = { + count: number; + last_updated_utc: string; +}; + +export type NetworkRequesterDetails = { + /** + * address of the embedded network requester + */ + address: string; + /** + * flag indicating whether this network requester uses the exit policy rather than the deprecated allow list + */ + uses_exit_policy: boolean; +}; + +export type NetworkSummary = { + gateways: GatewaySummary; + mixnodes: MixnodeSummary; + total_nodes: number; +}; + +/** + * The cost parameters, or the cost function, defined for the particular mixnode that influences + * how the rewards should be split between the node operator and its delegators. + */ +export type NodeCostParams = { + /** + * Operating cost of the associated node per the entire interval. + */ + interval_operating_cost: CoinSchema; + /** + * The profit margin of the associated node, i.e. the desired percent of the reward to be distributed to the operator. + */ + profit_margin_percent: string; +}; + +export type NodeDelegation = { + amount: CoinSchema; + block_height: number; + cumulative_reward_ratio: string; + owner: string; + proxy?: string | null; +}; + +export type NodeDescription = { + /** + * details define other optional details. + */ + details: string; + /** + * moniker defines a human-readable name for the node. + */ + moniker: string; + /** + * security contact defines an optional email for security contact. + */ + security_contact: string; + /** + * website defines an optional website link. + */ + website: string; +}; + +export type NodeGeoData = { + city: string; + country: string; + ip_address: string; + latitude: string; + longitude: string; + org: string; + postal: string; + region: string; + timezone: string; +}; + +export type NodeRewarding = { + /** + * Information provided by the operator that influence the cost function. + */ + cost_params: NodeCostParams; + /** + * Total delegation and compounded reward earned by all node delegators. + */ + delegates: string; + /** + * Marks the epoch when this node was last rewarded so that we wouldn't accidentally attempt + * to reward it multiple times in the same epoch. + */ + last_rewarded_epoch: U32; + /** + * Total pledge and compounded reward earned by the node operator. + */ + operator: string; + /** + * Cumulative reward earned by the "unit delegation" since the block 0. + */ + total_unit_reward: string; + unique_delegations: number; + /** + * Value of the theoretical "unit delegation" that has delegated to this node at block 0. + */ + unit_delegation: string; +}; + +export type NodeRole = + | { + Mixnode: { + layer: number; + }; + } + | "EntryGateway" + | "ExitGateway" + | "Standby" + | "Inactive"; + +export type NymNodeData = { + authenticator?: null | AuthenticatorDetails; + auxiliary_details?: AuxiliaryDetails; + build_information: BinaryBuildInformationOwned; + declared_role?: DeclaredRoles; + host_information: HostInformation; + ip_packet_router?: null | IpPacketRouterDetails; + last_polled?: OffsetDateTimeJsonSchemaWrapper; + mixnet_websockets: WebSockets; + network_requester?: null | NetworkRequesterDetails; + wireguard?: null | WireguardDetails; +}; + +export type OffsetDateTimeJsonSchemaWrapper = string; + +export type PagedResultExtendedNymNode = { + items: Array<{ + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultGateway = { + items: Array<{ + bonded: boolean; + config_score: number; + description: NodeDescription; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_log?: string | null; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultGatewaySkinny = { + items: Array<{ + config_score: number; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultMixnode = { + items: Array<{ + bonded: boolean; + description: NodeDescription; + full_details?: unknown; + is_dp_delegatee: boolean; + last_updated_utc: string; + mix_id: number; + self_described?: unknown; + total_stake: number; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultService = { + items: Array<{ + gateway_identity_key: string; + hostname?: string | null; + ip_address?: string | null; + last_successful_ping_utc?: string | null; + last_updated_utc: string; + mixnet_websockets?: unknown; + routing_score: number; + service_provider_client_id?: string | null; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultSessionStats = { + items: Array<{ + day: string; + gateway_identity_key: string; + mixnet_sessions?: unknown; + node_id: number; + session_started: number; + unique_active_clients: number; + unknown_sessions?: unknown; + users_hashes?: unknown; + vpn_sessions?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type ProbeOutcome = { + as_entry: Entry; + as_exit?: null | Exit; + wg?: null | ProbeOutcomeV1; +}; + +export type ProbeOutcomeV1 = { + can_handshake_v4: boolean; + can_handshake_v6: boolean; + can_register: boolean; + can_resolve_dns_v4: boolean; + can_resolve_dns_v6: boolean; + download_duration_sec_v4: number; + download_duration_sec_v6: number; + download_error_v4: string; + download_error_v6: string; + downloaded_file_v4: string; + downloaded_file_v6: string; + ping_hosts_performance_v4: number; + ping_hosts_performance_v6: number; + ping_ips_performance_v4: number; + ping_ips_performance_v6: number; +}; + +export type Service = { + gateway_identity_key: string; + hostname?: string | null; + ip_address?: string | null; + last_successful_ping_utc?: string | null; + last_updated_utc: string; + mixnet_websockets?: unknown; + routing_score: number; + service_provider_client_id?: string | null; +}; + +export type SessionStats = { + day: string; + gateway_identity_key: string; + mixnet_sessions?: unknown; + node_id: number; + session_started: number; + unique_active_clients: number; + unknown_sessions?: unknown; + users_hashes?: unknown; + vpn_sessions?: unknown; +}; + +export type SummaryHistory = { + date: string; + timestamp_utc: string; + value_json: unknown; +}; + +export type TestRun = { + id: number; + identity_key: string; + log: string; + status: string; +}; + +export type WebSockets = { + ws_port: number; + wss_port?: number | null; +}; + +export type WireguardDetails = { + port: number; + public_key: string; +}; + +export type U32 = number; + +export type GetGatewaysData = { + body?: never; + path?: never; + query?: { + min_node_version?: string; + }; + url: "/dvpn/v1/directory/gateways"; +}; + +export type GetGatewaysResponses = { + 200: Array; +}; + +export type GetGatewaysResponse = + GetGatewaysResponses[keyof GetGatewaysResponses]; + +export type GetGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/countries"; +}; + +export type GetGatewayCountriesResponses = { + 200: Array; +}; + +export type GetGatewayCountriesResponse = + GetGatewayCountriesResponses[keyof GetGatewayCountriesResponses]; + +export type GetGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/country/{two_letter_country_code}"; +}; + +export type GetGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetGatewaysByCountryResponse = + GetGatewaysByCountryResponses[keyof GetGatewaysByCountryResponses]; + +export type GetEntryGatewaysData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/entry"; +}; + +export type GetEntryGatewaysResponses = { + 200: Array; +}; + +export type GetEntryGatewaysResponse = + GetEntryGatewaysResponses[keyof GetEntryGatewaysResponses]; + +export type GetEntryGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/entry/countries"; +}; + +export type GetEntryGatewayCountriesResponses = { + 200: Array; +}; + +export type GetEntryGatewayCountriesResponse = + GetEntryGatewayCountriesResponses[keyof GetEntryGatewayCountriesResponses]; + +export type GetEntryGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/entry/country/{two_letter_country_code}"; +}; + +export type GetEntryGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetEntryGatewaysByCountryResponse = + GetEntryGatewaysByCountryResponses[keyof GetEntryGatewaysByCountryResponses]; + +export type GetExitGatewaysData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/exit"; +}; + +export type GetExitGatewaysResponses = { + 200: Array; +}; + +export type GetExitGatewaysResponse = + GetExitGatewaysResponses[keyof GetExitGatewaysResponses]; + +export type GetExitGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/exit/countries"; +}; + +export type GetExitGatewayCountriesResponses = { + 200: Array; +}; + +export type GetExitGatewayCountriesResponse = + GetExitGatewayCountriesResponses[keyof GetExitGatewayCountriesResponses]; + +export type GetExitGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/exit/country/{two_letter_country_code}"; +}; + +export type GetExitGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetExitGatewaysByCountryResponse = + GetExitGatewaysByCountryResponses[keyof GetExitGatewaysByCountryResponses]; + +export type NymNodesData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/explorer/v3/nym-nodes"; +}; + +export type NymNodesResponses = { + 200: PagedResultExtendedNymNode; +}; + +export type NymNodesResponse = NymNodesResponses[keyof NymNodesResponses]; + +export type NodeDelegationsData = { + body?: never; + path: { + node_id: U32; + }; + query?: never; + url: "/explorer/v3/nym-nodes/{node_id}/delegations"; +}; + +export type NodeDelegationsResponses = { + 200: NodeDelegation; +}; + +export type NodeDelegationsResponse = + NodeDelegationsResponses[keyof NodeDelegationsResponses]; + +export type GatewaysData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/gateways"; +}; + +export type GatewaysResponses = { + 200: PagedResultGateway; +}; + +export type GatewaysResponse = GatewaysResponses[keyof GatewaysResponses]; + +export type GatewaysSkinnyData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/gateways/skinny"; +}; + +export type GatewaysSkinnyResponses = { + 200: PagedResultGatewaySkinny; +}; + +export type GatewaysSkinnyResponse = + GatewaysSkinnyResponses[keyof GatewaysSkinnyResponses]; + +export type GetGatewayData = { + body?: never; + path: { + identity_key: string; + }; + query?: never; + url: "/v2/gateways/{identity_key}"; +}; + +export type GetGatewayResponses = { + 200: Gateway; +}; + +export type GetGatewayResponse = GetGatewayResponses[keyof GetGatewayResponses]; + +export type GetAllSessionsData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + node_id?: string; + day?: string; + }; + url: "/v2/metrics/sessions"; +}; + +export type GetAllSessionsResponses = { + 200: PagedResultSessionStats; +}; + +export type GetAllSessionsResponse = + GetAllSessionsResponses[keyof GetAllSessionsResponses]; + +export type MixnodesData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/mixnodes"; +}; + +export type MixnodesResponses = { + 200: PagedResultMixnode; +}; + +export type MixnodesResponse = MixnodesResponses[keyof MixnodesResponses]; + +export type GetStatsData = { + body?: never; + path?: never; + query?: { + offset?: number; + }; + url: "/v2/mixnodes/stats"; +}; + +export type GetStatsResponses = { + 200: Array; +}; + +export type GetStatsResponse = GetStatsResponses[keyof GetStatsResponses]; + +export type GetMixnodesData = { + body?: never; + path: { + mix_id: string; + }; + query?: never; + url: "/v2/mixnodes/{mix_id}"; +}; + +export type GetMixnodesResponses = { + 200: Mixnode; +}; + +export type GetMixnodesResponse = + GetMixnodesResponses[keyof GetMixnodesResponses]; + +export type Mixnodes2Data = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + wss?: boolean; + hostname?: boolean; + entry?: boolean; + }; + url: "/v2/services"; +}; + +export type Mixnodes2Responses = { + 200: PagedResultService; +}; + +export type Mixnodes2Response = Mixnodes2Responses[keyof Mixnodes2Responses]; + +export type BuildInformationData = { + body?: never; + path?: never; + query?: never; + url: "/v2/status/build_information"; +}; + +export type BuildInformationResponses = { + 200: BinaryBuildInformationOwned; +}; + +export type BuildInformationResponse = + BuildInformationResponses[keyof BuildInformationResponses]; + +export type HealthData = { + body?: never; + path?: never; + query?: never; + url: "/v2/status/health"; +}; + +export type HealthResponses = { + 200: HealthInfo; +}; + +export type HealthResponse = HealthResponses[keyof HealthResponses]; + +export type SummaryData = { + body?: never; + path?: never; + query?: never; + url: "/v2/summary"; +}; + +export type SummaryResponses = { + 200: NetworkSummary; +}; + +export type SummaryResponse = SummaryResponses[keyof SummaryResponses]; + +export type SummaryHistoryData = { + body?: never; + path?: never; + query?: never; + url: "/v2/summary/history"; +}; + +export type SummaryHistoryResponses = { + 200: Array; +}; + +export type SummaryHistoryResponse = + SummaryHistoryResponses[keyof SummaryHistoryResponses]; + +export type ClientOptions = { + baseUrl: "https://mainnet-node-status-api.nymtech.cc" | (string & {}); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx b/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx new file mode 100644 index 0000000000..9a7a13ac0b --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx @@ -0,0 +1,25 @@ +"use client"; +import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; + +export default function CardAlert() { + return ( + + + + + Plan about to expire + + + Enjoy 10% off when renewing your plan today. + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx b/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx new file mode 100644 index 0000000000..e0167a578a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx @@ -0,0 +1,24 @@ +import Link from "@mui/material/Link"; +import Typography from "@mui/material/Typography"; + +export default function Copyright(props: any) { + return ( + + {"Copyright © "} + + Nym Technologies SA + {" "} + {new Date().getFullYear()} + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx new file mode 100644 index 0000000000..ee5ea54091 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; + +export type GraphProps = { + title: string; + children?: React.ReactNode; +}; + +export default function GraphCard({ title, children }: GraphProps) { + return ( + + + + {title} + + {children} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx new file mode 100644 index 0000000000..f551b85e3d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx @@ -0,0 +1,42 @@ +"use client"; +import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; +import InsightsRoundedIcon from "@mui/icons-material/InsightsRounded"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +export default function HighlightedCard() { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); + + return ( + + + + + Explore your data + + + Uncover performance and visitor insights with our data wizardry. + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx b/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx new file mode 100644 index 0000000000..d1c47c549f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx @@ -0,0 +1,36 @@ +import SignalCellularAltIcon from "@mui/icons-material/SignalCellularAlt"; +import SignalCellularAlt1Bar from "@mui/icons-material/SignalCellularAlt1Bar"; +import SignalCellularAlt2Bar from "@mui/icons-material/SignalCellularAlt2Bar"; +import SignalCellularConnectedNoInternet0BarIcon from "@mui/icons-material/SignalCellularConnectedNoInternet0Bar"; + +export const ScoreIcon = ({ score }: { score?: string }) => { + if (!score) { + return ; + } + if (score.toLowerCase() === "offline") { + return ; + } + if (score.toLowerCase() === "high") { + return ; + } + if (score.toLowerCase() === "medium") { + return ; + } + return ; +}; + +export const ReverseScoreIcon = ({ score }: { score?: string }) => { + if (!score) { + return ; + } + if (score.toLowerCase() === "offline") { + return ; + } + if (score.toLowerCase() === "low") { + return ; + } + if (score.toLowerCase() === "medium") { + return ; + } + return ; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx new file mode 100644 index 0000000000..0ecf2514a0 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { areaElementClasses } from "@mui/x-charts/LineChart"; +import { SparkLineChart } from "@mui/x-charts/SparkLineChart"; + +export type StatCardProps = { + title: string; + value: string; + interval: string; + trend: "up" | "down" | "neutral"; + data: number[]; +}; + +function getDaysInMonth(month: number, year: number) { + const date = new Date(year, month, 0); + const monthName = date.toLocaleDateString("en-US", { + month: "short", + }); + const daysInMonth = date.getDate(); + const days = []; + let i = 1; + while (days.length < daysInMonth) { + days.push(`${monthName} ${i}`); + i += 1; + } + return days; +} + +function AreaGradient({ color, id }: { color: string; id: string }) { + return ( + + + + + + + ); +} + +export default function StatCard({ + title, + value, + interval, + trend, + data, +}: StatCardProps) { + const theme = useTheme(); + const daysInWeek = getDaysInMonth(4, 2024); + + const trendColors = { + up: + theme.palette.mode === "light" + ? theme.palette.success.main + : theme.palette.success.dark, + down: + theme.palette.mode === "light" + ? theme.palette.error.main + : theme.palette.error.dark, + neutral: + theme.palette.mode === "light" + ? theme.palette.grey[400] + : theme.palette.grey[700], + }; + + const labelColors = { + up: "success" as const, + down: "error" as const, + neutral: "default" as const, + }; + + const color = labelColors[trend]; + const chartColor = trendColors[trend]; + const trendValues = { up: "+25%", down: "-25%", neutral: "+5%" }; + + return ( + + + + {title} + + + + + + {value} + + + + + {interval} + + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx new file mode 100644 index 0000000000..30f066e937 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx @@ -0,0 +1,58 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { rollup } from "d3-array"; +import React from "react"; + +export const GatewayCanQueryMetadataTopup = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const results = data.map((g) => { + const r = (g.last_probe?.outcome.wg as any)?.can_query_metadata_v4; + if (r === undefined) { + return "-"; + } + if (r === true) { + return "yes"; + } + return "no"; + }); + // count occurrences of each result + const resultCounts = rollup( + results, + (v) => v.length, + (v) => v, // group by result string + ); + + const chartData = Array.from(resultCounts, ([result, count]) => ({ + result, + count, + })); + + const labels = chartData.map((d) => d.result); + const values = chartData.map((d) => d.count); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx new file mode 100644 index 0000000000..17ad7c128f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx @@ -0,0 +1,43 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayDownloadSpeeds = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().thresholds(10); // Number of bins + const bins = binner( + data + .map((g) => g.extra.downloadSpeedMBPerSec) + .filter((g) => Boolean(g)) as number[], + ); + + const labels = bins.map((b) => `${b.x0}-${b.x1} MB/sec`); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx new file mode 100644 index 0000000000..f3c201dce9 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx @@ -0,0 +1,45 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import React from "react"; + +export const GatewayLoads = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binned = data.reduce( + (acc, g) => { + const score: "low" | "medium" | "high" | "offline" = + (g as any).performance_v2?.load || "offline"; + acc[score] += 1; + return acc; + }, + { offline: 0, low: 0, medium: 0, high: 0 }, + ); + + const labels = ["offline", "low", "medium", "high"]; + const values = Object.values(binned); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx new file mode 100644 index 0000000000..38bca5f5c5 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx @@ -0,0 +1,43 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayPingPercentage = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().domain([0, 1]).thresholds(10); // Number of bins + const bins = binner( + data.map((g) => g.last_probe?.outcome.wg?.ping_ips_performance_v4 || 0), + ); + + const labels = bins.map( + (b) => `${(b.x0 || 0) * 100}-${(b.x1 || 0) * 100}%`, + ); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx new file mode 100644 index 0000000000..3daa8f4a67 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx @@ -0,0 +1,45 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import React from "react"; + +export const GatewayScores = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binned = data.reduce( + (acc, g) => { + const score: "low" | "medium" | "high" | "offline" = + (g as any).performance_v2?.score || "offline"; + acc[score] += 1; + return acc; + }, + { offline: 0, low: 0, medium: 0, high: 0 }, + ); + + const labels = ["offline", "low", "medium", "high"]; + const values = Object.values(binned); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx new file mode 100644 index 0000000000..d84f707316 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx @@ -0,0 +1,41 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayUptimePercentage = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().domain([0, 1]).thresholds(20); // Number of bins + const bins = binner(data.map((g) => Number.parseFloat(g.performance))); + + const labels = bins.map( + (b) => `${(b.x0 || 0) * 100}-${(b.x1 || 0) * 100}%`, + ); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx new file mode 100644 index 0000000000..a6e9d0f3c7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx @@ -0,0 +1,49 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { rollup } from "d3-array"; +import React from "react"; + +export const GatewayVersions = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const versions = data.map((g) => g.build_information.build_version); + // count occurrences of each version + const versionCounts = rollup( + versions, + (v) => v.length, + (v) => v, // group by version string + ); + + const chartData = Array.from(versionCounts, ([version, count]) => ({ + version, + count, + })); + + const labels = chartData.map((d) => d.version); + const values = chartData.map((d) => d.count); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx new file mode 100644 index 0000000000..5f978551f4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import ColorModeIconDropdown from "@/theme/ColorModeIconDropdown"; +import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; +import AppBar from "@mui/material/AppBar"; +import Stack from "@mui/material/Stack"; +import { tabsClasses } from "@mui/material/Tabs"; +import MuiToolbar from "@mui/material/Toolbar"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; +import MenuButton from "./MenuButton"; +import SideMenuMobile from "./SideMenuMobile"; +import { SiteLogo } from "./SiteLogo"; + +const Toolbar = styled(MuiToolbar)({ + width: "100%", + padding: "12px", + display: "flex", + flexDirection: "column", + alignItems: "start", + justifyContent: "center", + gap: "12px", + flexShrink: 0, + [`& ${tabsClasses.flexContainer}`]: { + gap: "8px", + p: "8px", + pb: 0, + }, +}); + +export default function AppNavbar() { + const [open, setOpen] = React.useState(false); + + const toggleDrawer = (newOpen: boolean) => () => { + setOpen(newOpen); + }; + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx new file mode 100644 index 0000000000..dd1ebef7d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx @@ -0,0 +1,91 @@ +"use client"; + +import DarkModeIcon from "@mui/icons-material/DarkModeRounded"; +import LightModeIcon from "@mui/icons-material/LightModeRounded"; +import Box from "@mui/material/Box"; +import IconButton, { type IconButtonOwnProps } from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useColorScheme } from "@mui/material/styles"; +import * as React from "react"; + +export default function ColorModeIconDropdown(props: IconButtonOwnProps) { + const { mode, systemMode, setMode } = useColorScheme(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleMode = (targetMode: "system" | "light" | "dark") => () => { + setMode(targetMode); + handleClose(); + }; + if (!mode) { + return ( + ({ + verticalAlign: "bottom", + display: "inline-flex", + width: "2.25rem", + height: "2.25rem", + borderRadius: theme.shape.borderRadius, + border: "1px solid", + borderColor: theme.palette.divider, + })} + /> + ); + } + const resolvedMode = (systemMode || mode) as "light" | "dark"; + const icon = { + light: , + dark: , + }[resolvedMode]; + return ( + + + {icon} + + + + System + + + Light + + + Dark + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx new file mode 100644 index 0000000000..839b3b6dc3 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx @@ -0,0 +1,29 @@ +"use client"; + +import MenuItem from "@mui/material/MenuItem"; +import Select, { type SelectProps } from "@mui/material/Select"; +import { useColorScheme } from "@mui/material/styles"; + +export default function ColorModeSelect(props: SelectProps) { + const { mode, setMode } = useColorScheme(); + if (!mode) { + return null; + } + return ( + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx new file mode 100644 index 0000000000..5d7ebe12c2 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx @@ -0,0 +1,33 @@ +"use client"; + +import ColorModeIconDropdown from "@/theme/ColorModeIconDropdown"; +import Stack from "@mui/material/Stack"; +import NavbarBreadcrumbs from "./NavbarBreadcrumbs"; + +import type React from "react"; +import Search from "./Search"; + +const showSearch = false; + +export default function Header({ title }: { title?: React.ReactNode }) { + return ( + + + + {showSearch && } + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx new file mode 100644 index 0000000000..c628996e25 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx @@ -0,0 +1,23 @@ +"use client"; +import Badge, { badgeClasses } from "@mui/material/Badge"; +import IconButton, { type IconButtonProps } from "@mui/material/IconButton"; + +export interface MenuButtonProps extends IconButtonProps { + showBadge?: boolean; +} + +export default function MenuButton({ + showBadge = false, + ...props +}: MenuButtonProps) { + return ( + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx new file mode 100644 index 0000000000..a38b662c88 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx @@ -0,0 +1,60 @@ +"use client"; + +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Stack from "@mui/material/Stack"; +import NextLink from "next/link"; +import { usePathname } from "next/navigation"; + +import DoorSlidingOutlinedIcon from "@mui/icons-material/DoorSlidingOutlined"; +import HubIcon from "@mui/icons-material/Hub"; +import SettingsInputAntennaIcon from "@mui/icons-material/SettingsInputAntenna"; +import ViewModuleIcon from "@mui/icons-material/ViewModule"; +import WorkspacePremiumIcon from "@mui/icons-material/WorkspacePremium"; + +const mainListItemsAll = [ + { text: "Network Nodes", icon: , url: "/nodes" }, + { text: "dVPN Gateways", icon: , url: "/dvpn" }, + { text: "SOCKS5 NRs", icon: , url: "/socks5" }, + { + text: "zk-nym Signers", + icon: , + url: "/zk-nym-signers", + }, + { + text: "Nyx Chain Validators", + icon: , + url: "/validators", + }, +]; + +const mainListItems = [mainListItemsAll[0], mainListItemsAll[1]]; + +export default function MenuContent() { + const path = usePathname(); + return ( + + + {mainListItems.map((item, index) => ( + + + {item.icon} + + + + ))} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx new file mode 100644 index 0000000000..b74145778e --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx @@ -0,0 +1,37 @@ +"use client"; + +import NavigateNextRoundedIcon from "@mui/icons-material/NavigateNextRounded"; +import Breadcrumbs, { breadcrumbsClasses } from "@mui/material/Breadcrumbs"; +import Typography from "@mui/material/Typography"; +import { styled } from "@mui/material/styles"; +import type React from "react"; + +const StyledBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({ + margin: theme.spacing(1, 0), + [`& .${breadcrumbsClasses.separator}`]: { + color: theme.palette.action.disabled, + margin: 1, + }, + [`& .${breadcrumbsClasses.ol}`]: { + alignItems: "center", + }, +})); + +export default function NavbarBreadcrumbs({ + title, +}: { title?: React.ReactNode }) { + return ( + } + > + Dashboard + + {title || "Home"} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx new file mode 100644 index 0000000000..940ad44148 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx @@ -0,0 +1,81 @@ +"use client"; + +import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; +import MoreVertRoundedIcon from "@mui/icons-material/MoreVertRounded"; +import Divider, { dividerClasses } from "@mui/material/Divider"; +import { listClasses } from "@mui/material/List"; +import ListItemIcon, { listItemIconClasses } from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Menu from "@mui/material/Menu"; +import MuiMenuItem from "@mui/material/MenuItem"; +import { paperClasses } from "@mui/material/Paper"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; +import MenuButton from "./MenuButton"; + +const MenuItem = styled(MuiMenuItem)({ + margin: "2px 0", +}); + +export default function OptionsMenu() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( + + + + + + Profile + My account + + Add another account + Settings + + + Logout + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx new file mode 100644 index 0000000000..78d95c6bdb --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx @@ -0,0 +1,27 @@ +"use client"; + +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import FormControl from "@mui/material/FormControl"; +import InputAdornment from "@mui/material/InputAdornment"; +import OutlinedInput from "@mui/material/OutlinedInput"; + +export default function Search() { + return ( + + + + + } + inputProps={{ + "aria-label": "search", + }} + /> + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx new file mode 100644 index 0000000000..3b0d2c2ed2 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx @@ -0,0 +1,107 @@ +"use client"; + +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import ConstructionRoundedIcon from "@mui/icons-material/ConstructionRounded"; +import DevicesRoundedIcon from "@mui/icons-material/DevicesRounded"; +import SmartphoneRoundedIcon from "@mui/icons-material/SmartphoneRounded"; +import MuiAvatar from "@mui/material/Avatar"; +import Divider from "@mui/material/Divider"; +import MuiListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import ListSubheader from "@mui/material/ListSubheader"; +import MenuItem from "@mui/material/MenuItem"; +import Select, { + type SelectChangeEvent, + selectClasses, +} from "@mui/material/Select"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; + +const Avatar = styled(MuiAvatar)(({ theme }) => ({ + width: 28, + height: 28, + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.secondary, + border: `1px solid ${theme.palette.divider}`, +})); + +const ListItemAvatar = styled(MuiListItemAvatar)({ + minWidth: 0, + marginRight: 12, +}); + +export default function SelectContent() { + const [company, setCompany] = React.useState(""); + + const handleChange = (event: SelectChangeEvent) => { + setCompany(event.target.value as string); + }; + + return ( + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx new file mode 100644 index 0000000000..047e2b696d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { SiteLogo } from "@/components/nav/SiteLogo"; +import Box from "@mui/material/Box"; +import Divider from "@mui/material/Divider"; +import MuiDrawer, { drawerClasses } from "@mui/material/Drawer"; +import { styled } from "@mui/material/styles"; +import MenuContent from "./MenuContent"; + +const drawerWidth = 240; + +const Drawer = styled(MuiDrawer)({ + width: drawerWidth, + flexShrink: 0, + boxSizing: "border-box", + mt: 10, + [`& .${drawerClasses.paper}`]: { + width: drawerWidth, + boxSizing: "border-box", + }, +}); + +export default function SideMenu() { + return ( + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx new file mode 100644 index 0000000000..f6e866af5a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Divider from "@mui/material/Divider"; +import Drawer, { drawerClasses } from "@mui/material/Drawer"; +import Stack from "@mui/material/Stack"; +import MenuContent from "./MenuContent"; + +interface SideMenuMobileProps { + open: boolean | undefined; + toggleDrawer: (newOpen: boolean) => () => void; +} + +export default function SideMenuMobile({ + open, + toggleDrawer, +}: SideMenuMobileProps) { + return ( + theme.zIndex.drawer + 1, + [`& .${drawerClasses.paper}`]: { + backgroundImage: "none", + backgroundColor: "background.paper", + }, + }} + > + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx new file mode 100644 index 0000000000..336ee3795c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx @@ -0,0 +1,15 @@ +import Link from "@mui/material/Link"; +import NextLink from "next/link"; + +export function SiteLogo() { + return ( + + Nym Node Status + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx b/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx new file mode 100644 index 0000000000..43578ed17f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { type Client, createClient } from "@/client/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import type React from "react"; +import { createContext, useContext, useRef } from "react"; + +interface State { + client?: Client; +} + +export const QueryContext = createContext({ + client: undefined, +}); + +export const useQueryContext = (): React.ContextType => + useContext(QueryContext); + +export const QueryContextProvider = ({ + children, +}: { + children: React.ReactNode | React.ReactNode[]; +}) => { + const openApiClient = useRef( + createClient({ baseUrl: "https://mainnet-node-status-api.nymtech.cc" }), + ); + const queryClient = useRef(new QueryClient()); + + const state: State = { + client: openApiClient.current, + }; + + return ( + + + {children} + {/* Add devtools in development */} + {process.env.NODE_ENV === "development" && ( + + )} + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts new file mode 100644 index 0000000000..852d109cc7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts @@ -0,0 +1,4 @@ +export interface Pagination { + pageIndex?: number; + pageSize?: number; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts new file mode 100644 index 0000000000..05bfa0a5cf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts @@ -0,0 +1,39 @@ +import { nymNodes } from "@/client/sdk.gen"; +import { useQueryContext } from "@/context/queryContext"; +import type { NymNode } from "@/hooks/useNymNodes"; +import { useQuery } from "@tanstack/react-query"; +import React from "react"; + +export const useAllNymNodes = () => { + const { client } = useQueryContext(); + const key = "nym-nodes-all"; + + const queryFn = React.useCallback(async (): Promise => { + const size = 100; + let busy = true; + let page = 0; + const allData = []; + do { + const { data, error } = await nymNodes({ client, query: { page, size } }); + if (error) throw error; + + if (data?.items) { + allData.push(...data.items); + } + + // keep querying until data is less than a page + if ((data?.items.length || 0) < size) { + busy = false; + } + page += 1; + } while (busy); + return allData; + }, [client]); + + const query = useQuery({ queryKey: [key], queryFn }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts new file mode 100644 index 0000000000..d1508ce7da --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts @@ -0,0 +1,20 @@ +import { getGatewaysOptions } from "@/client/@tanstack/react-query.gen"; +import { useQueryContext } from "@/context/queryContext"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +export const useDVpnGateways = () => { + const { client } = useQueryContext(); + const key = "gateways"; + + const query = useQuery({ + ...getGatewaysOptions({ + client, + }), + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts new file mode 100644 index 0000000000..02c87b46af --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts @@ -0,0 +1,47 @@ +import { getGateways } from "@/client/sdk.gen"; +import { useQueryContext } from "@/context/queryContext"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import React from "react"; + +export const useDVpnGatewaysTransformed = () => { + const { client } = useQueryContext(); + const key = "gateways"; + + const queryFn = React.useCallback(async () => { + const { data, error } = await getGateways({ client }); + if (error) throw error; + return (data || []).map((g) => { + const wg = g.last_probe?.outcome.wg as any; + const downloadSpeedMBPerSec = wg + ? Math.round( + (10 * ((wg?.downloaded_file_size_bytes_v4 || 0) / 1024 / 1024)) / + ((wg?.download_duration_milliseconds_v4 || 1) / 1000), + ) / 10 + : undefined; + const downloadSpeedIpv6MBPerSec = wg + ? Math.round( + (10 * ((wg?.downloaded_file_size_bytes_v6 || 0) / 1024 / 1024)) / + ((wg?.download_duration_milliseconds_v6 || 1) / 1000), + ) / 10 + : undefined; + return { + ...g, + extra: { + downloadSpeedMBPerSec, + downloadSpeedIpv6MBPerSec, + }, + }; + }); + }, [client]); + + const query = useQuery({ + queryKey: [key], + queryFn, + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts new file mode 100644 index 0000000000..fa17795d7d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts @@ -0,0 +1,52 @@ +import type { + DescribedNodeType, + NodeDescription, + NodeGeoData, + NodeRewarding, + NymNodeData, + U32, +} from "@/client"; +import { nymNodesOptions } from "@/client/@tanstack/react-query.gen"; +import { useQueryContext } from "@/context/queryContext"; +import type { Pagination } from "@/hooks/types"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +// TODO: how to re-use autogenerated subtype +export type NymNode = { + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; +}; + +export const useNymNodes = (props?: Pagination) => { + const { client } = useQueryContext(); + const { pageIndex = 0, pageSize = 10 } = props || {}; + const key = "nym-nodes"; + + const query = useQuery({ + ...nymNodesOptions({ + client, + query: { + page: pageIndex, + size: pageSize, + }, + }), + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx b/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx new file mode 100644 index 0000000000..e82ba5336c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx @@ -0,0 +1,29 @@ +import Copyright from "@/components/Copyright"; +import AppNavbar from "@/components/nav/AppNavbar"; +import SideMenu from "@/components/nav/SideMenu"; +import Box from "@mui/material/Box"; +import { alpha } from "@mui/material/styles"; +import type React from "react"; + +export default function LayoutWithNav({ + children, +}: { children?: React.ReactNode }) { + return ( + + + + {/* Main content */} + ({ + flexGrow: 1, + backgroundColor: alpha(theme.palette.background.default, 1), + overflow: "auto", + })} + > + {children} + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx b/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx new file mode 100644 index 0000000000..9b2e50bc45 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx @@ -0,0 +1,23 @@ +import Header from "@/components/nav/Header"; +import Stack from "@mui/material/Stack"; +import type React from "react"; + +export default function NestedLayoutWithHeader({ + children, + header, +}: { children?: React.ReactNode; header?: React.ReactNode }) { + return ( + +
+ {children} + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx b/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx new file mode 100644 index 0000000000..dd1ebef7d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx @@ -0,0 +1,91 @@ +"use client"; + +import DarkModeIcon from "@mui/icons-material/DarkModeRounded"; +import LightModeIcon from "@mui/icons-material/LightModeRounded"; +import Box from "@mui/material/Box"; +import IconButton, { type IconButtonOwnProps } from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useColorScheme } from "@mui/material/styles"; +import * as React from "react"; + +export default function ColorModeIconDropdown(props: IconButtonOwnProps) { + const { mode, systemMode, setMode } = useColorScheme(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleMode = (targetMode: "system" | "light" | "dark") => () => { + setMode(targetMode); + handleClose(); + }; + if (!mode) { + return ( + ({ + verticalAlign: "bottom", + display: "inline-flex", + width: "2.25rem", + height: "2.25rem", + borderRadius: theme.shape.borderRadius, + border: "1px solid", + borderColor: theme.palette.divider, + })} + /> + ); + } + const resolvedMode = (systemMode || mode) as "light" | "dark"; + const icon = { + light: , + dark: , + }[resolvedMode]; + return ( + + + {icon} + + + + System + + + Light + + + Dark + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx b/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx new file mode 100644 index 0000000000..4773499620 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx @@ -0,0 +1,27 @@ +import { ThemeProvider, createTheme } from "@mui/material/styles"; +import * as React from "react"; + +interface AppThemeProps { + children: React.ReactNode; + // themeComponents?: ThemeOptions["components"]; +} + +export default function AppTheme(props: AppThemeProps) { + const { children } = props; + const theme = React.useMemo(() => { + return createTheme({ + colorSchemes: { + dark: true, + light: true, + }, + typography: { + fontFamily: "system-ui, sans-serif", + }, + }); + }, []); + return ( + + {children} + + ); +} From f960bfa91b2bbbdbb2531c2e6e348452c7e43cd0 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Mon, 17 Nov 2025 11:40:27 +0100 Subject: [PATCH 22/23] probe fixes testing probe locally --- nym-gateway-probe/src/lib.rs | 4 ++-- nym-gateway-probe/src/nodes.rs | 10 ++++++++++ nym-gateway-probe/src/run.rs | 8 +++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 76b8c5c567..2c2f16ab2f 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -186,7 +186,7 @@ impl Probe { ) -> anyhow::Result { let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; - let tested_entry = self.tested_node.is_same_as_entry(); + let tested_entry = !only_wireguard; let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; let storage = Ephemeral::default(); @@ -232,7 +232,7 @@ impl Probe { only_wireguard: bool, min_mixnet_performance: Option, ) -> anyhow::Result { - let tested_entry = self.tested_node.is_same_as_entry(); + let tested_entry = !only_wireguard; let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; if config_dir.is_file() { diff --git a/nym-gateway-probe/src/nodes.rs b/nym-gateway-probe/src/nodes.rs index 0726bb3b7d..773470859f 100644 --- a/nym-gateway-probe/src/nodes.rs +++ b/nym-gateway-probe/src/nodes.rs @@ -199,6 +199,16 @@ impl NymApiDirectory { .map(|(id, _)| *id) } + pub fn random_entry_gateway(&self) -> anyhow::Result { + info!("Selecting random entry gateway"); + self.nodes + .iter() + .filter(|(_, n)| n.described.description.declared_role.entry) + .choose(&mut rand::thread_rng()) + .ok_or(anyhow!("no entry gateways available")) + .map(|(id, _)| *id) + } + pub fn get_nym_node(&self, identity: NodeIdentity) -> anyhow::Result { self.nodes .get(&identity) diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 9487b10905..064a0b49ef 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -126,8 +126,14 @@ pub(crate) async fn run() -> anyhow::Result { let entry = if let Some(gateway) = &args.entry_gateway { NodeIdentity::from_base58_string(gateway)? + } else if let Some(node) = args.node { + if directory.entry_gateway(&node).is_ok() { + node + } else { + directory.random_entry_gateway()? + } } else { - directory.random_exit_with_ipr()? + directory.random_entry_gateway()? }; let test_point = if let Some(node) = args.node { From bcce854a8b0c76b58cbaf8f063191b64428bb78e Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Mon, 17 Nov 2025 16:03:28 +0100 Subject: [PATCH 23/23] more attempts --- nym-gateway-probe/src/lib.rs | 46 ++++++++++++++++++++++++++---------- nym-gateway-probe/src/run.rs | 33 ++++++++++++++++---------- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 2c2f16ab2f..5f7827dff0 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -130,12 +130,20 @@ pub enum TestedNode { SameAsEntry, Custom { identity: NodeIdentity, + shares_entry: bool, }, } impl TestedNode { pub fn is_same_as_entry(&self) -> bool { - matches!(self, TestedNode::SameAsEntry) + matches!( + self, + TestedNode::SameAsEntry + | TestedNode::Custom { + shares_entry: true, + .. + } + ) } } @@ -186,7 +194,7 @@ impl Probe { ) -> anyhow::Result { let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; - let tested_entry = !only_wireguard; + let tested_entry = self.tested_node.is_same_as_entry(); let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; let storage = Ephemeral::default(); @@ -232,7 +240,7 @@ impl Probe { only_wireguard: bool, min_mixnet_performance: Option, ) -> anyhow::Result { - let tested_entry = !only_wireguard; + let tested_entry = self.tested_node.is_same_as_entry(); let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; if config_dir.is_file() { @@ -309,7 +317,20 @@ impl Probe { let entry_gateway = directory.entry_gateway(&self.entrypoint)?; let node_info: TestedNodeDetails = match self.tested_node { - TestedNode::Custom { identity } => { + TestedNode::Custom { + identity: _, + shares_entry: true, + } => { + debug!( + "testing node {} as both entry and exit", + entry_gateway.identity() + ); + entry_gateway.to_testable_node()? + } + TestedNode::Custom { + identity, + shares_entry: false, + } => { let node = directory.get_nym_node(identity)?; info!( "testing node {} (via entry {})", @@ -421,12 +442,17 @@ impl Probe { storage.credential_store().clone(), client, ); - let credential = bw_controller - .prepare_ecash_ticket( + let (wg_ticket_type, credential_provider) = if tested_entry { + ( TicketType::V1WireguardEntry, nym_address.gateway().to_bytes(), - 1, ) + } else { + (TicketType::V1WireguardExit, node_info.identity.to_bytes()) + }; + + let credential = bw_controller + .prepare_ecash_ticket(wg_ticket_type, credential_provider, 1) .await? .data; @@ -711,11 +737,7 @@ async fn do_ping_entry( } info!("Successfully mixnet pinged ourselves"); - if tested_entry { - Entry::success() - } else { - Entry::NotTested - } + Entry::success() } async fn connect_exit( diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 064a0b49ef..9c715bcdda 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -124,22 +124,31 @@ pub(crate) async fn run() -> anyhow::Result { let directory = NymApiDirectory::new(api_url).await?; - let entry = if let Some(gateway) = &args.entry_gateway { - NodeIdentity::from_base58_string(gateway)? - } else if let Some(node) = args.node { - if directory.entry_gateway(&node).is_ok() { - node - } else { - directory.random_entry_gateway()? - } + let node_override = args.node; + let entry_override = if let Some(gateway) = &args.entry_gateway { + Some(NodeIdentity::from_base58_string(gateway)?) + } else { + None + }; + + let entry = if let Some(entry) = entry_override { + entry + } else if let Some(node) = node_override { + node } else { directory.random_entry_gateway()? }; - let test_point = if let Some(node) = args.node { - TestedNode::Custom { identity: node } - } else { - TestedNode::SameAsEntry + let test_point = match (node_override, entry_override) { + (Some(node), Some(_)) => TestedNode::Custom { + identity: node, + shares_entry: false, + }, + (Some(node), None) => TestedNode::Custom { + identity: node, + shares_entry: true, + }, + (None, _) => TestedNode::SameAsEntry, }; let mut trial =