Compare commits

..

17 Commits

Author SHA1 Message Date
dynco-nym ab1fc724ae Fix probe types 2025-06-05 23:58:26 +02:00
dynco-nym 775250b0ee Bump package version 2025-06-05 20:30:45 +02:00
dynco-nym e975051566 Filter by semver 2025-06-05 20:30:45 +02:00
dynco-nym 8207a9c38a Return percent string instead of u8 2025-06-05 20:30:45 +02:00
dynco-nym bbdf0f158a Filter gw by country 2025-06-05 20:30:45 +02:00
dynco-nym b276a8bc61 Revert before merge: pin deps to cheddar release 2025-06-05 20:30:43 +02:00
dynco-nym f31e6f2470 Rest of the data for /gateways 2025-06-05 20:29:56 +02:00
dynco-nym ea08345025 /gateways works
- SkimmedNode data still missing
- need to move probe models to monorepo
2025-06-05 20:29:56 +02:00
dynco-nym 3e5537b753 Endpoint & cache 2025-06-05 20:29:56 +02:00
Mark Sinclair 23f86b6c1e wip - dvpn directory cache 2025-06-05 20:29:56 +02:00
Simon Wicky 8ba58ba11e [Feature] Noise XKpsk3 integration (2025 version) (#5692)
* grand Noise squaheroo

* fix merge conflicts and adapt code for the key rotation changes (#5824)

* remove file that should have been ignored
2025-06-05 11:34:55 +02:00
Simon Wicky be16fddc75 [Stats API] Infallible network view (#5825)
* infallible network view and cheddar model for current compatibility

* bump patch version

* typo
2025-06-04 17:08:44 +02:00
dependabot[bot] a7d6cba11d build(deps-dev): bump http-proxy-middleware (#5810)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.4 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.4...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 13:42:41 +02:00
dependabot[bot] a67ff33054 build(deps): bump tokio from 1.44.2 to 1.45.1 (#5798)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.44.2 to 1.45.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.44.2...tokio-1.45.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 10:21:00 +02:00
dependabot[bot] 61badfdcfe build(deps): bump undici in /.github/actions/nym-hash-releases/src (#5771)
Bumps [undici](https://github.com/nodejs/undici) from 5.28.5 to 5.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.28.5...v5.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 5.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:40:05 +02:00
dependabot[bot] 19dfbeb2b4 build(deps): bump cargo_metadata from 0.18.1 to 0.19.2 (#5765)
Bumps [cargo_metadata](https://github.com/oli-obk/cargo_metadata) from 0.18.1 to 0.19.2.
- [Release notes](https://github.com/oli-obk/cargo_metadata/releases)
- [Changelog](https://github.com/oli-obk/cargo_metadata/blob/main/CHANGELOG.md)
- [Commits](https://github.com/oli-obk/cargo_metadata/compare/0.18.1...0.19.2)

---
updated-dependencies:
- dependency-name: cargo_metadata
  dependency-version: 0.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:37:54 +02:00
dependabot[bot] 9f13616c24 build(deps): bump tempfile from 3.19.1 to 3.20.0 (#5764)
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.19.1 to 3.20.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.19.1...v3.20.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:37:39 +02:00
63 changed files with 3848 additions and 677 deletions
+3 -3
View File
@@ -415,9 +415,9 @@
}
},
"node_modules/undici": {
"version": "5.28.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz",
"integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==",
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
Generated
+1270 -506
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -66,6 +66,8 @@ members = [
"common/nym-id",
"common/nym-metrics",
"common/nym_offline_compact_ecash",
"common/nymnoise",
"common/nymnoise/keys",
"common/nymsphinx",
"common/nymsphinx/acknowledgements",
"common/nymsphinx/addressing",
@@ -203,7 +205,7 @@ bloomfilter = "3.0.1"
bs58 = "0.5.1"
bytecodec = "0.4.15"
bytes = "1.10.1"
cargo_metadata = "0.18.1"
cargo_metadata = "0.19.2"
celes = "2.6.0"
cfg-if = "1.0.0"
chacha20 = "0.9.0"
@@ -310,6 +312,7 @@ serde_with = "3.9.0"
serde_yaml = "0.9.25"
sha2 = "0.10.9"
si-scale = "0.2.3"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
sqlx = "0.8.6"
strum = "0.26"
@@ -319,10 +322,10 @@ syn = "1"
sysinfo = "0.33.0"
tap = "1.0.1"
tar = "0.4.44"
tempfile = "3.19"
tempfile = "3.20"
thiserror = "2.0"
time = "0.3.41"
tokio = "1.44"
tokio = "1.45"
tokio-postgres = "0.7"
tokio-stream = "0.1.17"
tokio-test = "0.4.4"
@@ -2048,10 +2048,11 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz",
"integrity": "sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
@@ -6095,9 +6096,9 @@
}
},
"http-proxy-middleware": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz",
"integrity": "sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"requires": {
"@types/http-proxy": "^1.17.8",
@@ -108,7 +108,7 @@ impl GatewayClient {
#[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.as_ref()).diffie_hellman(&self.pub_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)]
@@ -117,7 +117,7 @@ impl GatewayClient {
#[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.as_ref()).diffie_hellman(&self.pub_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)]
@@ -117,7 +117,7 @@ impl GatewayClient {
#[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.as_ref()).diffie_hellman(&self.pub_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)]
@@ -169,7 +169,7 @@ impl GatewayClient {
#[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.as_ref()).diffie_hellman(&self.pub_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)]
@@ -169,7 +169,7 @@ impl GatewayClient {
#[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.as_ref()).diffie_hellman(&self.pub_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)]
+3 -1
View File
@@ -44,7 +44,6 @@ nym-sphinx = { path = "../nymsphinx" }
nym-statistics-common = { path = "../statistics" }
nym-pemstore = { path = "../pemstore" }
nym-topology = { path = "../topology", features = ["persistence"] }
nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false }
nym-validator-client = { path = "../client-libs/validator-client", default-features = false }
nym-task = { path = "../task" }
nym-credentials-interface = { path = "../credentials-interface" }
@@ -57,6 +56,9 @@ nym-client-core-surb-storage = { path = "./surb-storage" }
nym-client-core-gateways-storage = { path = "./gateways-storage" }
nym-ecash-time = { path = "../ecash-time" }
[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false }
### For serving prometheus metrics
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.hyper]
workspace = true
+6 -1
View File
@@ -16,9 +16,14 @@ tokio-util = { workspace = true, features = ["codec"], optional = true }
tokio-stream = { workspace = true }
# internal
nym-noise = { path = "../../nymnoise" }
nym-sphinx = { path = "../../nymsphinx" }
nym-task = { path = "../../task", optional = true }
[features]
default = ["client"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
[dev-dependencies]
nym-crypto = { path = "../../crypto" }
rand = { workspace = true }
+37 -3
View File
@@ -3,6 +3,8 @@
use dashmap::DashMap;
use futures::StreamExt;
use nym_noise::config::NoiseConfig;
use nym_noise::upgrade_noise_initiator;
use nym_sphinx::forwarding::packet::MixPacket;
use nym_sphinx::framing::codec::NymCodec;
use nym_sphinx::framing::packet::FramedNymPacket;
@@ -52,6 +54,7 @@ pub trait SendWithoutResponse {
pub struct Client {
active_connections: ActiveConnections,
noise_config: NoiseConfig,
connections_count: Arc<AtomicUsize>,
config: Config,
}
@@ -97,6 +100,7 @@ impl ConnectionSender {
struct ManagedConnection {
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: ReceiverStream<FramedNymPacket>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
@@ -105,12 +109,14 @@ struct ManagedConnection {
impl ManagedConnection {
fn new(
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: mpsc::Receiver<FramedNymPacket>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
) -> Self {
ManagedConnection {
address,
noise_config,
message_receiver: ReceiverStream::new(message_receiver),
connection_timeout,
current_reconnection,
@@ -125,9 +131,21 @@ impl ManagedConnection {
Ok(stream_res) => match stream_res {
Ok(stream) => {
debug!("Managed to establish connection to {}", self.address);
// if we managed to connect, reset the reconnection count (whatever it might have been)
let noise_stream =
match upgrade_noise_initiator(stream, &self.noise_config).await {
Ok(noise_stream) => noise_stream,
Err(err) => {
error!("Failed to perform Noise handshake with {address} - {err}");
// we failed to finish the noise handshake - increase reconnection attempt
self.current_reconnection.fetch_add(1, Ordering::SeqCst);
return;
}
};
// if we managed to connect AND do the noise handshake, reset the reconnection count (whatever it might have been)
self.current_reconnection.store(0, Ordering::Release);
Framed::new(stream, NymCodec)
debug!("Noise initiator handshake completed for {:?}", address);
Framed::new(noise_stream, NymCodec)
}
Err(err) => {
debug!("failed to establish connection to {address} (err: {err})",);
@@ -160,9 +178,14 @@ impl ManagedConnection {
}
impl Client {
pub fn new(config: Config, connections_count: Arc<AtomicUsize>) -> Client {
pub fn new(
config: Config,
noise_config: NoiseConfig,
connections_count: Arc<AtomicUsize>,
) -> Client {
Client {
active_connections: Default::default(),
noise_config,
connections_count,
config,
}
@@ -217,6 +240,7 @@ impl Client {
let initial_connection_timeout = self.config.initial_connection_timeout;
let connections_count = self.connections_count.clone();
let noise_config = self.noise_config.clone();
tokio::spawn(async move {
// before executing the manager, wait for what was specified, if anything
if let Some(backoff) = backoff {
@@ -227,6 +251,7 @@ impl Client {
connections_count.fetch_add(1, Ordering::SeqCst);
ManagedConnection::new(
address,
noise_config,
receiver,
initial_connection_timeout,
current_reconnection_attempt,
@@ -291,8 +316,12 @@ impl SendWithoutResponse for Client {
#[cfg(test)]
mod tests {
use super::*;
use nym_crypto::asymmetric::x25519;
use nym_noise::config::NoiseNetworkView;
use rand::rngs::OsRng;
fn dummy_client() -> Client {
let mut rng = OsRng; //for test only, so we don't care if rng source isn't crypto grade
Client::new(
Config {
initial_reconnection_backoff: Duration::from_millis(10_000),
@@ -300,6 +329,11 @@ mod tests {
initial_connection_timeout: Duration::from_millis(1_500),
maximum_connection_buffer_size: 128,
},
NoiseConfig::new(
Arc::new(x25519::KeyPair::new(&mut rng)),
NoiseNetworkView::new_empty(),
Duration::from_millis(1_500),
),
Default::default(),
)
}
@@ -26,7 +26,7 @@ use nym_api_requests::models::{
};
use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated};
use nym_api_requests::nym_nodes::{
NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata,
NodesByAddressesResponse, SemiSkimmedNodesWithMetadata, SkimmedNode, SkimmedNodesWithMetadata,
};
use nym_coconut_dkg_common::types::EpochId;
use nym_http_api_client::UserAgent;
@@ -530,6 +530,43 @@ impl NymApiClient {
collect_paged_skimmed_v2!(self, get_basic_nodes_v2)
}
/// retrieve expanded information for all bonded nodes on the network
pub async fn get_all_expanded_nodes(
&self,
) -> Result<SemiSkimmedNodesWithMetadata, ValidatorClientError> {
// Unroll the first iteration to get the metadata
let mut page = 0;
let res = self
.nym_api
.get_expanded_nodes(false, Some(page), None)
.await?;
let mut nodes = res.nodes.data;
let metadata = res.metadata;
if res.nodes.pagination.total == nodes.len() {
return Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata));
}
page += 1;
loop {
let mut res = self
.nym_api
.get_expanded_nodes(false, Some(page), None)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata))
}
pub async fn health(&self) -> Result<ApiHealthResponse, ValidatorClientError> {
Ok(self.nym_api.health().await?)
}
@@ -35,7 +35,7 @@ pub use nym_api_requests::{
MixnodeStatusResponse, MixnodeUptimeHistoryResponse, RewardEstimationResponse,
StakeSaturationResponse, UptimeResponse,
},
nym_nodes::{CachedNodesResponse, SkimmedNode},
nym_nodes::{CachedNodesResponse, SemiSkimmedNode, SkimmedNode},
NymNetworkDetailsResponse,
};
use nym_contracts_common::IdentityKey;
@@ -643,6 +643,39 @@ pub trait NymApiClientExt: ApiClient {
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_expanded_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
) -> Result<PaginatedCachedNodesResponseV2<SemiSkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
self.get_json(
&[
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"semi-skimmed",
],
&params,
)
.await
}
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_active_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
@@ -218,6 +218,12 @@ impl From<PublicKey> for x25519_dalek::PublicKey {
}
}
impl AsRef<[u8]> for PublicKey {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct PrivateKey(x25519_dalek::StaticSecret);
@@ -248,6 +254,10 @@ impl PrivateKey {
PrivateKey(x25519_secret)
}
pub fn inner(&self) -> &x25519_dalek::StaticSecret {
&self.0
}
pub fn public_key(&self) -> PublicKey {
self.into()
}
@@ -256,6 +266,10 @@ impl PrivateKey {
self.0.to_bytes()
}
pub fn as_bytes(&self) -> &[u8; PRIVATE_KEY_SIZE] {
self.0.as_bytes()
}
pub fn from_bytes(b: &[u8]) -> Result<Self, KeyRecoveryError> {
if b.len() != PRIVATE_KEY_SIZE {
return Err(KeyRecoveryError::InvalidSizePrivateKey {
@@ -335,6 +349,12 @@ impl AsRef<x25519_dalek::StaticSecret> for PrivateKey {
}
}
impl AsRef<[u8]> for PrivateKey {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "nym-noise"
version = "0.1.0"
authors = ["Simon Wicky <simon@nymtech.net>"]
edition = "2021"
license.workspace = true
[dependencies]
arc-swap = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
pin-project = { workspace = true }
sha2 = { workspace = true }
snow = { workspace = true }
strum = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util", "time"] }
tokio-util = { workspace = true, features = ["codec"] }
# internal
nym-crypto = { path = "../crypto" }
nym-noise-keys = { path = "keys" }
[dev-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["full"] }
rand_chacha = { workspace = true }
nym-crypto = { path = "../crypto", features = ["rand"] }
[lints]
workspace = true
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "nym-noise-keys"
version = "0.1.0"
authors = ["Simon Wicky <simon@nymtech.net>"]
edition = "2021"
license.workspace = true
[dependencies]
schemars = { workspace = true, features = ["preserve_order"] }
serde = { workspace = true, features = ["derive"] }
utoipa = { workspace = true }
# internal
nym-crypto = { path = "../../crypto", features = ["asymmetric", "serde"] }
[lints]
workspace = true
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_crypto::asymmetric::x25519;
use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey;
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(from = "u8", into = "u8")]
pub enum NoiseVersion {
V1,
Unknown(u8), //Implies a newer version we don't know
}
impl From<u8> for NoiseVersion {
fn from(value: u8) -> Self {
match value {
1 => NoiseVersion::V1,
other => NoiseVersion::Unknown(other),
}
}
}
impl From<NoiseVersion> for u8 {
fn from(version: NoiseVersion) -> Self {
match version {
NoiseVersion::V1 => 1,
NoiseVersion::Unknown(other) => other,
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
pub struct VersionedNoiseKey {
#[schemars(with = "u8")]
#[schema(value_type = u8)]
pub supported_version: NoiseVersion,
#[schemars(with = "String")]
#[serde(with = "bs58_x25519_pubkey")]
#[schema(value_type = String)]
pub x25519_pubkey: x25519::PublicKey,
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
sync::Arc,
time::Duration,
};
use arc_swap::ArcSwap;
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::{NoiseVersion, VersionedNoiseKey};
use snow::params::NoiseParams;
use strum::{EnumIter, FromRepr};
#[derive(Default, Debug, Clone, Copy, EnumIter, FromRepr, Eq, PartialEq)]
#[repr(u8)]
#[non_exhaustive]
pub enum NoisePattern {
#[default]
XKpsk3 = 1,
IKpsk2 = 2,
}
impl NoisePattern {
pub(crate) const fn as_str(&self) -> &'static str {
match self {
Self::XKpsk3 => "Noise_XKpsk3_25519_AESGCM_SHA256",
Self::IKpsk2 => "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s", //Wireguard handshake (not exactly though)
}
}
// SAFETY: we have tests to ensure that hardcoded pattern are correct
#[allow(clippy::unwrap_used)]
pub(crate) fn psk_position(&self) -> u8 {
//automatic parsing, works for correct pattern, more convenient
match self.as_str().find("psk") {
Some(n) => {
let psk_index = n + 3;
let psk_char = self.as_str().chars().nth(psk_index).unwrap();
psk_char.to_string().parse().unwrap()
}
None => 0,
}
}
// SAFETY : we have tests to ensure that hardcoded pattern are correct
#[allow(clippy::unwrap_used)]
pub(crate) fn as_noise_params(&self) -> NoiseParams {
self.as_str().parse().unwrap()
}
}
#[derive(Debug, Default)]
struct SocketAddrToKey {
inner: ArcSwap<HashMap<SocketAddr, VersionedNoiseKey>>,
}
// SW NOTE : Only for phased upgrade. To remove once we decide all nodes have to support Noise
#[derive(Debug, Default)]
struct IpAddrToVersion {
inner: ArcSwap<HashMap<IpAddr, NoiseVersion>>,
}
#[derive(Debug, Clone, Default)]
pub struct NoiseNetworkView {
keys: Arc<SocketAddrToKey>,
support: Arc<IpAddrToVersion>,
}
impl NoiseNetworkView {
pub fn new_empty() -> Self {
NoiseNetworkView {
keys: Default::default(),
support: Default::default(),
}
}
pub fn swap_view(&self, new: HashMap<SocketAddr, VersionedNoiseKey>) {
let noise_support = new
.iter()
.map(|(s_addr, key)| (s_addr.ip(), key.supported_version))
.collect::<HashMap<_, _>>();
self.keys.inner.store(Arc::new(new));
self.support.inner.store(Arc::new(noise_support));
}
}
#[derive(Clone)]
pub struct NoiseConfig {
network: NoiseNetworkView,
pub(crate) local_key: Arc<x25519::KeyPair>,
pub(crate) pattern: NoisePattern,
pub(crate) timeout: Duration,
pub(crate) unsafe_disabled: bool, // allows for nodes to not attempt to do a noise handshake, VERY UNSAFE, FOR DEBUG PURPOSE ONLY
}
impl NoiseConfig {
pub fn new(
noise_key: Arc<x25519::KeyPair>,
network: NoiseNetworkView,
timeout: Duration,
) -> Self {
NoiseConfig {
network,
local_key: noise_key,
pattern: Default::default(),
timeout,
unsafe_disabled: false,
}
}
#[must_use]
pub fn with_noise_pattern(mut self, pattern: NoisePattern) -> Self {
self.pattern = pattern;
self
}
#[must_use]
pub fn with_unsafe_disabled(mut self, disabled: bool) -> Self {
self.unsafe_disabled = disabled;
self
}
pub(crate) fn get_noise_key(&self, s_address: &SocketAddr) -> Option<VersionedNoiseKey> {
self.network.keys.inner.load().get(s_address).copied()
}
// Only for phased update
//SW This can lead to some troubles if two nodes shares the same IP and one support Noise but not the other. This in only for the progressive update though and there is no workaround
pub(crate) fn get_noise_support(&self, ip_addr: IpAddr) -> Option<NoiseVersion> {
let plain_ip_support = self.network.support.inner.load().get(&ip_addr).copied();
// SW default bind address being [::]:1789, it can happen that a responder sees the ipv6-mapped address of the initiator, this check for that
let canonical_ip = &ip_addr.to_canonical();
let canonical_ip_support = self.network.support.inner.load().get(canonical_ip).copied();
plain_ip_support.or(canonical_ip_support)
}
}
#[cfg(test)]
mod tests {
use snow::params::NoiseParams;
use super::NoisePattern;
use std::str::FromStr;
use strum::IntoEnumIterator;
// The goal of these is to make sure every NoisePatterns are correct and unwrap can be used on them
#[test]
fn noise_patterns_are_valid() {
for pattern in NoisePattern::iter() {
assert!(NoiseParams::from_str(pattern.as_str()).is_ok())
}
}
#[test]
fn noise_patterns_psk_position_is_valid() {
for pattern in NoisePattern::iter() {
match pattern {
NoisePattern::XKpsk3 => assert_eq!(pattern.psk_position(), 3),
NoisePattern::IKpsk2 => assert_eq!(pattern.psk_position(), 2),
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::io;
use pin_project::pin_project;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::stream::NoiseStream;
//SW once plain TCP support is dropped, this whole enum can be dropped, and we can only propagate NoiseStream
#[pin_project(project = ConnectionProj)]
pub enum Connection<C> {
Raw(#[pin] C),
Noise(#[pin] Box<NoiseStream<C>>),
}
impl<C> AsyncRead for Connection<C>
where
C: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_read(cx, buf),
ConnectionProj::Raw(stream) => stream.poll_read(cx, buf),
}
}
}
impl<C> AsyncWrite for Connection<C>
where
C: AsyncWrite + AsyncRead + Unpin,
{
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<Result<usize, io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_write(cx, buf),
ConnectionProj::Raw(stream) => stream.poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_flush(cx),
ConnectionProj::Raw(stream) => stream.poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_shutdown(cx),
ConnectionProj::Raw(stream) => stream.poll_shutdown(cx),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_noise_keys::NoiseVersion;
use snow::Error;
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NoiseError {
#[error("encountered a Noise decryption error")]
DecryptionError,
#[error("encountered a Noise Protocol error: {0}")]
ProtocolError(Error),
#[error("encountered an IO error: {0}")]
IoError(#[from] io::Error),
#[error("Incorrect state")]
IncorrectStateError,
#[error("Handshake did not complete")]
HandshakeError,
#[error("unknown noise version (encoded value: {encoded})")]
UnknownVersion { encoded: u8 },
#[error("unknown noise pattern (encoded value: {encoded})")]
UnknownPattern { encoded: u8 },
#[error("unknown noise message type (encoded value: {encoded})")]
UnknownMessageType { encoded: u8 },
#[error("failed to generate psk for requested version {noise_version}")]
PskGenerationFailure { noise_version: u8 },
#[error("noise initiator attempted to use version v{noise_version} of the protocol - we don't know how to handle it")]
UnknownVersionHandshake { noise_version: u8 },
#[error("noise initiator attempted to use an unexpected noise pattern. we're configured for {configured} while it requested {received}")]
UnexpectedNoisePattern {
configured: &'static str,
received: &'static str,
},
#[error("handshake version has unexpectedly changed. initial was {initial:?} and received {received:?}")]
UnexpectedHandshakeVersion {
initial: NoiseVersion,
received: NoiseVersion,
},
#[error("data packet version has unexpectedly changed. initial was {initial:?} and received {received:?}")]
UnexpectedDataVersion {
initial: NoiseVersion,
received: NoiseVersion,
},
#[error("received a non-handshake message during noise handshake")]
NonHandshakeMessageReceived,
#[error("received a non-data message post noise handshake")]
NonDataMessageReceived,
#[error("handshake message exceeded maximum size (got {size} bytes)")]
HandshakeTooBig { size: usize },
#[error("noise message exceeded maximum size (got {size} bytes)")]
DataTooBig { size: usize },
#[error("Handshake timeout")]
HandshakeTimeout(#[from] tokio::time::error::Elapsed),
}
impl NoiseError {
pub(crate) fn naive_to_io_error(self) -> std::io::Error {
match self {
NoiseError::IoError(err) => err,
other => std::io::Error::other(other),
}
}
}
impl From<Error> for NoiseError {
fn from(err: Error) -> Self {
match err {
Error::Decrypt => NoiseError::DecryptionError,
err => NoiseError::ProtocolError(err),
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_noise_keys::NoiseVersion;
use snow::error::Prerequisite;
use snow::Error;
use tokio::net::TcpStream;
use tracing::{error, warn};
pub mod config;
pub mod connection;
pub mod error;
pub mod stream;
use crate::config::NoiseConfig;
use crate::connection::Connection;
use crate::error::NoiseError;
use crate::stream::NoiseStreamBuilder;
const NOISE_PSK_PREFIX: &[u8] = b"NYMTECH_NOISE_dQw4w9WgXcQ";
pub const LATEST_NOISE_VERSION: NoiseVersion = NoiseVersion::V1;
// TODO: this should be behind some trait because presumably, depending on the version,
// other arguments would be needed
mod psk_gen {
use crate::error::NoiseError;
use crate::stream::Psk;
use crate::NOISE_PSK_PREFIX;
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::NoiseVersion;
use sha2::{Digest, Sha256};
pub(crate) fn generate_psk(
responder_pub_key: x25519::PublicKey,
version: NoiseVersion,
) -> Result<Psk, NoiseError> {
match version {
NoiseVersion::V1 => Ok(generate_psk_v1(responder_pub_key)),
NoiseVersion::Unknown(noise_version) => {
Err(NoiseError::PskGenerationFailure { noise_version })
}
}
}
fn generate_psk_v1(responder_pub_key: x25519::PublicKey) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(NOISE_PSK_PREFIX);
hasher.update(responder_pub_key.to_bytes());
hasher.finalize().into()
}
}
pub async fn upgrade_noise_initiator(
conn: TcpStream,
config: &NoiseConfig,
) -> Result<Connection<TcpStream>, NoiseError> {
if config.unsafe_disabled {
warn!("Noise is disabled in the config. Not attempting any handshake");
return Ok(Connection::Raw(conn));
}
//Get init material
let responder_addr = conn.peer_addr().map_err(|err| {
error!("Unable to extract peer address from connection - {err}");
Error::Prereq(Prerequisite::RemotePublicKey)
})?;
let Some(key) = config.get_noise_key(&responder_addr) else {
warn!("{responder_addr} can't speak Noise yet, falling back to TCP");
return Ok(Connection::Raw(conn));
};
let handshake_version = match key.supported_version {
NoiseVersion::V1 => NoiseVersion::V1,
// We're talking to a more recent node, but we can't adapt. Let's try to do our best and if it fails, it fails.
// If that node sees we're older, it will try to adapt too.
NoiseVersion::Unknown(version) => {
warn!("{responder_addr} is announcing an v{version} version of Noise that we don't know how to parse, we will attempt to downgrade to our current highest supported version");
LATEST_NOISE_VERSION
}
};
NoiseStreamBuilder::new(conn)
.perform_initiator_handshake(config, handshake_version, key.x25519_pubkey)
.await
.map(|stream| Connection::Noise(Box::new(stream)))
}
pub async fn upgrade_noise_responder(
conn: TcpStream,
config: &NoiseConfig,
) -> Result<Connection<TcpStream>, NoiseError> {
if config.unsafe_disabled {
warn!("Noise is disabled in the config. Not attempting any handshake");
return Ok(Connection::Raw(conn));
}
//Get init material
let initiator_addr = match conn.peer_addr() {
Ok(addr) => addr,
Err(err) => {
error!("Unable to extract peer address from connection - {err}");
return Err(Error::Prereq(Prerequisite::RemotePublicKey).into());
}
};
// if responder doesn't announce noise support, we fallback to tcp
if config.get_noise_support(initiator_addr.ip()).is_none() {
warn!("{initiator_addr} can't speak Noise yet, falling back to TCP",);
return Ok(Connection::Raw(conn));
};
NoiseStreamBuilder::new(conn)
.perform_responder_handshake(config)
.await
.map(|stream| Connection::Noise(Box::new(stream)))
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::NoiseError;
use crate::stream::framing::{NymNoiseFrame, NymNoiseHeader};
use bytes::{BufMut, BytesMut};
use tokio_util::codec::{Decoder, Encoder};
#[derive(Debug, Clone, Copy)]
enum DecodeState {
Header,
Payload(NymNoiseHeader),
}
pub struct NymNoiseCodec {
state: DecodeState,
}
impl NymNoiseCodec {
pub fn new() -> Self {
NymNoiseCodec {
state: DecodeState::Header,
}
}
fn decode_header(&self, src: &mut BytesMut) -> Result<Option<NymNoiseHeader>, NoiseError> {
if src.len() < NymNoiseHeader::SIZE {
// Not enough data
return Ok(None);
}
// note: successful call to 'decode' advances the buffer by NymNoiseHeader::SIZE
let Some(header) = NymNoiseHeader::decode(src)? else {
return Ok(None);
};
Ok(Some(header))
}
fn decode_data(&self, n: usize, src: &mut BytesMut) -> Option<BytesMut> {
// At this point, the buffer has already had the required capacity
// reserved. All there is to do is read.
if src.len() < n {
return None;
}
Some(src.split_to(n))
}
}
impl Decoder for NymNoiseCodec {
type Item = NymNoiseFrame;
type Error = NoiseError;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
let header = match self.state {
DecodeState::Header => match self.decode_header(src)? {
None => return Ok(None),
Some(header) => {
self.state = DecodeState::Payload(header);
header
}
},
DecodeState::Payload(header) => header,
};
let Some(data) = self.decode_data(header.data_len as usize, src) else {
return Ok(None);
};
// Update the decode state
self.state = DecodeState::Header;
// make sure the buffer has enough space to read the next header
src.reserve(NymNoiseHeader::SIZE);
Ok(Some(NymNoiseFrame {
header,
data: data.freeze(),
}))
}
}
impl Encoder<NymNoiseFrame> for NymNoiseCodec {
type Error = NoiseError;
fn encode(&mut self, frame: NymNoiseFrame, dst: &mut BytesMut) -> Result<(), Self::Error> {
frame.header.encode(dst);
dst.put_slice(frame.data.as_ref());
Ok(())
}
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::config::NoisePattern;
use crate::error::NoiseError;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use nym_noise_keys::NoiseVersion;
use strum::FromRepr;
#[derive(Debug)]
pub struct NymNoiseFrame {
pub header: NymNoiseHeader,
pub data: Bytes,
}
impl NymNoiseFrame {
pub fn new_handshake_frame(
data: Bytes,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<Self, NoiseError> {
if data.len() > u16::MAX as usize {
return Err(NoiseError::HandshakeTooBig { size: data.len() });
}
Ok(NymNoiseFrame {
header: NymNoiseHeader {
version,
noise_pattern: pattern,
message_type: NymNoiseMessageType::Handshake,
data_len: data.len() as u16,
},
data,
})
}
pub fn new_data_frame(
data: Bytes,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<Self, NoiseError> {
if data.len() > u16::MAX as usize {
return Err(NoiseError::HandshakeTooBig { size: data.len() });
}
Ok(NymNoiseFrame {
header: NymNoiseHeader {
version,
noise_pattern: pattern,
message_type: NymNoiseMessageType::Data,
data_len: data.len() as u16,
},
data,
})
}
pub fn version(&self) -> NoiseVersion {
self.header.version
}
pub fn is_handshake_message(&self) -> bool {
self.header.is_handshake_message()
}
pub fn is_data_message(&self) -> bool {
self.header.is_data_message()
}
pub fn noise_pattern(&self) -> NoisePattern {
self.header.noise_pattern
}
}
#[derive(Debug, Copy, Clone, FromRepr)]
#[repr(u8)]
#[non_exhaustive]
pub enum NymNoiseMessageType {
Handshake = 0,
Data = 1,
}
#[derive(Debug, Clone, Copy)]
pub struct NymNoiseHeader {
pub version: NoiseVersion,
pub noise_pattern: NoisePattern,
pub message_type: NymNoiseMessageType,
pub data_len: u16,
}
impl NymNoiseHeader {
pub(crate) const SIZE: usize = 8;
pub fn is_handshake_message(&self) -> bool {
matches!(self.message_type, NymNoiseMessageType::Handshake)
}
pub fn is_data_message(&self) -> bool {
matches!(self.message_type, NymNoiseMessageType::Data)
}
// 0 1 2 3 4 5 6 7 8
// +-+-+-+-+-+-+-+-+
// |V|P|T|Len| Res.|
// +-+-+-+-+-+-+-+-+
pub(crate) fn encode(&self, dst: &mut BytesMut) {
dst.reserve(Self::SIZE);
// byte 0
dst.put_u8(self.version.into());
// byte 1
dst.put_u8(self.noise_pattern as u8);
// byte 2
dst.put_u8(self.message_type as u8);
// byte 3-4
dst.put_u16(self.data_len);
// byte 5-7 (RESERVED):
dst.extend_from_slice(&[0u8; 3])
}
pub(crate) fn decode(src: &mut BytesMut) -> Result<Option<Self>, NoiseError> {
if src.len() < Self::SIZE {
// can't do anything if we don't have enough bytes - but reserve enough for the next call
src.reserve(Self::SIZE);
return Ok(None);
}
let version = src.get_u8();
let pattern = src.get_u8();
let message_type = src.get_u8();
let data_len = src.get_u16();
// reserved
src.advance(3);
let version = NoiseVersion::from(version);
// here, based on versions, we could do vary the further parsing
// match version {
// NoiseVersion::V1 => {}
// NoiseVersion::Unknown(_) => {}
// }
let noise_pattern = NoisePattern::from_repr(pattern)
.ok_or(NoiseError::UnknownPattern { encoded: pattern })?;
let message_type =
NymNoiseMessageType::from_repr(message_type).ok_or(NoiseError::UnknownMessageType {
encoded: message_type,
})?;
Ok(Some(NymNoiseHeader {
version,
noise_pattern,
message_type,
data_len,
}))
}
}
+583
View File
@@ -0,0 +1,583 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::config::{NoiseConfig, NoisePattern};
use crate::error::NoiseError;
use crate::psk_gen::generate_psk;
use crate::stream::codec::NymNoiseCodec;
use crate::stream::framing::NymNoiseFrame;
use bytes::{Bytes, BytesMut};
use futures::{Sink, SinkExt, Stream, StreamExt};
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::NoiseVersion;
use snow::{Builder, HandshakeState, TransportState};
use std::io;
use std::pin::Pin;
use std::task::Poll;
use std::{cmp::min, task::ready};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_util::codec::Framed;
mod codec;
mod framing;
const TAGLEN: usize = 16;
const HANDSHAKE_MAX_LEN: usize = 1024; // using this constant to limit the handshake's buffer size
pub(crate) type Psk = [u8; 32];
pub(crate) struct NoiseStreamBuilder<C> {
inner_stream: Framed<C, NymNoiseCodec>,
}
impl<C> NoiseStreamBuilder<C> {
pub(crate) fn new(inner_stream: C) -> Self
where
C: AsyncRead + AsyncWrite,
{
NoiseStreamBuilder {
inner_stream: Framed::new(inner_stream, NymNoiseCodec::new()),
}
}
async fn perform_initiator_handshake_inner(
self,
pattern: NoisePattern,
local_private_key: impl AsRef<[u8]>,
remote_pub_key: impl AsRef<[u8]>,
psk: Psk,
version: NoiseVersion,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let handshake = Builder::new(pattern.as_noise_params())
.local_private_key(local_private_key.as_ref())
.remote_public_key(remote_pub_key.as_ref())
.psk(pattern.psk_position(), &psk)
.build_initiator()?;
self.perform_handshake(handshake, version, pattern).await
}
pub(crate) async fn perform_initiator_handshake(
self,
config: &NoiseConfig,
version: NoiseVersion,
remote_pub_key: x25519::PublicKey,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let psk = generate_psk(remote_pub_key, version)?;
let timeout = config.timeout;
tokio::time::timeout(
timeout,
self.perform_initiator_handshake_inner(
config.pattern,
config.local_key.private_key(),
remote_pub_key,
psk,
version,
),
)
.await?
}
async fn perform_responder_handshake_inner(
mut self,
noise_pattern: NoisePattern,
local_private_key: impl AsRef<[u8]>,
local_pub_key: x25519::PublicKey,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
// 1. we read the first message from the initiator to establish noise version and pattern
// and determine if we can continue with the handshake
let initial_frame = self
.inner_stream
.next()
.await
.ok_or(NoiseError::IoError(io::ErrorKind::BrokenPipe.into()))??;
if !initial_frame.is_handshake_message() {
return Err(NoiseError::NonHandshakeMessageReceived);
}
let pattern = initial_frame.noise_pattern();
// I can imagine we should be able to handle multiple patterns here, but I guess there's a reason a value is set in the config
// but refactoring this shouldn't be too difficult
if pattern != noise_pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: noise_pattern.as_str(),
received: pattern.as_str(),
});
}
// 2. generate psk and handshake state
let psk = generate_psk(local_pub_key, initial_frame.header.version)?;
let mut handshake = Builder::new(pattern.as_noise_params())
.local_private_key(local_private_key.as_ref())
.psk(pattern.psk_position(), &psk)
.build_responder()?;
// update handshake state with initial frame
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN);
handshake.read_message(&initial_frame.data, &mut buf)?;
// 3. run handshake to completion
self.perform_handshake(handshake, initial_frame.version(), pattern)
.await
}
pub(crate) async fn perform_responder_handshake(
self,
config: &NoiseConfig,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let timeout = config.timeout;
tokio::time::timeout(
timeout,
self.perform_responder_handshake_inner(
config.pattern,
config.local_key.private_key(),
*config.local_key.public_key(),
),
)
.await?
}
async fn send_handshake_msg(
&mut self,
handshake: &mut HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<(), NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer
let len = handshake.write_message(&[], &mut buf)?;
buf.truncate(len);
let frame = NymNoiseFrame::new_handshake_frame(buf.freeze(), version, pattern)?;
self.inner_stream.send(frame).await?;
Ok(())
}
async fn recv_handshake_msg(
&mut self,
handshake: &mut HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<(), NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
match self.inner_stream.next().await {
Some(Ok(frame)) => {
// validate the frame
if !frame.is_handshake_message() {
return Err(NoiseError::NonHandshakeMessageReceived);
}
if frame.version() != version {
return Err(NoiseError::UnexpectedHandshakeVersion {
initial: version,
received: frame.version(),
});
}
if frame.noise_pattern() != pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: pattern.as_str(),
received: frame.noise_pattern().as_str(),
});
}
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer
handshake.read_message(&frame.data, &mut buf)?;
Ok(())
}
Some(Err(err)) => Err(err),
None => Err(NoiseError::HandshakeError),
}
}
async fn perform_handshake(
mut self,
mut handshake_state: HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
while !handshake_state.is_handshake_finished() {
if handshake_state.is_my_turn() {
self.send_handshake_msg(&mut handshake_state, version, pattern)
.await?;
} else {
self.recv_handshake_msg(&mut handshake_state, version, pattern)
.await?;
}
}
let transport = handshake_state.into_transport_mode()?;
Ok(NoiseStream {
inner_stream: self.inner_stream,
negotiated_pattern: pattern,
negotiated_version: version,
transport,
dec_buffer: Default::default(),
})
}
}
/// Wrapper around a TcpStream
pub struct NoiseStream<C> {
inner_stream: Framed<C, NymNoiseCodec>,
negotiated_pattern: NoisePattern,
negotiated_version: NoiseVersion,
transport: TransportState,
dec_buffer: BytesMut,
}
impl<C> NoiseStream<C> {
fn validate_data_frame(&self, frame: NymNoiseFrame) -> Result<Bytes, NoiseError> {
if !frame.is_data_message() {
return Err(NoiseError::NonDataMessageReceived);
}
// validate the frame
if !frame.is_data_message() {
return Err(NoiseError::NonDataMessageReceived);
}
if frame.version() != self.negotiated_version {
return Err(NoiseError::UnexpectedDataVersion {
initial: self.negotiated_version,
received: frame.version(),
});
}
if frame.noise_pattern() != self.negotiated_pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: self.negotiated_pattern.as_str(),
received: frame.noise_pattern().as_str(),
});
};
Ok(frame.data)
}
fn poll_data_frame(
&mut self,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<io::Result<Bytes>>>
where
C: AsyncRead + AsyncWrite + Unpin,
{
match ready!(Pin::new(&mut self.inner_stream).poll_next(cx)) {
None => Poll::Ready(None),
Some(Err(err)) => Poll::Ready(Some(Err(err.naive_to_io_error()))),
Some(Ok(frame)) => match self.validate_data_frame(frame) {
Err(err) => Poll::Ready(Some(Err(err.naive_to_io_error()))),
Ok(data) => Poll::Ready(Some(Ok(data))),
},
}
}
}
impl<C> AsyncRead for NoiseStream<C>
where
C: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let pending = match self.poll_data_frame(cx) {
Poll::Pending => {
//no new data, a return value of Poll::Pending means the waking is already scheduled
//Nothing new to decrypt, only check if we can return something from dec_storage, happens after
true
}
Poll::Ready(Some(Ok(noise_msg))) => {
// We have a new noise msg
let mut dec_msg = BytesMut::zeroed(noise_msg.len() - TAGLEN);
let len = match self.transport.read_message(&noise_msg, &mut dec_msg) {
Ok(len) => len,
Err(_) => return Poll::Ready(Err(io::ErrorKind::InvalidInput.into())),
};
self.dec_buffer.extend(&dec_msg[..len]);
false
}
Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(err)),
Poll::Ready(None) => {
//Stream is done, we might still have data in the buffer though, happens afterward
false
}
};
// Checking if there is something to return from the buffer
let read_len = min(buf.remaining(), self.dec_buffer.len());
if read_len > 0 {
buf.put_slice(&self.dec_buffer.split_to(read_len));
return Poll::Ready(Ok(()));
}
// buf.remaining == 0 or nothing in the buffer, we must return the value we had from the inner_stream
if pending {
//If we end up here, it means the previous poll_next was pending as well, hence waking is already scheduled
Poll::Pending
} else {
Poll::Ready(Ok(()))
}
}
}
impl<C> AsyncWrite for NoiseStream<C>
where
C: AsyncWrite + Unpin,
{
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, std::io::Error>> {
// returns on Poll::Pending and Poll:Ready(Err)
ready!(Pin::new(&mut self.inner_stream).poll_ready(cx))
.map_err(|err| err.naive_to_io_error())?;
// we can send at most u16::MAX bytes in a frame, but we also have to include the tag when encoding
let msg_len = min(u16::MAX as usize - TAGLEN, buf.len());
// Ready to send, encrypting message
let mut noise_buf = BytesMut::zeroed(msg_len + TAGLEN);
let Ok(len) = self
.transport
.write_message(&buf[..msg_len], &mut noise_buf)
else {
return Poll::Ready(Err(io::ErrorKind::InvalidInput.into()));
};
noise_buf.truncate(len);
let frame = NymNoiseFrame::new_data_frame(
noise_buf.freeze(),
self.negotiated_version,
self.negotiated_pattern,
)
.map_err(|err| err.naive_to_io_error())?;
// Tokio uses the same `start_send ` in their SinkWriter implementation. https://docs.rs/tokio-util/latest/src/tokio_util/io/sink_writer.rs.html#104
match Pin::new(&mut self.inner_stream).start_send(frame) {
Ok(()) => Poll::Ready(Ok(msg_len)),
Err(e) => Poll::Ready(Err(e.naive_to_io_error())),
}
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner_stream)
.poll_flush(cx)
.map_err(|err| err.naive_to_io_error())
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner_stream)
.poll_close(cx)
.map_err(|err| err.naive_to_io_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_crypto::asymmetric::x25519;
use rand_chacha::rand_core::SeedableRng;
use std::io::Error;
use std::mem;
use std::sync::Arc;
use std::task::{Context, Waker};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::join;
use tokio::sync::Mutex;
use tokio::time::timeout;
fn mock_streams() -> (MockStream, MockStream) {
let ch1 = Arc::new(Mutex::new(Default::default()));
let ch2 = Arc::new(Mutex::new(Default::default()));
(
MockStream {
inner: MockStreamInner {
tx: ch1.clone(),
rx: ch2.clone(),
},
},
MockStream {
inner: MockStreamInner { tx: ch2, rx: ch1 },
},
)
}
struct MockStream {
inner: MockStreamInner,
}
#[allow(dead_code)]
impl MockStream {
fn unchecked_tx_data(&self) -> Vec<u8> {
self.inner.tx.try_lock().unwrap().data.clone()
}
fn unchecked_rx_data(&self) -> Vec<u8> {
self.inner.rx.try_lock().unwrap().data.clone()
}
}
struct MockStreamInner {
tx: Arc<Mutex<DataWrapper>>,
rx: Arc<Mutex<DataWrapper>>,
}
#[derive(Default)]
struct DataWrapper {
data: Vec<u8>,
waker: Option<Waker>,
}
impl AsyncRead for MockStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let mut inner = self.inner.rx.try_lock().unwrap();
let data = mem::take(&mut inner.data);
if data.is_empty() {
inner.waker = Some(cx.waker().clone());
return Poll::Pending;
}
if let Some(waker) = inner.waker.take() {
waker.wake();
}
buf.put_slice(&data);
Poll::Ready(Ok(()))
}
}
impl AsyncWrite for MockStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, Error>> {
let mut inner = self.inner.tx.try_lock().unwrap();
let len = buf.len();
if !inner.data.is_empty() {
assert!(inner.waker.is_none());
inner.waker = Some(cx.waker().clone());
return Poll::Pending;
}
inner.data.extend_from_slice(buf);
if let Some(waker) = inner.waker.take() {
waker.wake();
}
Poll::Ready(Ok(len))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn noise_handshake() -> anyhow::Result<()> {
let dummy_seed = [42u8; 32];
let mut rng = rand_chacha::ChaCha20Rng::from_seed(dummy_seed);
let initiator_keys = Arc::new(x25519::KeyPair::new(&mut rng));
let responder_keys = Arc::new(x25519::KeyPair::new(&mut rng));
let (initiator_stream, responder_stream) = mock_streams();
let psk = generate_psk(*responder_keys.public_key(), NoiseVersion::V1)?;
let pattern = NoisePattern::default();
let stream_initiator = NoiseStreamBuilder::new(initiator_stream)
.perform_initiator_handshake_inner(
pattern,
initiator_keys.private_key().to_bytes(),
responder_keys.public_key().to_bytes(),
psk,
NoiseVersion::V1,
);
let stream_responder = NoiseStreamBuilder::new(responder_stream)
.perform_responder_handshake_inner(
pattern,
responder_keys.private_key().to_bytes(),
*responder_keys.public_key(),
);
let initiator_fut =
tokio::spawn(
async move { timeout(Duration::from_millis(200), stream_initiator).await },
);
let responder_fut =
tokio::spawn(
async move { timeout(Duration::from_millis(200), stream_responder).await },
);
let (initiator, responder) = join!(initiator_fut, responder_fut);
let mut initiator = initiator???;
let mut responder = responder???;
let msg = b"hello there";
// if noise was successful we should be able to write a proper message across
timeout(Duration::from_millis(200), initiator.write_all(msg)).await??;
initiator.inner_stream.flush().await?;
let inner_buf = initiator.inner_stream.get_ref().unchecked_tx_data();
let mut buf = [0u8; 11];
timeout(Duration::from_millis(200), responder.read(&mut buf)).await??;
assert_eq!(&buf[..], msg);
// the inner content is different from the actual msg since it was encrypted
assert_ne!(inner_buf, buf);
assert_ne!(inner_buf.len(), msg.len());
Ok(())
}
}
+16 -2
View File
@@ -266,6 +266,20 @@ dependencies = [
"thiserror 1.0.64",
]
[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror 2.0.12",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -1249,7 +1263,7 @@ dependencies = [
name = "nym-network-defaults"
version = "0.1.0"
dependencies = [
"cargo_metadata",
"cargo_metadata 0.19.2",
"regex",
]
@@ -2019,7 +2033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e27d6bdd219887a9eadd19e1c34f32e47fa332301184935c6d9bca26f3cca525"
dependencies = [
"anyhow",
"cargo_metadata",
"cargo_metadata 0.18.1",
"cfg-if",
"regex",
"rustc_version",
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-84610644ac15a598/out/nym-api-example.sqlite
+1
View File
@@ -38,6 +38,7 @@ nym-compact-ecash = { path = "../../common/nym_offline_compact_ecash" }
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", features = ["naive_float"] }
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] }
nym-node-requests = { path = "../../nym-node/nym-node-requests", default-features = false, features = ["openapi"] }
nym-noise-keys = { path = "../../common/nymnoise/keys"}
nym-network-defaults = { path = "../../common/network-defaults" }
nym-ticketbooks-merkle = { path = "../../common/ticketbooks-merkle" }
+46 -13
View File
@@ -8,15 +8,13 @@ use crate::helpers::PlaceholderJsonSchemaImpl;
use crate::legacy::{
LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer,
};
use crate::nym_nodes::SemiSkimmedNode;
use crate::nym_nodes::{BasicEntryInformation, NodeRole, SkimmedNode};
use crate::pagination::PaginatedResponse;
use cosmwasm_std::{Addr, Coin, Decimal, Uint128};
use nym_contracts_common::NaiveFloat;
use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey};
use nym_crypto::asymmetric::x25519::{
self,
serde_helpers::{bs58_x25519_pubkey, option_bs58_x25519_pubkey},
};
use nym_crypto::asymmetric::x25519::{self, serde_helpers::bs58_x25519_pubkey};
use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::reward_params::{Performance, RewardingParams};
use nym_mixnet_contract_common::rewarding::RewardEstimate;
@@ -28,6 +26,7 @@ use nym_node_requests::api::v1::authenticator::models::Authenticator;
use nym_node_requests::api::v1::gateway::models::Wireguard;
use nym_node_requests::api::v1::ip_packet_router::models::IpPacketRouter;
use nym_node_requests::api::v1::node::models::{AuxiliaryDetails, NodeRoles};
use nym_noise_keys::VersionedNoiseKey;
use schemars::gen::SchemaGenerator;
use schemars::schema::{InstanceType, Schema, SchemaObject};
use schemars::JsonSchema;
@@ -471,6 +470,17 @@ impl MixNodeBondAnnotated {
performance: self.node_performance.last_24h,
})
}
pub fn try_to_semi_skimmed_node(
&self,
role: NodeRole,
) -> Result<SemiSkimmedNode, MalformedNodeBond> {
let skimmed_node = self.try_to_skimmed_node(role)?;
Ok(SemiSkimmedNode {
basic: skimmed_node,
x25519_noise_versioned_key: None, // legacy node won't ever support Noise
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -536,6 +546,17 @@ impl GatewayBondAnnotated {
performance: self.node_performance.last_24h,
})
}
pub fn try_to_semi_skimmed_node(
&self,
role: NodeRole,
) -> Result<SemiSkimmedNode, MalformedNodeBond> {
let skimmed_node = self.try_to_skimmed_node(role)?;
Ok(SemiSkimmedNode {
basic: skimmed_node,
x25519_noise_versioned_key: None, // legacy node won't ever support Noise
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -878,10 +899,7 @@ pub struct HostKeys {
pub pre_announced_x25519_sphinx_key: Option<SphinxKey>,
#[serde(default)]
#[serde(with = "option_bs58_x25519_pubkey")]
#[schemars(with = "Option<String>")]
#[schema(value_type = String)]
pub x25519_noise: Option<x25519::PublicKey>,
pub x25519_versioned_noise: Option<VersionedNoiseKey>,
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
@@ -910,7 +928,7 @@ impl From<nym_node_requests::api::v1::node::models::HostKeys> for HostKeys {
x25519: value.x25519_sphinx,
current_x25519_sphinx_key: value.primary_x25519_sphinx_key.into(),
pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key.map(Into::into),
x25519_noise: value.x25519_noise,
x25519_versioned_noise: value.x25519_versioned_noise,
}
}
}
@@ -1087,6 +1105,24 @@ impl NymNodeDescription {
performance,
}
}
pub fn to_semi_skimmed_node(
&self,
current_rotation_id: u32,
role: NodeRole,
performance: Performance,
) -> SemiSkimmedNode {
let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance);
SemiSkimmedNode {
basic: skimmed_node,
x25519_noise_versioned_key: self
.description
.host_information
.keys
.x25519_versioned_noise,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
@@ -1372,10 +1408,7 @@ pub struct NetworkMonitorRunDetailsResponse {
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
pub struct NoiseDetails {
#[schemars(with = "String")]
#[serde(with = "bs58_x25519_pubkey")]
#[schema(value_type = String)]
pub x25119_pubkey: x25519::PublicKey,
pub key: VersionedNoiseKey,
pub mixnet_port: u16,
+15 -1
View File
@@ -9,6 +9,7 @@ use nym_crypto::asymmetric::{ed25519, x25519};
use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::reward_params::Performance;
use nym_mixnet_contract_common::{EpochId, Interval, NodeId};
use nym_noise_keys::VersionedNoiseKey;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
@@ -27,6 +28,18 @@ impl SkimmedNodesWithMetadata {
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
pub struct SemiSkimmedNodesWithMetadata {
pub nodes: Vec<SemiSkimmedNode>,
pub metadata: NodesResponseMetadata,
}
impl SemiSkimmedNodesWithMetadata {
pub fn new(nodes: Vec<SemiSkimmedNode>, metadata: NodesResponseMetadata) -> Self {
SemiSkimmedNodesWithMetadata { nodes, metadata }
}
}
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema, PartialEq,
)]
@@ -260,7 +273,8 @@ impl SkimmedNode {
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
pub struct SemiSkimmedNode {
pub basic: SkimmedNode,
pub x25519_noise_pubkey: String,
pub x25519_noise_versioned_key: Option<VersionedNoiseKey>,
// pub location:
}
+2 -2
View File
@@ -160,11 +160,11 @@ async fn nodes_noise(
n.description
.host_information
.keys
.x25519_noise
.x25519_versioned_noise
.map(|noise_key| (noise_key, n))
})
.map(|(noise_key, node)| NoiseDetails {
x25119_pubkey: noise_key,
key: noise_key,
mixnet_port: node.description.mix_port(),
ip_addresses: node.description.host_information.ip_address.clone(),
})
+20 -1
View File
@@ -4,7 +4,7 @@
use nym_api_requests::models::{
GatewayBondAnnotated, MalformedNodeBond, MixNodeBondAnnotated, OffsetDateTimeJsonSchemaWrapper,
};
use nym_api_requests::nym_nodes::{NodeRole, SkimmedNode};
use nym_api_requests::nym_nodes::{NodeRole, SemiSkimmedNode, SkimmedNode};
use nym_mixnet_contract_common::reward_params::Performance;
use time::OffsetDateTime;
@@ -14,6 +14,11 @@ pub(crate) trait LegacyAnnotation {
fn identity(&self) -> &str;
fn try_to_skimmed_node(&self, role: NodeRole) -> Result<SkimmedNode, MalformedNodeBond>;
fn try_to_semi_skimmed_node(
&self,
role: NodeRole,
) -> Result<SemiSkimmedNode, MalformedNodeBond>;
}
impl LegacyAnnotation for MixNodeBondAnnotated {
@@ -28,6 +33,13 @@ impl LegacyAnnotation for MixNodeBondAnnotated {
fn try_to_skimmed_node(&self, role: NodeRole) -> Result<SkimmedNode, MalformedNodeBond> {
self.try_to_skimmed_node(role)
}
fn try_to_semi_skimmed_node(
&self,
role: NodeRole,
) -> Result<SemiSkimmedNode, MalformedNodeBond> {
self.try_to_semi_skimmed_node(role)
}
}
impl LegacyAnnotation for GatewayBondAnnotated {
@@ -42,6 +54,13 @@ impl LegacyAnnotation for GatewayBondAnnotated {
fn try_to_skimmed_node(&self, role: NodeRole) -> Result<SkimmedNode, MalformedNodeBond> {
self.try_to_skimmed_node(role)
}
fn try_to_semi_skimmed_node(
&self,
role: NodeRole,
) -> Result<SemiSkimmedNode, MalformedNodeBond> {
self.try_to_semi_skimmed_node(role)
}
}
pub(crate) fn refreshed_at(
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::support::http::state::AppState;
use crate::unstable_routes::v2::nym_nodes::semi_skimmed::nodes_expanded;
use crate::unstable_routes::v2::nym_nodes::skimmed::{
entry_gateways_basic_all, exit_gateways_basic_all, mixnodes_basic_active, mixnodes_basic_all,
nodes_basic_all,
@@ -11,6 +12,7 @@ use axum::Router;
use tower_http::compression::CompressionLayer;
pub(crate) mod helpers;
pub(crate) mod semi_skimmed;
pub(crate) mod skimmed;
#[allow(deprecated)]
@@ -29,5 +31,9 @@ pub(crate) fn routes() -> Router<AppState> {
.route("/entry-gateways", get(entry_gateways_basic_all))
.route("/exit-gateways", get(exit_gateways_basic_all)),
)
.nest(
"/semi-skimmed",
Router::new().route("/", get(nodes_expanded)),
)
.layer(CompressionLayer::new())
}
@@ -0,0 +1,177 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_status_api::models::AxumResult;
use crate::support::http::state::AppState;
use crate::unstable_routes::helpers::{refreshed_at, LegacyAnnotation};
use crate::unstable_routes::v2::nym_nodes::helpers::NodesParamsWithRole;
use axum::extract::{Query, State};
use nym_api_requests::models::{
NodeAnnotation, NymNodeDescription, OffsetDateTimeJsonSchemaWrapper,
};
use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponseV2, SemiSkimmedNode};
use nym_api_requests::pagination::PaginatedResponse;
use nym_http_api_common::FormattedResponse;
use nym_mixnet_contract_common::NodeId;
use nym_topology::CachedEpochRewardedSet;
use std::collections::HashMap;
use tracing::trace;
use utoipa::ToSchema;
pub type PaginatedSemiSkimmedNodes =
AxumResult<FormattedResponse<PaginatedCachedNodesResponseV2<SemiSkimmedNode>>>;
//SW TODO : this is copied from skimmed nodes, surely we can do better than that
fn build_nym_nodes_response<'a, NI>(
rewarded_set: &CachedEpochRewardedSet,
nym_nodes_subset: NI,
annotations: &HashMap<NodeId, NodeAnnotation>,
current_key_rotation: u32,
active_only: bool,
) -> Vec<SemiSkimmedNode>
where
NI: Iterator<Item = &'a NymNodeDescription> + 'a,
{
let mut nodes = Vec::new();
for nym_node in nym_nodes_subset {
let node_id = nym_node.node_id;
let role: NodeRole = rewarded_set.role(node_id).into();
// if the role is inactive, see if our filter allows it
if active_only && role.is_inactive() {
continue;
}
// honestly, not sure under what exact circumstances this value could be missing,
// but in that case just use 0 performance
let annotation = annotations.get(&node_id).copied().unwrap_or_default();
nodes.push(nym_node.to_semi_skimmed_node(
current_key_rotation,
role,
annotation.last_24h_performance,
));
}
nodes
}
//SW TODO : this is copied from skimmed nodes, surely we can do better than that
/// Given all relevant caches, add appropriate legacy nodes to the part of the response
fn add_legacy<LN>(
nodes: &mut Vec<SemiSkimmedNode>,
rewarded_set: &CachedEpochRewardedSet,
describe_cache: &DescribedNodes,
annotated_legacy_nodes: &HashMap<NodeId, LN>,
current_key_rotation: u32,
) where
LN: LegacyAnnotation,
{
for (node_id, legacy) in annotated_legacy_nodes.iter() {
let role: NodeRole = rewarded_set.role(*node_id).into();
// if we have self-described info, prefer it over contract data
if let Some(described) = describe_cache.get_node(node_id) {
nodes.push(described.to_semi_skimmed_node(
current_key_rotation,
role,
legacy.performance(),
))
} else {
match legacy.try_to_semi_skimmed_node(role) {
Ok(node) => nodes.push(node),
Err(err) => {
let id = legacy.identity();
trace!("node {id} is malformed: {err}")
}
}
}
}
}
#[allow(dead_code)] // not dead, used in OpenAPI docs
#[derive(ToSchema)]
#[schema(title = "PaginatedCachedNodesExpandedResponseSchema")]
pub struct PaginatedCachedNodesExpandedResponseSchema {
pub refreshed_at: OffsetDateTimeJsonSchemaWrapper,
#[schema(value_type = SemiSkimmedNode)]
pub nodes: PaginatedResponse<SemiSkimmedNode>,
}
/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used)
/// that are currently bonded.
#[utoipa::path(
tag = "Unstable Nym Nodes",
get,
params(NodesParamsWithRole),
path = "",
context_path = "/v2/unstable/nym-nodes/semi-skimmed",
responses(
(status = 200, content(
(PaginatedCachedNodesExpandedResponseSchema = "application/json"),
(PaginatedCachedNodesExpandedResponseSchema = "application/yaml"),
(PaginatedCachedNodesExpandedResponseSchema = "application/bincode")
))
)
)]
pub(super) async fn nodes_expanded(
state: State<AppState>,
query_params: Query<NodesParamsWithRole>,
) -> PaginatedSemiSkimmedNodes {
// 1. grab all relevant described nym-nodes
let rewarded_set = state.rewarded_set().await?;
let describe_cache = state.describe_nodes_cache_data().await?;
let all_nym_nodes = describe_cache.all_nym_nodes();
let annotations = state.node_annotations().await?;
let legacy_mixnodes = state.legacy_mixnode_annotations().await?;
let legacy_gateways = state.legacy_gateways_annotations().await?;
let contract_cache = state.nym_contract_cache();
let current_key_rotation = contract_cache.current_key_rotation_id().await?;
let interval = contract_cache.current_interval().await?;
let mut nodes = build_nym_nodes_response(
&rewarded_set,
all_nym_nodes,
&annotations,
current_key_rotation,
false,
);
// add legacy gateways to the response
add_legacy(
&mut nodes,
&rewarded_set,
&describe_cache,
&legacy_gateways,
current_key_rotation,
);
// add legacy mixnodes to the response
add_legacy(
&mut nodes,
&rewarded_set,
&describe_cache,
&legacy_mixnodes,
current_key_rotation,
);
// min of all caches
let refreshed_at = refreshed_at([
rewarded_set.timestamp(),
annotations.timestamp(),
describe_cache.timestamp(),
legacy_mixnodes.timestamp(),
legacy_gateways.timestamp(),
]);
let output = query_params.output.unwrap_or_default();
Ok(output.to_response(PaginatedCachedNodesResponseV2::new_full(
interval.current_epoch_absolute_id(),
current_key_rotation,
refreshed_at,
nodes,
)))
}
@@ -3,7 +3,7 @@
set -eu
export ENVIRONMENT=${ENVIRONMENT:-"mainnet"}
probe_git_ref="nym-vpn-core-v1.4.0"
probe_git_ref="nym-vpn-core-v1.10.0"
crate_root=$(dirname $(realpath "$0"))
monorepo_root=$(realpath "${crate_root}/../..")
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-api"
version = "2.3.3"
version = "2.4.1-rc.1"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -23,23 +23,39 @@ envy = { workspace = true }
futures-util = { workspace = true }
itertools = { workspace = true }
moka = { workspace = true, features = ["future"] }
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] }
nym-bin-common = { path = "../../common/bin-common", features = ["models"] }
nym-node-status-client = { path = "../nym-node-status-client" }
nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "serde"] }
nym-http-api-client = { path = "../../common/http-api-client" }
nym-http-api-common = { path = "../../common/http-api-common", features = ["middleware"]}
nym-network-defaults = { path = "../../common/network-defaults" }
nym-serde-helpers = { path = "../../common/serde-helpers" }
nym-statistics-common = { path = "../../common/statistics" }
nym-validator-client = { path = "../../common/client-libs/validator-client" }
nym-task = { path = "../../common/task" }
nym-node-requests = { path = "../../nym-node/nym-node-requests", features = ["openapi"] }
# TODO dz had to switch to cheddar versions because develop is ahead of current
# Nym API: revert after Cheddar is out
nym-contracts-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-mixnet-contract-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-bin-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-node-status-client = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-crypto = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-http-api-client = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-http-api-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar", features = ["middleware"]}
nym-network-defaults = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-serde-helpers = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-statistics-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
# nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
# nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] }
# nym-bin-common = { path = "../../common/bin-common", features = ["models"] }
# nym-node-status-client = { path = "../nym-node-status-client" }
# nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "serde"] }
# nym-http-api-client = { path = "../../common/http-api-client" }
# nym-http-api-common = { path = "../../common/http-api-common", features = ["middleware"]}
# nym-network-defaults = { path = "../../common/network-defaults" }
# nym-serde-helpers = { path = "../../common/serde-helpers" }
# nym-statistics-common = { path = "../../common/statistics" }
# nym-validator-client = { path = "../../common/client-libs/validator-client" }
nym-validator-client = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
# nym-task = { path = "../../common/task" }
# nym-node-requests = { path = "../../nym-node/nym-node-requests", features = ["openapi"] }
nym-task = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-node-requests = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
rand = { workspace = true }
rand_chacha = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_json_path = { workspace = true }
@@ -1,5 +1,3 @@
use std::str::FromStr;
use crate::{
http::{self, models::SummaryHistory},
utils::{decimal_to_i64, unix_timestamp_to_utc_rfc3339, NumericalCheckedCast},
@@ -14,6 +12,7 @@ use nym_validator_client::{
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::str::FromStr;
use strum_macros::{EnumString, FromRepr};
use time::{Date, OffsetDateTime};
use utoipa::ToSchema;
@@ -111,7 +111,8 @@ pub(crate) async fn get_all_gateways(pool: &DbPool) -> anyhow::Result<Vec<Gatewa
error!("Conversion from DTO failed: {e}. Invalidly stored data?");
e
})?;
tracing::trace!("Fetched {} gateways from DB", items.len());
// TODO dz revert to trace
tracing::info!("Fetched {} gateways from DB", items.len());
Ok(items)
}
@@ -36,6 +36,8 @@ pub(crate) async fn get_all_nym_nodes(pool: &DbPool) -> anyhow::Result<Vec<NymNo
bond_info as "bond_info: serde_json::Value"
FROM
nym_nodes
ORDER BY
node_id
"#,
)
.fetch(&mut *conn)
@@ -0,0 +1,111 @@
use axum::{
extract::{Path, Query, State},
Json, Router,
};
use semver::Version;
use serde::Deserialize;
use std::sync::LazyLock;
use tracing::instrument;
use utoipa::IntoParams;
use crate::http::models::DVpnGateway;
use crate::http::{
error::{HttpError, HttpResult},
state::AppState,
};
static MIN_SUPPORTED_VERSION: LazyLock<Version> = LazyLock::new(|| Version::new(1, 6, 2));
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/", axum::routing::get(dvpn_gateways))
.route(
"/country/:two_letter_country_code",
axum::routing::get(gateways_by_country),
)
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
struct MinNodeVersionQuery {
min_node_version: Option<String>,
}
#[utoipa::path(
tag = "dVPN Directory Cache",
get,
params(
MinNodeVersionQuery
),
path = "/directory/gateways",
summary = "Gets available entry and exit gateways from the Nym network directory",
context_path = "/dvpn/v1",
responses(
(status = 200, body = Vec<DVpnGateway>)
)
)]
#[instrument(level = tracing::Level::INFO, skip(state))]
async fn dvpn_gateways(
Query(MinNodeVersionQuery { min_node_version }): Query<MinNodeVersionQuery>,
state: State<AppState>,
) -> HttpResult<Json<Vec<DVpnGateway>>> {
let min_node_version = match min_node_version {
Some(min_version) => semver::Version::parse(&min_version)
.map_err(|_| HttpError::invalid_input("Min version must be valid semver"))?,
None => MIN_SUPPORTED_VERSION.clone(),
};
Ok(Json(
state
.cache()
.get_dvpn_gateway_list(state.db_pool(), &min_node_version)
.await,
))
}
#[allow(dead_code)] // clippy doesn't detect usage in utoipa macros
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Path)]
struct TwoLetterCountryCodeParam {
#[param(min_length = 2, max_length = 2)]
two_letter_country_code: String,
}
#[utoipa::path(
tag = "dVPN Directory Cache",
get,
params(
TwoLetterCountryCodeParam
),
path = "/directory/gateways/country/{two_letter_country_code}",
summary = "Gets available gateways from the Nym network directory by country",
context_path = "/dvpn/v1",
responses(
(status = 200, body = Vec<DVpnGateway>)
)
)]
#[instrument(level = tracing::Level::INFO, skip(state), fields(two_letter_country_code = two_letter_country_code))]
async fn gateways_by_country(
Path(TwoLetterCountryCodeParam {
two_letter_country_code,
}): Path<TwoLetterCountryCodeParam>,
state: State<AppState>,
) -> HttpResult<Json<Vec<DVpnGateway>>> {
let country_filter = two_letter_country_code.to_lowercase();
match two_letter_country_code.len() {
2 => Ok(Json(
state
.cache()
.get_dvpn_gateway_list(state.db_pool(), &MIN_SUPPORTED_VERSION)
.await
.into_iter()
.filter(|gw| {
gw.location.two_letter_iso_country_code.to_lowercase() == country_filter
})
.collect(),
)),
_ => Err(HttpError::invalid_input(
"Only two letter country code is allowed",
)),
}
}
@@ -8,6 +8,7 @@ use utoipa_swagger_ui::SwaggerUi;
use crate::http::{server::HttpServer, state::AppState};
pub(crate) mod dvpn;
pub(crate) mod gateways;
pub(crate) mod metrics;
pub(crate) mod mixnodes;
@@ -44,6 +45,10 @@ impl RouterBuilder {
"/explorer/v3",
Router::new().nest("/nym-nodes", nym_nodes::routes()),
)
.nest(
"/dvpn/v1",
Router::new().nest("/directory/gateways", dvpn::routes()),
)
.nest(
"/internal",
Router::new().nest("/testruns", testruns::routes()),
@@ -1,12 +1,22 @@
use cosmwasm_std::{Addr, Coin, Decimal};
use nym_mixnet_contract_common::CoinSchema;
use nym_node_requests::api::v1::node::models::NodeDescription;
use nym_validator_client::client::NodeId;
use nym_validator_client::{
client::NodeId,
models::{
AuthenticatorDetails, BinaryBuildInformationOwned, IpPacketRouterDetails, NymNodeData,
},
nym_api::SkimmedNode,
nym_nodes::{BasicEntryInformation, NodeRole},
};
use serde::{Deserialize, Serialize};
use tracing::{error, instrument};
use utoipa::ToSchema;
pub(crate) use nym_node_status_client::models::TestrunAssignment;
use crate::monitor::ExplorerPrettyBond;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Gateway {
pub gateway_identity_key: String,
@@ -23,6 +33,192 @@ pub struct Gateway {
pub config_score: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct BuildInformation {
pub build_version: String,
pub commit_branch: String,
pub commit_sha: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct Location {
pub two_letter_iso_country_code: String,
pub latitude: f64,
pub longitude: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct DVpnGateway {
pub identity_key: String,
pub name: String,
pub ip_packet_router: Option<IpPacketRouterDetails>,
pub authenticator: Option<AuthenticatorDetails>,
pub location: Location,
pub last_probe: Option<DirectoryGwProbe>,
pub ip_addresses: Vec<String>,
pub mix_port: u16,
pub role: NodeRole,
pub entry: Option<BasicEntryInformation>,
// The performance data here originates from the nym-api, and is effectively mixnet performance
// at the time of writing this
pub performance: String,
pub build_information: BinaryBuildInformationOwned,
}
/// 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
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct LastProbeResult {
node: String,
used_entry: String,
outcome: ProbeOutcome,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct DirectoryGwProbe {
last_updated_utc: String,
outcome: ProbeOutcome,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct ProbeOutcome {
as_entry: directory_gw_probe_outcome::Entry,
as_exit: Option<directory_gw_probe_outcome::Exit>,
wg: Option<wg_outcome_versions::ProbeOutcomeV1>,
}
pub mod directory_gw_probe_outcome {
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
#[allow(clippy::enum_variant_names)]
pub enum Entry {
Tested(EntryTestResult),
NotTested,
EntryFailure,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct EntryTestResult {
pub can_connect: bool,
pub can_route: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct Exit {
can_connect: bool,
can_route_ip_v4: bool,
can_route_ip_external_v4: bool,
can_route_ip_v6: bool,
can_route_ip_external_v6: bool,
}
}
pub mod wg_outcome_versions {
use super::*;
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct ProbeOutcomeV1 {
pub can_register: bool,
pub can_handshake_v4: bool,
pub can_resolve_dns_v4: bool,
pub ping_hosts_performance_v4: f32,
pub ping_ips_performance_v4: f32,
pub can_handshake_v6: bool,
pub can_resolve_dns_v6: bool,
pub ping_hosts_performance_v6: f32,
pub ping_ips_performance_v6: f32,
pub download_duration_sec_v4: u64,
pub downloaded_file_v4: String,
pub download_error_v4: String,
pub download_duration_sec_v6: u64,
pub downloaded_file_v6: String,
pub download_error_v6: String,
}
}
impl DVpnGateway {
#[instrument(level = tracing::Level::INFO, name = "dvpn_gw_new", skip_all, fields(gateway_key = gateway.gateway_identity_key, node_id = skimmed_node.node_id))]
pub(crate) fn new(gateway: Gateway, skimmed_node: &SkimmedNode) -> anyhow::Result<Self> {
let location = gateway
.explorer_pretty_bond
.ok_or_else(|| anyhow::anyhow!("Missing explorer_pretty_bond"))
.and_then(|value| {
serde_json::from_value::<ExplorerPrettyBond>(value).map_err(From::from)
})
.map(|bond| bond.location)?;
let self_described = gateway
.self_described
.ok_or_else(|| anyhow::anyhow!("Missing self_described"))
.and_then(|value| serde_json::from_value::<NymNodeData>(value).map_err(From::from))?;
let last_probe_result = match gateway.last_probe_result {
Some(value) => {
let parsed =
serde_json::from_value::<LastProbeResult>(value).inspect_err(|err| {
error!("Failed to deserialize probe result: {err}");
})?;
Some(parsed)
}
None => None,
};
Ok(Self {
identity_key: gateway.gateway_identity_key,
name: gateway.description.moniker,
ip_packet_router: self_described.ip_packet_router,
authenticator: self_described.authenticator,
location: Location {
latitude: location.location.latitude,
longitude: location.location.longitude,
two_letter_iso_country_code: location.two_letter_iso_country_code,
},
last_probe: last_probe_result.map(|res| DirectoryGwProbe {
last_updated_utc: gateway.last_testrun_utc.unwrap_or_default(),
outcome: res.outcome,
}),
ip_addresses: skimmed_node
.ip_addresses
.iter()
.map(|ip| ip.to_string())
.collect(),
mix_port: skimmed_node.mix_port,
role: skimmed_node.role.clone(),
entry: skimmed_node.entry.clone(),
performance: to_percent(gateway.performance),
build_information: self_described.build_information,
})
}
}
fn to_percent(performance: u8) -> String {
let fraction = performance as f32 / 100.0;
format!("{:.2}", fraction)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn to_percent_should_work() {
let starting = [0u8, 33, 50, 99, 100];
let expected = ["0.00", "0.33", "0.50", "0.99", "1.00"];
for (starting, expected) in starting.into_iter().zip(expected) {
assert_eq!(expected, to_percent(starting));
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct GatewaySkinny {
pub gateway_identity_key: String,
@@ -1,16 +1,20 @@
use cosmwasm_std::Decimal;
use itertools::Itertools;
use moka::{future::Cache, Entry};
use nym_contracts_common::NaiveFloat;
use nym_crypto::asymmetric::ed25519::PublicKey;
use nym_mixnet_contract_common::NodeId;
use nym_validator_client::nym_api::SkimmedNode;
use semver::Version;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use tracing::instrument;
use tracing::{error, instrument, warn};
use crate::{
db::{queries, DbPool},
http::models::{DailyStats, ExtendedNymNode, Gateway, Mixnode, NodeGeoData, SummaryHistory},
http::models::{
DVpnGateway, DailyStats, ExtendedNymNode, Gateway, Mixnode, NodeGeoData, SummaryHistory,
},
monitor::{DelegationsCache, NodeGeoCache},
};
@@ -88,6 +92,8 @@ const MIXNODE_STATS_HISTORY_DAYS: usize = 30;
#[derive(Debug, Clone)]
pub(crate) struct HttpCache {
gateways: Cache<String, Arc<RwLock<Vec<Gateway>>>>,
// each min_version has their own cached list
dvpn_gateways: Cache<Version, Arc<RwLock<Vec<DVpnGateway>>>>,
mixnodes: Cache<String, Arc<RwLock<Vec<Mixnode>>>>,
nym_nodes: Cache<String, Arc<RwLock<Vec<ExtendedNymNode>>>>,
mixstats: Cache<String, Arc<RwLock<Vec<DailyStats>>>>,
@@ -102,6 +108,10 @@ impl HttpCache {
.max_capacity(2)
.time_to_live(Duration::from_secs(ttl_seconds))
.build(),
dvpn_gateways: Cache::builder()
.max_capacity(6)
.time_to_live(Duration::from_secs(ttl_seconds))
.build(),
mixnodes: Cache::builder()
.max_capacity(2)
.time_to_live(Duration::from_secs(ttl_seconds))
@@ -158,10 +168,11 @@ impl HttpCache {
let gateways = crate::db::queries::get_all_gateways(db)
.await
.unwrap_or_default();
self.upsert_gateway_list(gateways.clone()).await;
if gateways.is_empty() {
tracing::warn!("Database contains 0 gateways");
tracing::warn!("Database: gateway list is empty");
} else {
self.upsert_gateway_list(gateways.clone()).await;
}
gateways
@@ -169,6 +180,132 @@ impl HttpCache {
}
}
pub async fn upsert_dvpn_gateway_list(
&self,
new_gateway_list: Vec<DVpnGateway>,
min_node_version: &Version,
) -> Entry<Version, Arc<RwLock<Vec<DVpnGateway>>>> {
self.dvpn_gateways
.entry_by_ref(min_node_version)
.and_upsert_with(|maybe_entry| async {
if let Some(entry) = maybe_entry {
let v = entry.into_value();
let mut guard = v.write().await;
*guard = new_gateway_list;
v.clone()
} else {
Arc::new(RwLock::new(new_gateway_list))
}
})
.await
}
pub async fn get_dvpn_gateway_list(
&self,
db: &DbPool,
min_node_version: &Version,
) -> Vec<DVpnGateway> {
match self.dvpn_gateways.get(min_node_version).await {
Some(guard) => {
tracing::trace!("Fetching from cache...");
let read_lock = guard.read().await;
read_lock.clone()
}
None => {
tracing::trace!("No gateways (dVPN) in cache, refreshing from DB...");
let gateways = self.get_gateway_list(db).await;
let started_with = gateways.len();
let Ok(skimmed_nodes) = crate::db::queries::get_described_bonded_nym_nodes(db)
.await
.map(|records| {
records
.into_iter()
.filter_map(|dto| SkimmedNode::try_from(dto).ok())
.map(|skimmed_node| {
(
skimmed_node.ed25519_identity_pubkey.to_base58_string(),
skimmed_node,
)
})
.collect::<HashMap<_, _>>()
})
.inspect_err(|err| {
// this would fail only in case of internal error: log and return gracefully
error!("Failed to get nym_nodes from DB: {err}");
})
else {
return Vec::new();
};
let res_gws = gateways
.into_iter()
.filter(|gw| gw.bonded)
.filter_map(|gw| match skimmed_nodes.get(&gw.gateway_identity_key) {
Some(skimmed_node) => Some((gw, skimmed_node)),
None => {
warn!(
"No SkimmedNode data found for GW, identity_key={}",
gw.gateway_identity_key
);
None
}
})
.filter_map(
|(gw, skimmed_node)| match DVpnGateway::new(gw, skimmed_node) {
Ok(gw) => Some(gw),
Err(err) => {
error!(
"Failed to convert GW node_id={} to dVPN form: {}",
skimmed_node.node_id, err
);
None
}
},
)
.filter(|gw| {
let gw_version = &gw.build_information.build_version;
if let Ok(gw_version) = Version::parse(gw_version) {
&gw_version >= min_node_version
} else {
warn!("Failed to parse GW version {}", gw_version);
false
}
})
.filter(|gw| {
// gateways must have a country
if gw.location.two_letter_iso_country_code.len() == 2 {
true
} else {
warn!(
"Invalid country code: {}",
gw.location.two_letter_iso_country_code
);
false
}
})
// sort by country, then by identity key
.sorted_by_key(|item| {
(
item.location.two_letter_iso_country_code.clone(),
item.identity_key.clone(),
)
})
.collect::<Vec<_>>();
if res_gws.is_empty() && started_with > 0 {
tracing::warn!("Started with {}, got 0 gateways", started_with);
} else {
self.upsert_dvpn_gateway_list(res_gws.clone(), min_node_version)
.await;
}
res_gws
}
}
}
pub async fn upsert_mixnode_list(
&self,
new_mixnode_list: Vec<Mixnode>,
@@ -201,10 +338,11 @@ impl HttpCache {
let mixnodes = crate::db::queries::get_all_mixnodes(db)
.await
.unwrap_or_default();
self.upsert_mixnode_list(mixnodes.clone()).await;
if mixnodes.is_empty() {
tracing::warn!("Database contains 0 mixnodes");
} else {
self.upsert_mixnode_list(mixnodes.clone()).await;
}
mixnodes
@@ -245,14 +383,13 @@ impl HttpCache {
None => {
tracing::trace!("No nym nodes in cache, refreshing cache from DB...");
let nym_nodes = aggregate_node_info_from_db(db, node_geocache)
.await
.inspect(|nym_nodes| {
if nym_nodes.is_empty() {
tracing::warn!("Database contains 0 nym nodes");
}
})?;
self.upsert_nym_node_list(nym_nodes.clone()).await;
let nym_nodes = aggregate_node_info_from_db(db, node_geocache).await?;
if nym_nodes.is_empty() {
tracing::warn!("Database contains 0 nym nodes");
} else {
self.upsert_nym_node_list(nym_nodes.clone()).await;
}
Ok(nym_nodes)
}
@@ -63,7 +63,7 @@ impl IpInfoClient {
}
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExplorerPrettyBond {
pub(crate) identity_key: String,
pub(crate) owner: Addr,
@@ -71,7 +71,7 @@ pub(crate) struct ExplorerPrettyBond {
pub(crate) location: Location,
}
#[derive(Debug, Clone, Default, Serialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct Location {
pub(crate) two_letter_iso_country_code: String,
#[serde(flatten)]
@@ -7,7 +7,6 @@ use crate::db::models::{
NYMNODES_DESCRIBED_COUNT, NYMNODE_COUNT,
};
use crate::db::{queries, DbPool};
use crate::monitor::geodata::{ExplorerPrettyBond, Location};
use crate::utils::now_utc;
use crate::utils::{decimal_to_i64, LogError, NumericalCheckedCast};
use anyhow::anyhow;
@@ -30,7 +29,7 @@ use std::{
use tokio::{sync::RwLock, time::Duration};
use tracing::instrument;
pub(crate) use geodata::IpInfoClient;
pub(crate) use geodata::{ExplorerPrettyBond, IpInfoClient, Location};
pub(crate) use node_delegations::DelegationsCache;
mod geodata;
@@ -106,11 +105,10 @@ impl Monitor {
.clone()
.expect("rust sdk mainnet default missing api_url");
let nym_api =
nym_http_api_client::ClientBuilder::new_with_urls(vec![default_api_url.into()])
.no_hickory_dns()
.with_timeout(self.nym_api_client_timeout)
.build::<&str>()?;
let nym_api = nym_http_api_client::ClientBuilder::new_with_url(default_api_url)
.no_hickory_dns()
.with_timeout(self.nym_api_client_timeout)
.build::<&str>()?;
let api_client = NymApiClient::from(nym_api);
@@ -134,6 +132,8 @@ impl Monitor {
})
.collect::<Vec<_>>();
tracing::info!("🟣 🚪 gateway nodes: {}", gateways.len());
let bonded_nym_nodes = api_client
.get_all_bonded_nym_nodes()
.await?
@@ -150,7 +150,8 @@ impl Monitor {
.await
.log_error("get_all_basic_nodes")?;
tracing::info!("🟣 get_all_basic_nodes: {}", nym_nodes.len());
let nym_node_count = nym_nodes.len();
tracing::info!("🟣 get_all_basic_nodes: {}", nym_node_count);
let nym_node_records =
self.prepare_nym_node_data(nym_nodes.clone(), &bonded_nym_nodes, &described_nodes);
@@ -251,7 +252,7 @@ impl Monitor {
//
let nodes_summary = vec![
(NYMNODE_COUNT, nym_nodes.len()),
(NYMNODE_COUNT, nym_node_count),
(ASSIGNED_MIXING_COUNT, assigned_mixing_count),
(MIXNODES_LEGACY_COUNT, count_legacy_mixnodes),
(NYMNODES_DESCRIBED_COUNT, described_nodes.len()),
@@ -267,7 +268,7 @@ impl Monitor {
let last_updated = now_utc();
let last_updated_utc = last_updated.unix_timestamp().to_string();
let network_summary = NetworkSummary {
total_nodes: nym_nodes.len().cast_checked()?,
total_nodes: nym_node_count.cast_checked()?,
mixnodes: mixnode::MixnodeSummary {
bonded: mixnode::MixingNodesSummary {
count: assigned_mixing_count.cast_checked()?,
@@ -457,11 +458,7 @@ impl Monitor {
async fn check_ipinfo_bandwidth(&self) {
match self.ipinfo.check_remaining_bandwidth().await {
Ok(bandwidth) => {
tracing::info!(
"ipinfo monthly bandwidth: {}/{} spent",
bandwidth.month,
bandwidth.limit
);
tracing::info!("ipinfo monthly bandwidth: {} spent", bandwidth.month);
}
Err(err) => {
tracing::debug!("Couldn't check ipinfo bandwidth: {}", err);
@@ -57,7 +57,7 @@ async fn run(
.clone()
.expect("rust sdk mainnet default missing api_url");
let nym_api = nym_http_api_client::ClientBuilder::new_with_urls(vec![default_api_url.into()])
let nym_api = nym_http_api_client::ClientBuilder::new_with_url(default_api_url)
.no_hickory_dns()
.with_timeout(nym_api_client_timeout)
.build::<&str>()?;
@@ -24,7 +24,7 @@ pub(crate) async fn spawn(pool: DbPool, refresh_interval: Duration) {
});
}
#[instrument(level = "debug", name = "testrun_queue", skip_all)]
#[instrument(level = "info", name = "testrun_queue", skip_all)]
async fn run(pool: &DbPool) -> anyhow::Result<()> {
tracing::info!("Spawning testruns...");
if pool.is_closed() {
+2
View File
@@ -55,6 +55,8 @@ nym-config = { path = "../common/config" }
nym-crypto = { path = "../common/crypto", features = ["asymmetric", "rand"] }
nym-nonexhaustive-delayqueue = { path = "../common/nonexhaustive-delayqueue" }
nym-mixnet-client = { path = "../common/client-libs/mixnet-client" }
nym-noise = { path = "../common/nymnoise" }
nym-noise-keys = { path = "../common/nymnoise/keys" }
nym-pemstore = { path = "../common/pemstore" }
nym-sphinx-acknowledgements = { path = "../common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { path = "../common/nymsphinx/addressing" }
+1
View File
@@ -26,6 +26,7 @@ nym-crypto = { path = "../../common/crypto", features = [
"serde",
] }
nym-exit-policy = { path = "../../common/exit-policy" }
nym-noise-keys = { path = "../../common/nymnoise/keys" }
nym-wireguard-types = { path = "../../common/wireguard-types", default-features = false }
# feature-specific dependencies:
+80 -14
View File
@@ -1,4 +1,4 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2023-2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::api::v1::node::models::{
@@ -41,6 +41,7 @@ impl<T> SignedData<T> {
T: Serialize,
{
let plaintext = serde_json::to_string(&data)?;
let signature = key.sign(plaintext).to_base58_string();
Ok(SignedData { data, signature })
}
@@ -67,6 +68,17 @@ impl SignedHostInformation {
return true;
}
// TODO: @JS: to remove downgrade support in future release(s)
let legacy_v3 = SignedData {
data: LegacyHostInformationV3::from(self.data.clone()),
signature: self.signature.clone(),
};
if legacy_v3.verify(&self.keys.ed25519_identity) {
return true;
}
// attempt to verify legacy signatures
let legacy_v3 = SignedData {
data: LegacyHostInformationV3::from(self.data.clone()),
@@ -116,9 +128,11 @@ impl Display for ErrorResponse {
#[allow(deprecated)]
#[cfg(test)]
mod tests {
use super::*;
use crate::api::v1::node::models::{HostKeys, SphinxKey};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_noise_keys::{NoiseVersion, VersionedNoiseKey};
use rand_chacha::rand_core::SeedableRng;
#[test]
@@ -127,7 +141,10 @@ mod tests {
let ed22519 = ed25519::KeyPair::new(&mut rng);
let x25519_sphinx = x25519::KeyPair::new(&mut rng);
let x25519_sphinx2 = x25519::KeyPair::new(&mut rng);
let x25519_noise = x25519::KeyPair::new(&mut rng);
let x25519_versioned_noise = VersionedNoiseKey {
supported_version: NoiseVersion::V1,
x25519_pubkey: *x25519::KeyPair::new(&mut rng).public_key(),
};
let current_rotation_id = 1234;
@@ -142,8 +159,8 @@ mod tests {
rotation_id: current_rotation_id,
public_key: *x25519_sphinx.public_key(),
},
x25519_noise: None,
pre_announced_x25519_sphinx_key: None,
x25519_versioned_noise: None,
},
};
@@ -162,7 +179,7 @@ mod tests {
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_noise: Some(*x25519_noise.public_key()),
x25519_versioned_noise: Some(x25519_versioned_noise),
},
};
@@ -186,7 +203,7 @@ mod tests {
rotation_id: current_rotation_id + 1,
public_key: *x25519_sphinx2.public_key(),
}),
x25519_noise: None,
x25519_versioned_noise: None,
},
};
@@ -208,7 +225,7 @@ mod tests {
rotation_id: current_rotation_id + 1,
public_key: *x25519_sphinx2.public_key(),
}),
x25519_noise: Some(*x25519_noise.public_key()),
x25519_versioned_noise: Some(x25519_versioned_noise),
},
};
@@ -225,13 +242,13 @@ mod tests {
let x25519_sphinx = x25519::KeyPair::new(&mut rng);
let x25519_noise = x25519::KeyPair::new(&mut rng);
let legacy_info = crate::api::v1::node::models::LegacyHostInformationV3 {
let legacy_info_no_noise = crate::api::v1::node::models::LegacyHostInformationV3 {
ip_address: vec!["1.1.1.1".parse().unwrap()],
hostname: Some("foomp.com".to_string()),
keys: crate::api::v1::node::models::LegacyHostKeysV3 {
ed25519_identity: *ed22519.public_key(),
x25519_sphinx: *x25519_sphinx.public_key(),
x25519_noise: Some(*x25519_noise.public_key()),
x25519_noise: None,
},
};
@@ -247,12 +264,12 @@ mod tests {
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_noise: Some(*x25519_noise.public_key()),
x25519_versioned_noise: None,
},
};
// signature on legacy data
let signature = SignedData::new(legacy_info, ed22519.private_key())
let signature = SignedData::new(legacy_info_no_noise, ed22519.private_key())
.unwrap()
.signature;
@@ -263,7 +280,53 @@ mod tests {
};
assert!(!current_struct.verify(ed22519.public_key()));
assert!(current_struct.verify_host_information())
assert!(current_struct.verify_host_information());
// //technically this variant should never happen
let legacy_info_noise = crate::api::v1::node::models::LegacyHostInformationV3 {
ip_address: vec!["1.1.1.1".parse().unwrap()],
hostname: Some("foomp.com".to_string()),
keys: crate::api::v1::node::models::LegacyHostKeysV3 {
ed25519_identity: *ed22519.public_key(),
x25519_sphinx: *x25519_sphinx.public_key(),
x25519_noise: Some(*x25519_noise.public_key()),
},
};
// note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
let current_struct_noise = crate::api::v1::node::models::HostInformation {
ip_address: vec!["1.1.1.1".parse().unwrap()],
hostname: Some("foomp.com".to_string()),
keys: HostKeys {
ed25519_identity: *ed22519.public_key(),
x25519_sphinx: *x25519_sphinx.public_key(),
primary_x25519_sphinx_key: SphinxKey {
rotation_id: u32::MAX,
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_versioned_noise: Some(VersionedNoiseKey {
supported_version: NoiseVersion::V1,
x25519_pubkey: legacy_info_noise.keys.x25519_noise.unwrap(),
}),
},
};
// signature on legacy data
let signature_noise = SignedData::new(legacy_info_noise, ed22519.private_key())
.unwrap()
.signature;
// signed blob with the 'current' structure
let current_struct_noise = SignedData {
data: current_struct_noise,
signature: signature_noise,
};
assert!(!current_struct_noise.verify(ed22519.public_key()));
assert!(current_struct_noise.verify_host_information())
}
#[test]
@@ -305,7 +368,7 @@ mod tests {
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_noise: None,
x25519_versioned_noise: None,
},
};
@@ -321,7 +384,10 @@ mod tests {
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_noise: Some(legacy_info_noise.keys.x25519_noise.parse().unwrap()),
x25519_versioned_noise: Some(VersionedNoiseKey {
supported_version: NoiseVersion::V1,
x25519_pubkey: legacy_info_noise.keys.x25519_noise.parse().unwrap(),
}),
},
};
@@ -379,7 +445,7 @@ mod tests {
public_key: *x25519_sphinx.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_noise: None,
x25519_versioned_noise: None,
},
};
@@ -4,9 +4,9 @@
use celes::Country;
use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey};
use nym_crypto::asymmetric::x25519::{
self,
serde_helpers::{bs58_x25519_pubkey, option_bs58_x25519_pubkey},
self, serde_helpers::bs58_x25519_pubkey, serde_helpers::option_bs58_x25519_pubkey,
};
use nym_noise_keys::VersionedNoiseKey;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
@@ -145,9 +145,8 @@ pub struct HostKeys {
pub pre_announced_x25519_sphinx_key: Option<SphinxKey>,
/// Base58-encoded x25519 public key of this node used for the noise protocol.
#[schemars(with = "Option<String>")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub x25519_noise: Option<x25519::PublicKey>,
#[serde(default)]
pub x25519_versioned_noise: Option<VersionedNoiseKey>,
}
// we need the intermediate struct to help us with the new explicit sphinx key fields
@@ -167,7 +166,7 @@ impl From<HostKeysDeHelper> for HostKeys {
x25519_sphinx: value.x25519_sphinx,
primary_x25519_sphinx_key,
pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key,
x25519_noise: value.x25519_noise,
x25519_versioned_noise: value.x25519_versioned_noise,
}
}
}
@@ -194,8 +193,7 @@ struct HostKeysDeHelper {
/// Base58-encoded x25519 public key of this node used for the noise protocol.
#[serde(default)]
#[serde(with = "option_bs58_x25519_pubkey")]
pub x25519_noise: Option<x25519::PublicKey>,
pub x25519_versioned_noise: Option<VersionedNoiseKey>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -224,18 +222,14 @@ impl SphinxKey {
#[derive(Serialize)]
pub struct LegacyHostKeysV3 {
/// Base58-encoded ed25519 public key of this node. Currently, it corresponds to either mixnode's or gateway's identity.
#[serde(alias = "ed25519")]
#[serde(with = "bs58_ed25519_pubkey")]
pub ed25519_identity: ed25519::PublicKey,
/// Base58-encoded x25519 public key of this node used for sphinx/outfox packet creation.
/// Currently, it corresponds to either mixnode's or gateway's key.
#[serde(alias = "x25519")]
#[serde(with = "bs58_x25519_pubkey")]
pub x25519_sphinx: x25519::PublicKey,
/// Base58-encoded x25519 public key of this node used for the noise protocol.
#[serde(default)]
#[serde(with = "option_bs58_x25519_pubkey")]
pub x25519_noise: Option<x25519::PublicKey>,
@@ -259,7 +253,7 @@ impl From<HostKeys> for LegacyHostKeysV3 {
LegacyHostKeysV3 {
ed25519_identity: value.ed25519_identity,
x25519_sphinx: value.primary_x25519_sphinx_key.public_key,
x25519_noise: value.x25519_noise,
x25519_noise: value.x25519_versioned_noise.map(|k| k.x25519_pubkey),
}
}
}
+1 -2
View File
@@ -765,8 +765,7 @@ impl Default for MixnetDebug {
packet_forwarding_maximum_backoff: Self::DEFAULT_PACKET_FORWARDING_MAXIMUM_BACKOFF,
initial_connection_timeout: Self::DEFAULT_INITIAL_CONNECTION_TIMEOUT,
maximum_connection_buffer_size: Self::DEFAULT_MAXIMUM_CONNECTION_BUFFER_SIZE,
// to be changed by @SW once the implementation is there
unsafe_disable_noise: true,
unsafe_disable_noise: false,
}
}
}
@@ -55,7 +55,7 @@ pub(crate) async fn host_information(
rotation_id: primary_key.rotation_id(),
public_key: primary_pubkey,
},
x25519_noise: state.static_information.x25519_noise_key,
x25519_versioned_noise: state.static_information.x25519_versioned_noise_key,
pre_announced_x25519_sphinx_key: pre_announced,
},
};
+3 -2
View File
@@ -4,8 +4,9 @@
use crate::node::http::state::load::CachedNodeLoad;
use crate::node::http::state::metrics::MetricsAppState;
use crate::node::key_rotation::active_keys::ActiveSphinxKeys;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::asymmetric::ed25519;
use nym_node_metrics::NymNodeMetrics;
use nym_noise_keys::VersionedNoiseKey;
use nym_verloc::measurements::SharedVerlocStats;
use std::net::IpAddr;
use std::sync::Arc;
@@ -17,7 +18,7 @@ pub mod metrics;
pub(crate) struct StaticNodeInformation {
pub(crate) ed25519_identity_keys: Arc<ed25519::KeyPair>,
pub(crate) x25519_noise_key: Option<x25519::PublicKey>,
pub(crate) x25519_versioned_noise_key: Option<VersionedNoiseKey>,
pub(crate) ip_addresses: Vec<IpAddr>,
pub(crate) hostname: Option<String>,
}
+28 -9
View File
@@ -3,6 +3,8 @@
use crate::node::mixnet::shared::SharedData;
use futures::StreamExt;
use nym_noise::connection::Connection;
use nym_noise::upgrade_noise_responder;
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_framing::codec::NymCodec;
use nym_sphinx_framing::packet::FramedNymPacket;
@@ -72,7 +74,6 @@ impl PendingReplayCheckPackets {
pub(crate) struct ConnectionHandler {
shared: SharedData,
mixnet_connection: Framed<TcpStream, NymCodec>,
remote_address: SocketAddr,
// packets pending for replay detection
@@ -89,11 +90,7 @@ impl Drop for ConnectionHandler {
}
impl ConnectionHandler {
pub(crate) fn new(
shared: &SharedData,
tcp_stream: TcpStream,
remote_address: SocketAddr,
) -> Self {
pub(crate) fn new(shared: &SharedData, remote_address: SocketAddr) -> Self {
let shutdown = shared.shutdown.child_token(remote_address.to_string());
shared.metrics.network.new_active_ingress_mixnet_client();
@@ -104,11 +101,11 @@ impl ConnectionHandler {
replay_protection_filter: shared.replay_protection_filter.clone(),
mixnet_forwarder: shared.mixnet_forwarder.clone(),
final_hop: shared.final_hop.clone(),
noise_config: shared.noise_config.clone(),
metrics: shared.metrics.clone(),
shutdown,
},
remote_address,
mixnet_connection: Framed::new(tcp_stream, NymCodec),
pending_packets: PendingReplayCheckPackets::new(),
}
}
@@ -466,7 +463,29 @@ impl ConnectionHandler {
remote = %self.remote_address
)
)]
pub(crate) async fn handle_stream(&mut self) {
pub(crate) async fn handle_connection(&mut self, socket: TcpStream) {
let noise_stream = match upgrade_noise_responder(socket, &self.shared.noise_config).await {
Ok(noise_stream) => noise_stream,
Err(err) => {
error!(
"Failed to perform Noise handshake with {:?} - {err}",
self.remote_address
);
return;
}
};
debug!(
"Noise responder handshake completed for {:?}",
self.remote_address
);
self.handle_stream(Framed::new(noise_stream, NymCodec))
.await
}
pub(crate) async fn handle_stream(
&mut self,
mut mixnet_connection: Framed<Connection<TcpStream>, NymCodec>,
) {
loop {
tokio::select! {
biased;
@@ -474,7 +493,7 @@ impl ConnectionHandler {
trace!("connection handler: received shutdown");
break
}
maybe_framed_nym_packet = self.mixnet_connection.next() => {
maybe_framed_nym_packet = mixnet_connection.next() => {
match maybe_framed_nym_packet {
Some(Ok(packet)) => self.handle_received_nym_packet(packet).await,
Some(Err(err)) => {
+10 -2
View File
@@ -10,6 +10,7 @@ use nym_gateway::node::GatewayStorageError;
use nym_mixnet_client::forwarder::{MixForwardingSender, PacketToForward};
use nym_node_metrics::mixnet::PacketKind;
use nym_node_metrics::NymNodeMetrics;
use nym_noise::config::NoiseConfig;
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_framing::processing::{
MixPacketVersion, MixProcessingResult, MixProcessingResultData, PacketProcessingError,
@@ -74,6 +75,9 @@ pub(crate) struct SharedData {
// data specific to the final hop (gateway) processing
pub(super) final_hop: SharedFinalHopData,
// for establishing a Noise connection
pub(super) noise_config: NoiseConfig,
pub(super) metrics: NymNodeMetrics,
pub(super) shutdown: ShutdownToken,
}
@@ -86,12 +90,14 @@ fn convert_to_metrics_version(processed: MixPacketVersion) -> PacketKind {
}
impl SharedData {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
processing_config: ProcessingConfig,
sphinx_keys: ActiveSphinxKeys,
replay_protection_filter: ReplayProtectionBloomfilters,
mixnet_forwarder: MixForwardingSender,
final_hop: SharedFinalHopData,
noise_config: NoiseConfig,
metrics: NymNodeMetrics,
shutdown: ShutdownToken,
) -> Self {
@@ -101,6 +107,7 @@ impl SharedData {
replay_protection_filter,
mixnet_forwarder,
final_hop,
noise_config,
metrics,
shutdown,
}
@@ -163,8 +170,9 @@ impl SharedData {
match accepted {
Ok((socket, remote_addr)) => {
debug!("accepted incoming mixnet connection from: {remote_addr}");
let mut handler = ConnectionHandler::new(self, socket, remote_addr);
let join_handle = tokio::spawn(async move { handler.handle_stream().await });
let mut handler = ConnectionHandler::new(self, remote_addr);
let join_handle =
tokio::spawn(async move { handler.handle_connection(socket).await });
self.log_connected_clients();
Some(join_handle)
}
+27 -3
View File
@@ -50,6 +50,8 @@ use nym_network_requester::{
use nym_node_metrics::events::MetricEventsSender;
use nym_node_metrics::NymNodeMetrics;
use nym_node_requests::api::v1::node::models::{AnnouncePorts, NodeDescription};
use nym_noise::config::{NoiseConfig, NoiseNetworkView};
use nym_noise_keys::VersionedNoiseKey;
use nym_sphinx_acknowledgements::AckKey;
use nym_sphinx_addressing::Recipient;
use nym_task::{ShutdownManager, ShutdownToken, TaskClient};
@@ -800,16 +802,19 @@ impl NymNode {
config.api.v1_config.node.roles.ip_packet_router_enabled = true;
}
let x25519_noise_key = if self.config.mixnet.debug.unsafe_disable_noise {
let x25519_versioned_noise_key = if self.config.mixnet.debug.unsafe_disable_noise {
None
} else {
Some(*self.x25519_noise_keys.public_key())
Some(VersionedNoiseKey {
supported_version: nym_noise::LATEST_NOISE_VERSION,
x25519_pubkey: *self.x25519_noise_keys.public_key(),
})
};
let app_state = AppState::new(
StaticNodeInformation {
ed25519_identity_keys: self.ed25519_identity_keys.clone(),
x25519_noise_key,
x25519_versioned_noise_key,
ip_addresses: self.config.host.public_ips.clone(),
hostname: self.config.host.hostname.clone(),
},
@@ -1031,6 +1036,7 @@ impl NymNode {
active_clients_store: &ActiveClientsStore,
replay_protection_bloomfilter: ReplayProtectionBloomfilters,
routing_filter: F,
noise_config: NoiseConfig,
shutdown: ShutdownToken,
) -> Result<(MixForwardingSender, ActiveConnections), NymNodeError>
where
@@ -1054,6 +1060,7 @@ impl NymNode {
);
let mixnet_client = nym_mixnet_client::Client::new(
mixnet_client_config,
noise_config.clone(),
self.metrics
.network
.active_egress_mixnet_connections_counter(),
@@ -1080,6 +1087,7 @@ impl NymNode {
replay_protection_bloomfilter,
mix_packet_sender.clone(),
final_hop_data,
noise_config,
self.metrics.clone(),
shutdown,
);
@@ -1089,10 +1097,18 @@ impl NymNode {
}
pub(crate) async fn run_minimal_mixnet_processing(self) -> Result<(), NymNodeError> {
let noise_config = nym_noise::config::NoiseConfig::new(
self.x25519_noise_keys.clone(),
NoiseNetworkView::new_empty(),
self.config.mixnet.debug.initial_connection_timeout,
)
.with_unsafe_disabled(true);
self.start_mixnet_listener(
&ActiveClientsStore::new(),
ReplayProtectionBloomfilters::new_disabled(),
OpenFilter,
noise_config,
self.shutdown_manager.clone_token("mixnet-traffic"),
)
.await?;
@@ -1137,11 +1153,19 @@ impl NymNode {
let bloomfilters_manager = self.setup_replay_detection().await?;
let noise_config = nym_noise::config::NoiseConfig::new(
self.x25519_noise_keys.clone(),
network_refresher.noise_view(),
self.config.mixnet.debug.initial_connection_timeout,
)
.with_unsafe_disabled(self.config.mixnet.debug.unsafe_disable_noise);
let (mix_packet_sender, active_egress_mixnet_connections) = self
.start_mixnet_listener(
&active_clients_store,
bloomfilters_manager.bloomfilters(),
network_refresher.routing_filter(),
noise_config,
self.shutdown_manager.clone_token("mixnet-traffic"),
)
.await?;
+44 -13
View File
@@ -8,6 +8,7 @@ use async_trait::async_trait;
use nym_crypto::asymmetric::ed25519;
use nym_gateway::node::UserAgent;
use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS};
use nym_noise::config::NoiseNetworkView;
use nym_task::ShutdownToken;
use nym_topology::node::RoutingNode;
use nym_topology::{
@@ -16,10 +17,10 @@ use nym_topology::{
};
use nym_validator_client::nym_api::NymApiClientExt;
use nym_validator_client::nym_nodes::{
NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata,
NodesByAddressesResponse, SemiSkimmedNode, SemiSkimmedNodesWithMetadata,
};
use nym_validator_client::{NymApiClient, ValidatorClientError};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::ops::Deref;
use std::sync::Arc;
@@ -63,10 +64,12 @@ impl NodesQuerier {
res
}
async fn current_nymnodes(&mut self) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
async fn current_nymnodes(
&mut self,
) -> Result<SemiSkimmedNodesWithMetadata, ValidatorClientError> {
let res = self
.client
.get_all_basic_nodes_with_metadata()
.get_all_expanded_nodes()
.await
.inspect_err(|err| error!("failed to get network nodes: {err}"));
@@ -150,13 +153,19 @@ impl TopologyProvider for CachedTopologyProvider {
network_guard.rewarded_set.clone(),
Vec::new(),
)
.with_additional_nodes(network_guard.network_nodes.iter().filter(|node| {
if node.supported_roles.mixnode {
node.performance.round_to_integer() >= self.min_mix_performance
} else {
true
}
}));
.with_additional_nodes(
network_guard
.network_nodes
.iter()
.map(|node| &node.basic)
.filter(|node| {
if node.supported_roles.mixnode {
node.performance.round_to_integer() >= self.min_mix_performance
} else {
true
}
}),
);
if !topology.has_node(self.gateway_node.identity_key) {
debug!("{self_node} didn't exist in topology. inserting it.",);
@@ -188,7 +197,7 @@ impl CachedNetwork {
struct CachedNetworkInner {
rewarded_set: EpochRewardedSet,
topology_metadata: NymTopologyMetadata,
network_nodes: Vec<SkimmedNode>,
network_nodes: Vec<SemiSkimmedNode>,
}
pub struct NetworkRefresher {
@@ -199,6 +208,7 @@ pub struct NetworkRefresher {
network: CachedNetwork,
routing_filter: NetworkRoutingFilter,
noise_view: NoiseNetworkView,
}
impl NetworkRefresher {
@@ -226,6 +236,7 @@ impl NetworkRefresher {
shutdown_token,
network: CachedNetwork::new_empty(),
routing_filter: NetworkRoutingFilter::new_empty(testnet),
noise_view: NoiseNetworkView::new_empty(),
};
this.obtain_initial_network().await?;
@@ -282,7 +293,7 @@ impl NetworkRefresher {
// collect all known/allowed nodes information
let known_nodes = nodes
.iter()
.flat_map(|n| n.ip_addresses.iter())
.flat_map(|n| n.basic.ip_addresses.iter())
.copied()
.collect::<HashSet<_>>();
@@ -305,6 +316,22 @@ impl NetworkRefresher {
self.routing_filter.resolved.swap_denied(current_denied);
self.routing_filter.pending.clear().await;
//update noise Noise Nodes
let noise_nodes = nodes
.iter()
.filter(|n| n.x25519_noise_versioned_key.is_some())
.flat_map(|n| {
n.basic.ip_addresses.iter().map(|ip_addr| {
(
SocketAddr::new(*ip_addr, n.basic.mix_port),
#[allow(clippy::unwrap_used)]
n.x25519_noise_versioned_key.unwrap(), // SAFETY : we filtered out nodes where this option can be None
)
})
})
.collect::<HashMap<_, _>>();
self.noise_view.swap_view(noise_nodes);
let mut network_guard = self.network.inner.write().await;
network_guard.topology_metadata =
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id);
@@ -340,6 +367,10 @@ impl NetworkRefresher {
self.network.clone()
}
pub(crate) fn noise_view(&self) -> NoiseNetworkView {
self.noise_view.clone()
}
pub(crate) async fn run(&mut self) {
let mut full_refresh_interval = interval(self.full_refresh_interval);
full_refresh_interval.reset();
+1 -1
View File
@@ -177,7 +177,7 @@ impl ThroughputTestingClient {
// by tagging the packet
let shared_secret = private
.as_ref()
.as_ref()
.inner()
.diffie_hellman(&header.shared_secret);
let payload_key = rederive_lioness_payload_key(shared_secret.as_bytes());
+4 -3
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-statistics-api"
version = "0.1.0"
version = "0.1.1"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -39,7 +39,6 @@ utoipauto.workspace = true
#internal
nym-bin-common = { path = "../common/bin-common" }
nym-http-api-client = { path = "../common/http-api-client" }
nym-http-api-common = { path = "../common/http-api-common", features = [
"middleware",
] }
@@ -47,7 +46,9 @@ nym-statistics-common = { path = "../common/statistics", features = [
"openapi",
] }
nym-task = { path = "../common/task" }
nym-validator-client = { path = "../common/client-libs/validator-client" }
nym-http-api-client = { git = "https://github.com/nymtech/nym", branch = "release/2025.11-cheddar" }
nym-validator-client = { git = "https://github.com/nymtech/nym", branch = "release/2025.11-cheddar" } # current develop is incompatible with current deployed API
[build-dependencies]
anyhow = { workspace = true }
+1 -1
View File
@@ -33,7 +33,7 @@ async fn main() -> anyhow::Result<()> {
args.nym_api_url,
shutdown_manager.child_token("network-refresher"),
)
.await?;
.await;
let http_server =
http::server::build_http_api(storage, network_refresher.network_view(), args.http_port)
+22 -10
View File
@@ -71,15 +71,15 @@ impl NetworkRefresher {
pub(crate) async fn initialise_new(
maybe_nym_api_url: Option<Url>,
shutdown_token: ShutdownToken,
) -> Result<Self> {
) -> Self {
let node_querier = match maybe_nym_api_url {
Some(url) => Some(NodesQuerier {
client: nym_http_api_client::Client::builder::<_, anyhow::Error>(url)?
.no_hickory_dns()
.with_user_agent("node-statistics-api")
.build::<anyhow::Error>()?
.into(),
}),
Some(url) => match Self::build_http_api_client(url) {
Ok(client) => Some(NodesQuerier { client }),
Err(e) => {
warn!("Failed to build Nym API client, no network view will be availabe : {e}");
None
}
},
None => {
warn!("No Nym API specified, network view is unavailable");
None
@@ -93,8 +93,20 @@ impl NetworkRefresher {
network: NetworkView::new_empty(),
};
this.refresh_network_nodes().await?;
Ok(this)
if let Err(e) = this.refresh_network_nodes().await {
warn!("Failed to fetch initial network nodes : {e}");
}
this
}
fn build_http_api_client(url: Url) -> Result<NymApiClient> {
Ok(
nym_http_api_client::Client::builder::<_, anyhow::Error>(url)?
.no_hickory_dns()
.with_user_agent("node-statistics-api")
.build::<anyhow::Error>()?
.into(),
)
}
async fn refresh_network_nodes(&mut self) -> Result<()> {
+13 -1
View File
@@ -4032,6 +4032,7 @@ dependencies = [
"nym-mixnet-contract-common",
"nym-network-defaults",
"nym-node-requests",
"nym-noise-keys",
"nym-serde-helpers",
"nym-ticketbooks-merkle",
"schemars",
@@ -4272,7 +4273,7 @@ dependencies = [
name = "nym-network-defaults"
version = "0.1.0"
dependencies = [
"cargo_metadata 0.18.1",
"cargo_metadata 0.19.2",
"dotenvy",
"log",
"regex",
@@ -4294,6 +4295,7 @@ dependencies = [
"nym-crypto",
"nym-exit-policy",
"nym-http-api-client",
"nym-noise-keys",
"nym-wireguard-types",
"schemars",
"serde",
@@ -4304,6 +4306,16 @@ dependencies = [
"utoipa",
]
[[package]]
name = "nym-noise-keys"
version = "0.1.0"
dependencies = [
"nym-crypto",
"schemars",
"serde",
"utoipa",
]
[[package]]
name = "nym-pemstore"
version = "0.3.0"