Merge branch 'develop' into merge/release/2025.4-dorina

This commit is contained in:
Jędrzej Stuczyński
2025-03-04 11:00:24 +00:00
95 changed files with 3560 additions and 1424 deletions
@@ -26,6 +26,7 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- uses: actions/checkout@v4
@@ -12,6 +12,7 @@ jobs:
runs-on: arc-ubuntu-22.04
env:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Check out repository code
uses: actions/checkout@v4
+1
View File
@@ -37,6 +37,7 @@ jobs:
env:
CARGO_TERM_COLOR: always
IPINFO_API_TOKEN: ${{ secrets.IPINFO_API_TOKEN }}
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Install Dependencies (Linux)
run: sudo apt-get update && sudo apt-get -y install libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libudev-dev squashfs-tools protobuf-compiler
Generated
+778 -751
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -191,10 +191,10 @@ aes = "0.8.1"
aes-gcm = "0.10.1"
aes-gcm-siv = "0.11.1"
ammonia = "4"
anyhow = "1.0.95"
anyhow = "1.0.97"
arc-swap = "1.7.1"
argon2 = "0.5.0"
async-trait = "0.1.86"
async-trait = "0.1.87"
axum = "0.7.5"
axum-client-ip = "0.6.1"
axum-extra = "0.9.4"
@@ -205,7 +205,7 @@ bincode = "1.3.3"
bip39 = { version = "2.0.0", features = ["zeroize"] }
bit-vec = "0.7.0" # can we unify those?
bitvec = "1.0.0"
blake3 = "1.5.5"
blake3 = "1.6.1"
bloomfilter = "1.0.14"
bs58 = "0.5.1"
bytecodec = "0.4.15"
@@ -215,14 +215,14 @@ celes = "2.5.0"
cfg-if = "1.0.0"
chacha20 = "0.9.0"
chacha20poly1305 = "0.10.1"
chrono = "0.4.39"
chrono = "0.4.40"
cipher = "0.4.3"
clap = "4.5.30"
clap = "4.5.31"
clap_complete = "4.5"
clap_complete_fig = "4.5"
colored = "2.2"
comfy-table = "7.1.4"
console = "0.15.10"
console = "0.15.11"
console-subscriber = "0.1.1"
console_error_panic_hook = "0.1"
const-str = "0.5.6"
@@ -246,12 +246,12 @@ envy = "0.4"
etherparse = "0.13.0"
eyre = "0.6.9"
fastrand = "2.1.1"
flate2 = "1.0.35"
flate2 = "1.1.0"
futures = "0.3.31"
futures-util = "0.3"
generic-array = "0.14.7"
getrandom = "0.2.10"
getset = "0.1.4"
getset = "0.1.5"
handlebars = "3.5.5"
headers = "0.4.0"
hex = "0.4.3"
@@ -272,7 +272,7 @@ inquire = "0.6.2"
ip_network = "0.4.1"
ipnetwork = "0.20"
isocountry = "0.3.2"
itertools = "0.13.0"
itertools = "0.14.0"
k256 = "0.13"
lazy_static = "1.5.0"
ledger-transport = "0.10.0"
@@ -309,12 +309,12 @@ rocket_cors = "0.6.0"
rocket_okapi = "0.8.0"
rs_merkle = "1.4.2"
safer-ffi = "0.1.13"
schemars = "0.8.21"
schemars = "0.8.22"
semver = "1.0.25"
serde = "1.0.217"
serde_bytes = "0.11.15"
serde_bytes = "0.11.16"
serde_derive = "1.0"
serde_json = "1.0.138"
serde_json = "1.0.140"
serde_json_path = "0.7.2"
serde_repr = "0.1"
serde_with = "3.9.0"
@@ -20,8 +20,8 @@ use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::identity;
use nym_gateway_requests::registration::handshake::client_handshake;
use nym_gateway_requests::{
BinaryRequest, ClientControlRequest, ClientRequest, SensitiveServerResponse, ServerResponse,
SharedGatewayKey, SharedSymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION,
BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersionExt,
SensitiveServerResponse, ServerResponse, SharedGatewayKey, SharedSymmetricKey,
CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
};
use nym_sphinx::forwarding::packet::MixPacket;
@@ -204,15 +204,15 @@ impl<C, St> GatewayClient<C, St> {
"Attemting to establish connection to gateway at: {}",
self.gateway_address
);
let (ws_stream, _) = connect_async(&self.gateway_address).await?;
let (ws_stream, _) = connect_async(
&self.gateway_address,
#[cfg(unix)]
self.connection_fd_callback.clone(),
)
.await?;
self.connection = SocketState::Available(Box::new(ws_stream));
#[cfg(unix)]
if let (Some(callback), Some(fd)) = (self.connection_fd_callback.as_ref(), self.ws_fd()) {
callback.as_ref()(fd);
}
Ok(())
}
@@ -563,28 +563,10 @@ impl<C, St> GatewayClient<C, St> {
Ok(zeroizing_updated_key)
}
async fn authenticate(&mut self) -> Result<(), GatewayClientError> {
let Some(shared_key) = self.shared_key.as_ref() else {
return Err(GatewayClientError::NoSharedKeyAvailable);
};
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
debug!("authenticating with gateway");
let self_address = self
.local_identity
.as_ref()
.public_key()
.derive_destination_address();
let msg = ClientControlRequest::new_authenticate(
self_address,
shared_key,
self.cfg.bandwidth.require_tickets,
)?;
async fn send_authenticate_request_and_handle_response(
&mut self,
msg: ClientControlRequest,
) -> Result<(), GatewayClientError> {
match self.send_websocket_message(msg).await? {
ServerResponse::Authenticate {
protocol_version,
@@ -608,6 +590,51 @@ impl<C, St> GatewayClient<C, St> {
}
}
async fn authenticate_v1(&mut self) -> Result<(), GatewayClientError> {
debug!("using v1 authentication");
let Some(shared_key) = self.shared_key.as_ref() else {
return Err(GatewayClientError::NoSharedKeyAvailable);
};
let self_address = self
.local_identity
.public_key()
.derive_destination_address();
let msg = ClientControlRequest::new_authenticate(
self_address,
shared_key,
self.cfg.bandwidth.require_tickets,
)?;
self.send_authenticate_request_and_handle_response(msg)
.await
}
async fn authenticate_v2(&mut self) -> Result<(), GatewayClientError> {
debug!("using v2 authentication");
let Some(shared_key) = self.shared_key.as_ref() else {
return Err(GatewayClientError::NoSharedKeyAvailable);
};
let msg = ClientControlRequest::new_authenticate_v2(shared_key, &self.local_identity)?;
self.send_authenticate_request_and_handle_response(msg)
.await
}
async fn authenticate(&mut self, use_v2: bool) -> Result<(), GatewayClientError> {
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
debug!("authenticating with gateway");
if use_v2 {
self.authenticate_v2().await
} else {
self.authenticate_v1().await
}
}
/// Helper method to either call register or authenticate based on self.shared_key value
#[instrument(skip_all,
fields(
@@ -623,19 +650,25 @@ impl<C, St> GatewayClient<C, St> {
}
// 1. check gateway's protocol version
let supports_aes_gcm_siv = match self.get_gateway_protocol().await {
Ok(protocol) => protocol >= AES_GCM_SIV_PROTOCOL_VERSION,
let gw_protocol = match self.get_gateway_protocol().await {
Ok(protocol) => Some(protocol),
Err(_) => {
// if we failed to send the request, it means the gateway is running the old binary,
// so it has reset our connection - we have to reconnect
self.establish_connection().await?;
false
None
}
};
let supports_aes_gcm_siv = gw_protocol.supports_aes256_gcm_siv();
let supports_auth_v2 = gw_protocol.supports_authenticate_v2();
if !supports_aes_gcm_siv {
warn!("this gateway is on an old version that doesn't support AES256-GCM-SIV");
}
if !supports_auth_v2 {
warn!("this gateway is on an old version that doesn't support authentication v2")
}
if self.authenticated {
debug!("Already authenticated");
@@ -650,7 +683,7 @@ impl<C, St> GatewayClient<C, St> {
}
if self.shared_key.is_some() {
self.authenticate().await?;
self.authenticate(supports_auth_v2).await?;
if self.authenticated {
// if we are authenticated it means we MUST have an associated shared_key
@@ -983,7 +1016,8 @@ impl<C, St> GatewayClient<C, St> {
}
// if we're reconnecting, because we lost connection, we need to re-authenticate the connection
self.authenticate().await?;
self.authenticate(self.negotiated_protocol.supports_authenticate_v2())
.await?;
// this call is NON-blocking
self.start_listening_for_mixnet_messages()?;
@@ -1,6 +1,11 @@
use crate::error::GatewayClientError;
use nym_http_api_client::HickoryDnsResolver;
#[cfg(unix)]
use std::{
os::fd::{AsRawFd, RawFd},
sync::Arc,
};
use tokio::net::TcpStream;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use tungstenite::handshake::client::Response;
@@ -11,7 +16,10 @@ use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn connect_async(
endpoint: &str,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), GatewayClientError> {
use tokio::net::TcpSocket;
let resolver = HickoryDnsResolver::default();
let uri =
Url::parse(endpoint).map_err(|_| GatewayClientError::InvalidUrl(endpoint.to_owned()))?;
@@ -37,14 +45,41 @@ pub(crate) async fn connect_async(
}
};
let stream = TcpStream::connect(&sock_addrs[..]).await.map_err(|error| {
GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
source: error.into(),
let mut stream = Err(GatewayClientError::NoEndpointForConnection {
address: endpoint.to_owned(),
});
for sock_addr in sock_addrs {
let socket = if sock_addr.is_ipv4() {
TcpSocket::new_v4()
} else {
TcpSocket::new_v6()
}
})?;
.map_err(|err| GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
source: err.into(),
})?;
tokio_tungstenite::client_async_tls(endpoint, stream)
#[cfg(unix)]
if let Some(callback) = connection_fd_callback.as_ref() {
callback.as_ref()(socket.as_raw_fd());
}
match socket.connect(sock_addr).await {
Ok(s) => {
stream = Ok(s);
break;
}
Err(err) => {
stream = Err(GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
source: err.into(),
});
continue;
}
}
}
tokio_tungstenite::client_async_tls(endpoint, stream?)
.await
.map_err(|error| GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
@@ -43,6 +43,9 @@ pub enum GatewayClientError {
#[error("connection failed: {address}: {source}")]
NetworkConnectionFailed { address: String, source: WsError },
#[error("no socket address for endpoint: {address}")]
NoEndpointForConnection { address: String },
#[error("Invalid URL: {0}")]
InvalidUrl(String),
@@ -23,11 +23,12 @@ use nym_api_requests::models::{
NymNodeDescription, RewardEstimationResponse, StakeSaturationResponse,
};
use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated};
use nym_api_requests::nym_nodes::SkimmedNode;
use nym_api_requests::nym_nodes::{NodesByAddressesResponse, SkimmedNode};
use nym_coconut_dkg_common::types::EpochId;
use nym_ecash_contract_common::deposit::DepositId;
use nym_http_api_client::UserAgent;
use nym_network_defaults::NymNetworkDetails;
use std::net::IpAddr;
use time::Date;
use url::Url;
@@ -710,4 +711,11 @@ impl NymApiClient {
.issued_ticketbooks_challenge(expiration_date, deposits)
.await?)
}
pub async fn nodes_by_addresses(
&self,
addresses: Vec<IpAddr>,
) -> Result<NodesByAddressesResponse, ValidatorClientError> {
Ok(self.nym_api.nodes_by_addresses(addresses).await?)
}
}
@@ -15,7 +15,9 @@ use nym_api_requests::models::{
AnnotationResponse, ApiHealthResponse, LegacyDescribedMixNode, NodePerformanceResponse,
NodeRefreshBody, NymNodeDescription, PerformanceHistoryResponse, RewardedSetResponse,
};
use nym_api_requests::nym_nodes::PaginatedCachedNodesResponse;
use nym_api_requests::nym_nodes::{
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponse,
};
use nym_api_requests::pagination::PaginatedResponse;
pub use nym_api_requests::{
ecash::{
@@ -40,6 +42,7 @@ pub use nym_http_api_client::Client;
use nym_http_api_client::{ApiClient, NO_PARAMS};
use nym_mixnet_contract_common::mixnode::MixNodeDetails;
use nym_mixnet_contract_common::{GatewayBond, IdentityKeyRef, NodeId, NymNodeDetails};
use std::net::IpAddr;
use time::format_description::BorrowedFormatItem;
use time::Date;
use tracing::instrument;
@@ -1015,6 +1018,23 @@ pub trait NymApiClientExt: ApiClient {
.await
}
async fn nodes_by_addresses(
&self,
addresses: Vec<IpAddr>,
) -> Result<NodesByAddressesResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
routes::nym_nodes::BY_ADDRESSES,
],
NO_PARAMS,
&NodesByAddressesRequestBody { addresses },
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_network_details(&self) -> Result<NymNetworkDetailsResponse, NymAPIError> {
self.get_json(
@@ -43,6 +43,7 @@ pub mod nym_nodes {
pub const NYM_NODES_BONDED: &str = "bonded";
pub const NYM_NODES_REWARDED_SET: &str = "rewarded-set";
pub const NYM_NODES_REFRESH_DESCRIBED: &str = "refresh-described";
pub const BY_ADDRESSES: &str = "by-addresses";
}
pub const STATUS_ROUTES: &str = "status";
-9
View File
@@ -25,15 +25,6 @@ pub fn in6addr_any_init() -> IpAddr {
IpAddr::V6(Ipv6Addr::UNSPECIFIED)
}
/// Helper for providing binding warnings if node tries to bind to any of those
pub const SPECIAL_ADDRESSES: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V4(Ipv4Addr::BROADCAST),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
];
// TODO: is it really part of 'Config'?
pub trait OptionalSet {
/// If the value is available (i.e. `Some`), the provided closure is applied.
+3
View File
@@ -20,11 +20,14 @@ serde_json = { workspace = true }
strum = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true, features = ["log"] }
time = { workspace = true }
subtle = { workspace = true }
zeroize = { workspace = true }
nym-crypto = { path = "../crypto", features = ["aead", "hashing"] }
nym-pemstore = { path = "../pemstore" }
nym-sphinx = { path = "../nymsphinx" }
nym-serde-helpers = { path = "../serde-helpers", features = ["base64"] }
nym-task = { path = "../task" }
nym-credentials = { path = "../credentials" }
@@ -15,6 +15,12 @@ use thiserror::Error;
// this is no longer constant size due to the differences in ciphertext between aes128ctr and aes256gcm-siv (inclusion of tag)
pub struct EncryptedAddressBytes(Vec<u8>);
impl From<Vec<u8>> for EncryptedAddressBytes {
fn from(encrypted_address: Vec<u8>) -> Self {
EncryptedAddressBytes(encrypted_address)
}
}
#[derive(Debug, Error)]
pub enum EncryptedAddressConversionError {
#[error("Failed to decode the encrypted address - {0}")]
+20 -1
View File
@@ -19,7 +19,7 @@ pub use shared_key::{
SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey,
};
pub const CURRENT_PROTOCOL_VERSION: u8 = AES_GCM_SIV_PROTOCOL_VERSION;
pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION;
/// Defines the current version of the communication protocol between gateway and clients.
/// It has to be incremented for any breaking change.
@@ -27,10 +27,29 @@ pub const CURRENT_PROTOCOL_VERSION: u8 = AES_GCM_SIV_PROTOCOL_VERSION;
// 1 - initial release
// 2 - changes to client credentials structure
// 3 - change to AES-GCM-SIV and non-zero IVs
// 4 - introduction of v2 authentication protocol to prevent reply attacks
pub const INITIAL_PROTOCOL_VERSION: u8 = 1;
pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: u8 = 2;
pub const AES_GCM_SIV_PROTOCOL_VERSION: u8 = 3;
pub const AUTHENTICATE_V2_PROTOCOL_VERSION: u8 = 4;
// TODO: could using `Mac` trait here for OutputSize backfire?
// Should hmac itself be exposed, imported and used instead?
pub type LegacyGatewayMacSize = <GatewayIntegrityHmacAlgorithm as OutputSizeUser>::OutputSize;
pub trait GatewayProtocolVersionExt {
fn supports_aes256_gcm_siv(&self) -> bool;
fn supports_authenticate_v2(&self) -> bool;
}
impl GatewayProtocolVersionExt for Option<u8> {
fn supports_aes256_gcm_siv(&self) -> bool {
let Some(protocol) = *self else { return false };
protocol >= AES_GCM_SIV_PROTOCOL_VERSION
}
fn supports_authenticate_v2(&self) -> bool {
let Some(protocol) = *self else { return false };
protocol >= AUTHENTICATE_V2_PROTOCOL_VERSION
}
}
@@ -3,6 +3,7 @@
use crate::SharedKeyUsageError;
use nym_credentials_interface::CompactEcashError;
use nym_crypto::asymmetric::ed25519::SignatureError;
use nym_sphinx::addressing::nodes::NymNodeRoutingAddressError;
use nym_sphinx::forwarding::packet::MixPacketFormattingError;
use nym_sphinx::params::packet_sizes::PacketSize;
@@ -92,7 +93,34 @@ pub enum GatewayRequestsError {
#[error("the provided [v1] credential has invalid number of parameters - {0}")]
InvalidNumberOfEmbededParameters(u32),
#[error("failed to authenticate the client: {0}")]
Authentication(#[from] AuthenticationFailure),
// variant to catch legacy errors
#[error("{0}")]
Other(String),
}
#[derive(Debug, Error)]
pub enum AuthenticationFailure {
#[error(transparent)]
KeyUsageFailure(#[from] SharedKeyUsageError),
#[error("failed to verify provided address ciphertext")]
MalformedCiphertext,
#[error("failed to verify request signature")]
InvalidSignature(#[from] SignatureError),
#[error("provided request timestamp is in the future")]
RequestTimestampInFuture,
#[error("the client is not registered")]
NotRegistered,
#[error("the provided request is too stale to process")]
StaleRequest,
#[error("the provided request timestamp is smaller or equal to a one previously used")]
RequestReuse,
}
@@ -0,0 +1,142 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::{AuthenticationFailure, GatewayRequestsError, SharedGatewayKey};
use nym_crypto::asymmetric::ed25519;
use serde::{Deserialize, Serialize};
use std::iter;
use std::time::Duration;
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticateRequest {
#[serde(flatten)]
pub content: AuthenticateRequestContent,
pub request_signature: ed25519::Signature,
}
impl AuthenticateRequest {
pub fn new(
protocol_version: u8,
shared_key: &SharedGatewayKey,
identity_keys: &ed25519::KeyPair,
) -> Result<AuthenticateRequest, GatewayRequestsError> {
let content = AuthenticateRequestContent::new(
protocol_version,
shared_key,
*identity_keys.public_key(),
)?;
let plaintext = content.plaintext();
let request_signature = identity_keys.private_key().sign(&plaintext);
Ok(AuthenticateRequest {
content,
request_signature,
})
}
pub fn verify_timestamp(&self, max_request_age: Duration) -> Result<(), AuthenticationFailure> {
let now = OffsetDateTime::now_utc();
if self.content.request_timestamp() + max_request_age < now {
return Err(AuthenticationFailure::StaleRequest);
}
if self.content.request_timestamp() > now {
return Err(AuthenticationFailure::RequestTimestampInFuture);
}
Ok(())
}
pub fn ensure_timestamp_not_reused(
&self,
previous: OffsetDateTime,
) -> Result<(), AuthenticationFailure> {
if self.content.request_timestamp() <= previous {
return Err(AuthenticationFailure::RequestReuse);
}
Ok(())
}
pub fn verify_ciphertext(
&self,
shared_key: &SharedGatewayKey,
) -> Result<(), AuthenticationFailure> {
let expected = shared_key.encrypt(
self.content
.client_identity
.derive_destination_address()
.as_bytes_ref(),
Some(&self.content.nonce),
)?;
if !bool::from(expected.ct_eq(&self.content.address_ciphertext)) {
return Err(AuthenticationFailure::MalformedCiphertext);
}
Ok(())
}
pub fn verify_signature(&self) -> Result<(), AuthenticationFailure> {
let plaintext = self.content.plaintext();
self.content
.client_identity
.verify(plaintext, &self.request_signature)
.map_err(Into::into)
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticateRequestContent {
pub protocol_version: u8,
// this is identical to the client's address
pub client_identity: ed25519::PublicKey,
#[serde(with = "nym_serde_helpers::base64")]
pub address_ciphertext: Vec<u8>,
#[serde(with = "nym_serde_helpers::base64")]
pub nonce: Vec<u8>,
pub request_unix_timestamp: u64,
}
impl AuthenticateRequestContent {
fn new(
protocol_version: u8,
shared_key: &SharedGatewayKey,
client_identity: ed25519::PublicKey,
) -> Result<AuthenticateRequestContent, GatewayRequestsError> {
let nonce = shared_key.random_nonce_or_iv();
let destination_address = client_identity.derive_destination_address();
let address_ciphertext =
shared_key.encrypt(destination_address.as_bytes_ref(), Some(&nonce))?;
let now = OffsetDateTime::now_utc();
Ok(AuthenticateRequestContent {
protocol_version,
client_identity,
address_ciphertext,
nonce,
request_unix_timestamp: now.unix_timestamp() as u64, // SAFETY: we're running this in post 1970...
})
}
}
impl AuthenticateRequestContent {
pub fn plaintext(&self) -> Vec<u8> {
iter::once(self.protocol_version)
.chain(self.client_identity.to_bytes())
.chain(self.address_ciphertext.iter().copied())
.chain(self.nonce.iter().copied())
.chain(self.request_unix_timestamp.to_be_bytes())
.collect()
}
pub fn request_timestamp(&self) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(self.request_unix_timestamp as i64)
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
}
}
@@ -2,16 +2,21 @@
// SPDX-License-Identifier: Apache-2.0
use crate::models::CredentialSpendingRequest;
use crate::text_request::authenticate::AuthenticateRequest;
use crate::{
GatewayRequestsError, SharedGatewayKey, SymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION,
CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION,
AUTHENTICATE_V2_PROTOCOL_VERSION, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION,
INITIAL_PROTOCOL_VERSION,
};
use nym_credentials_interface::CredentialSpendingData;
use nym_crypto::asymmetric::ed25519;
use nym_sphinx::DestinationAddressBytes;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use tungstenite::Message;
pub mod authenticate;
// wrapper for all encrypted requests for ease of use
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
@@ -68,6 +73,9 @@ pub enum ClientControlRequest {
enc_address: String,
iv: String,
},
AuthenticateV2(Box<AuthenticateRequest>),
#[serde(alias = "handshakePayload")]
RegisterHandshakeInitRequest {
#[serde(default)]
@@ -123,9 +131,22 @@ impl ClientControlRequest {
})
}
pub fn new_authenticate_v2(
shared_key: &SharedGatewayKey,
identity_keys: &ed25519::KeyPair,
) -> Result<Self, GatewayRequestsError> {
// if we're using v2 authentication, we must announce at least that protocol version
let protocol_version = AUTHENTICATE_V2_PROTOCOL_VERSION;
Ok(ClientControlRequest::AuthenticateV2(Box::new(
AuthenticateRequest::new(protocol_version, shared_key, identity_keys)?,
)))
}
pub fn name(&self) -> String {
match self {
ClientControlRequest::Authenticate { .. } => "Authenticate".to_string(),
ClientControlRequest::AuthenticateV2(..) => "AuthenticateV2".to_string(),
ClientControlRequest::RegisterHandshakeInitRequest { .. } => {
"RegisterHandshakeInitRequest".to_string()
}
@@ -0,0 +1,7 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: GPL-3.0-only
*/
ALTER TABLE shared_keys
ADD COLUMN last_used_authentication TIMESTAMP WITHOUT TIME ZONE;
+14
View File
@@ -200,6 +200,20 @@ impl GatewayStorage {
Ok(())
}
pub async fn update_last_used_authentication_timestamp(
&self,
client_id: i64,
last_used_authentication_timestamp: OffsetDateTime,
) -> Result<(), GatewayStorageError> {
self.shared_key_manager
.update_last_used_authentication_timestamp(
client_id,
last_used_authentication_timestamp,
)
.await?;
Ok(())
}
pub async fn get_client(&self, client_id: i64) -> Result<Option<Client>, GatewayStorageError> {
let client = self.client_manager.get_client(client_id).await?;
Ok(client)
+1 -1
View File
@@ -14,13 +14,13 @@ pub struct Client {
#[derive(FromRow)]
pub struct PersistedSharedKeys {
#[allow(dead_code)]
pub client_id: i64,
#[allow(dead_code)]
pub client_address_bs58: String,
pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option<String>,
pub derived_aes256_gcm_siv_key: Option<Vec<u8>>,
pub last_used_authentication: Option<OffsetDateTime>,
}
impl TryFrom<PersistedSharedKeys> for SharedGatewayKey {
+21 -7
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::models::PersistedSharedKeys;
use time::OffsetDateTime;
#[derive(Clone)]
pub(crate) struct SharedKeysManager {
@@ -68,6 +69,22 @@ impl SharedKeysManager {
Ok(())
}
pub(crate) async fn update_last_used_authentication_timestamp(
&self,
client_id: i64,
last_used: OffsetDateTime,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE shared_keys SET last_used_authentication = ? WHERE client_id = ?;",
last_used,
client_id
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
/// Tries to retrieve shared keys stored for the particular client.
///
/// # Arguments
@@ -77,13 +94,10 @@ impl SharedKeysManager {
&self,
client_address_bs58: &str,
) -> Result<Option<PersistedSharedKeys>, sqlx::Error> {
sqlx::query_as!(
PersistedSharedKeys,
"SELECT * FROM shared_keys WHERE client_address_bs58 = ?",
client_address_bs58
)
.fetch_optional(&self.connection_pool)
.await
sqlx::query_as("SELECT * FROM shared_keys WHERE client_address_bs58 = ?")
.bind(client_address_bs58)
.fetch_optional(&self.connection_pool)
.await
}
/// Removes from the database shared keys derived with the particular client.
+6
View File
@@ -161,6 +161,12 @@ impl From<NymNodeRoutingAddress> for SocketAddr {
}
}
impl AsRef<SocketAddr> for NymNodeRoutingAddress {
fn as_ref(&self) -> &SocketAddr {
&self.0
}
}
impl TryInto<NodeAddressBytes> for NymNodeRoutingAddress {
type Error = NymNodeRoutingAddressError;
+9
View File
@@ -254,6 +254,15 @@ impl NymTopology {
}
}
pub fn with_additional_nodes<N>(mut self, nodes: impl Iterator<Item = N>) -> Self
where
N: TryInto<RoutingNode>,
<N as TryInto<RoutingNode>>::Error: Display,
{
self.add_additional_nodes(nodes);
self
}
pub fn has_node_details(&self, node_id: NodeId) -> bool {
self.node_details.contains_key(&node_id)
}
+5 -5
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "ahash"
@@ -1470,9 +1470,9 @@ checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
[[package]]
name = "schemars"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive",
@@ -1482,9 +1482,9 @@ dependencies = [
[[package]]
name = "schemars_derive"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
@@ -1 +1 @@
Monday, February 3rd 2025, 13:47:19 UTC
Wednesday, February 26th 2025, 16:02:47 UTC
@@ -16,8 +16,10 @@ Options:
If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env: NYMNODE_INIT_ONLY=]
--local
Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode <MODE>
Specifies the current mode of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway]
--mode [<MODE>...]
Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
--modes <MODES>
Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
-w, --write-changes
If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT>
@@ -31,7 +33,7 @@ Options:
--location <LOCATION>
Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS>
Socket address this node will use for binding its http API. default: `0.0.0.0:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH>
Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN>
@@ -43,27 +45,29 @@ Options:
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE>
Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--mixnet-bind-address <MIXNET_BIND_ADDRESS>
Address this node will bind to for listening for mixnet packets default: `0.0.0.0:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS>
Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--nyxd-urls <NYXD_URLS>
Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING>
Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED>
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `0.0.0.0:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-announced-port <WIREGUARD_ANNOUNCED_PORT>
Port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--verloc-bind-address <VERLOC_BIND_ADDRESS>
Socket address this node will use for binding its verloc API. default: `0.0.0.0:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS>
Socket address this node will use for binding its client websocket API. default: `0.0.0.0:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=]
Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT>
Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT>
@@ -0,0 +1,746 @@
import { Callout } from 'nextra/components';
import { Tabs } from 'nextra/components';
import { VarInfo } from 'components/variable-info.tsx';
import { Steps } from 'nextra/components';
import {Accordion, AccordionItem} from "@nextui-org/react";
import { MyTab } from 'components/generic-tabs.tsx';
import { AccordionTemplate } from 'components/accordion-template.tsx';
# Advanced Server Administration
This page is for experienced operators and aspiring sys-admins who seek for higher optimisation and better efficiency of their work managing Nym infrastructure. The steps shared on this page cannot be simply copy-pasted, they ask you for more attention and consideration all the way from choosing server and OS to specs per VM allocation.
<VarInfo />
## Virtualising a Dedicated Server
Some operators or squads of operators orchestrate multiple Nym nodes. Among other benefits (which are out of scope of this page), these operators can decide to acquire one larger dedicated (or bare-metal) server with enough specs (CPU, RAM, storage, bandwidth and port speed) to meet [minimum requirements](../../../nodes#minimum-requirements) for multiple nodes run in parallel.
This guide explains how to prepare your server in order to be able to host multiple nodes running on separated VMs.
<Callout type="info">
This guide is based on Ubuntu 22.04, in case you prefer another OS, you may have to do a bit of your own research to troubleshoot networking configuration and other parameters.
</Callout>
### Installing KVM on a Server with Ubuntu 22.04
**KVM** stands for **Kernel-based Virtual Machine**. It is a virtualization technology for Linux that allows a user to run multiple virtual machines (VMs) on a single physical machine. KVM turns the Linux kernel into a hypervisor, enabling it to manage multiple virtualised systems.
Follow the steps below to install KVM on Ubuntu 22.04 LTS.
#### Prerequisites
<Callout type="warning">
Operators aiming to run Nym node as mixnet [Exit Gateway](../../../community-counsel/exit-gateway) or with wireguard enabled should familiarize themselves with the challenges possibly coming along `nym-node` operation, described in our [community counsel](../../../community-counsel) and follow up with [legal suggestions](../../../community-counsel/legal). Particularly important is to [introduce yourself](../../../community-counsel/legal#introduce-nym-node-to-your-provider) and your intentions to run a Nym node to your provider.
This step is essential part of legal self defense because it may prevent your provider immediately shutting down your entire service (with all the VMs on it) when receiving first abuse report.
Additionally, before purchasing a large server, **contact the provider and ask if the offered CPU supports Virtualization Technology (VT)**, without this feature you will not be able to proceed.
</Callout>
Start with obtaining a server with Ubuntu 22.04 LTS:
- Make sure that your server meets [minimum requirements](../vps-setup#nym-node---dedicated-server) multiplied by number of `nym-node` instance you aim to run on it.
- Most people rent a server from a provider and it comes with a pre-installed OS (in this guide we use Ubuntu 22.04). In case your choice is a bare-metal machine, you probably know what you are doing, there are some useful guides to install a new OS, like [this one on ostechnix.com](https://ostechnix.com/install-ubuntu-server/).
Make sure thay your system actually supports hardware virtualisation:
- Check out the methods documented in [this guide by ostechnix.com](https://ostechnix.com/how-to-find-if-a-cpu-supports-virtualization-technology-vt/).
Order enough IPv4 and IPv6 (static and public) addresses to have one of each for each planned VM plus one extra for the main machine.
When you have your OS installed, validated CPU virtualisation support and obtained IP addresses, you can start configuring your VMs, following the steps below.
> Note that the commands below require root permission. You can either go through the setup as `root` or use `sudo` prefix with the commands used in the guide. You can switch to `root` shell by entering one of these commands `sudo su` or `sudo -i`.
<Steps>
##### 1. Install KVM
- Install KVM and required components:
```sh
apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virtinst
```
<br/>
<AccordionTemplate name="Component breakdown">
- `qemu-kvm`: Provides the core **KVM virtualization** support using QEMU.
- `libvirt-daemon-system`: Manages virtual machines via the **libvirt daemon**.
- `libvirt-clients` Provides command-line tools like `virsh` to manage VMs.
- `bridge-utils`: Enables **network bridging**, allowing VMs to communicate over the network.
- `virtinst`: Includes `virt-install` for **creating virtual machines** via CLI.
</AccordionTemplate>
- Start the `libvertd` service:
```sh
systemctl enable libvirtd
systemctl start libvirtd
```
- Validate by checking status of `libvirt` service:
```sh
systemctl status libvirtd
```
<br/>
<AccordionTemplate name="Console output">
The command output should look similar to this one:
```
root@nym-exit:~# systemctl status libvirtd
● libvirtd.service - Virtualization daemon
Loaded: loaded (/lib/systemd/system/libvirtd.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2025-02-27 14:25:28 MSK; 2min 1s ago
TriggeredBy: ● libvirtd-ro.socket
● libvirtd.socket
● libvirtd-admin.socket
Docs: man:libvirtd(8)
https://libvirt.org
Main PID: 6232 (libvirtd)
Tasks: 21 (limit: 32768)
Memory: 11.8M
CPU: 852ms
CGroup: /system.slice/libvirtd.service
├─6232 /usr/sbin/libvirtd
├─6460 /usr/sbin/dnsmasq --conf-file=/var/lib/libvirt/dnsmasq/default.conf --leasefile-ro --dhcp-script=/usr/lib/libvirt/libvirt_leaseshelper
└─6461 /usr/sbin/dnsmasq --conf-file=/var/lib/libvirt/dnsmasq/default.conf --leasefile-ro --dhcp-script=/usr/lib/libvirt/libvirt_leaseshelper
Feb 27 14:25:28 nym-exit.example.com systemd[1]: Started Virtualization daemon.
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: started, version 2.90 cachesize 150
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: compile time options: IPv6 GNU-getopt DBus no-UBus i18n IDN2 DHCP DHCPv6 no-Lua TFTP conntrack ipset no-nftset auth cryptohash DNSSEC loop-detect inotify dump>
Feb 27 14:25:30 nym-exit.example.com dnsmasq-dhcp[6460]: DHCP, IP range 192.168.122.2 -- 192.168.122.254, lease time 1h
Feb 27 14:25:30 nym-exit.example.com dnsmasq-dhcp[6460]: DHCP, sockets bound exclusively to interface virbr0
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: reading /etc/resolv.conf
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: using nameserver 127.0.0.53#53
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: read /etc/hosts - 8 names
Feb 27 14:25:30 nym-exit.example.com dnsmasq[6460]: read /var/lib/libvirt/dnsmasq/default.addnhosts - 0 names
Feb 27 14:25:30 nym-exit.example.com dnsmasq-dhcp[6460]: read /var/lib/libvirt/dnsmasq/default.hostsfile
```
</AccordionTemplate>
- In case you don't configure KVM as `root`, add your current user to the `kvm` and `libvirt` groups to enable VM creation and management using the `virsh` command-line tool or the `virt-manager` GUI:
```bash
usermod -aG kvm $USER
usermod -aG libvirt $USER
```
##### 2. Setup Bridge Networking with KVM
A **bridged network** lets VMs share the hosts network interface, allowing direct IPv4/IPv6 access like a physical machine.
By default, KVM sets up a **private virtual bridge**, enabling VM-to-VM communication within the host. It provides its own subnet, DHCP, and NAT for external access.
Check the IP of KVMs default virtual interfaces with:
```bash
ip a
```
<br/>
<AccordionTemplate name="Console output">
The command output should look similar to this one:
```
root@nym-exit:~# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eno1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:14 brd ff:ff:ff:ff:ff:ff
altname enp2s0f0
3: eno49: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
link/ether 38:63:bb:2e:9d:20 brd ff:ff:ff:ff:ff:ff
altname enp4s0f0
inet 31.222.238.222/24 brd 31.222.238.255 scope global eno49
valid_lft forever preferred_lft forever
inet6 fe80::3a63:bbff:fe2e:9d20/64 scope link
valid_lft forever preferred_lft forever
4: eno2: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:15 brd ff:ff:ff:ff:ff:ff
altname enp2s0f1
5: eno3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:16 brd ff:ff:ff:ff:ff:ff
altname enp2s0f2
6: eno50: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 38:63:bb:2e:9d:24 brd ff:ff:ff:ff:ff:ff
altname enp4s0f1
7: eno4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:17 brd ff:ff:ff:ff:ff:ff
altname enp2s0f3
8: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
link/ether 52:54:00:ac:d3:ba brd ff:ff:ff:ff:ff:ff
inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
valid_lft forever preferred_lft forever
```
</AccordionTemplate>
By default, KVM uses the `virbr0` network with `<IPv4_ADDRESS>.1/24`, assigning guest VMs IPs in the `<IPv4_ADDRESS>.0/24` range. The host OS is reachable at `<IPv4_ADDRESS>.1`, allowing SSH and file transfers (`scp`) between the host and guests.
This setup works if you only access VMs from the host. However, remote systems on a different subnet (e.g., `<IPv4_ADDRESS_ALT>.0/24`) **cannot** reach the VMs.
To enable external access, we need a *public bridge* that connects VMs to the hosts main network, using its DHCP. This ensures VMs get IPs in the same range as the host.
Before configuring a public bridge, **disable Netfilter** on bridges for better performance and security, as it is enabled by default.
- Create a file located at `/etc/sysctl.d/bridge.conf`:
```bash
nano /etc/sysctl.d/bridge.conf
# in case of using custom editor, replace nano in the syntax
```
- Paste inside the following block, save and exit:
```ini
net.bridge.bridge-nf-call-ip6tables=0
net.bridge.bridge-nf-call-iptables=0
net.bridge.bridge-nf-call-arptables=0
```
- Create a file `/etc/udev/rules.d/99-bridge.rules`:
```bash
nano /etc/udev/rules.d/99-bridge.rules
```
- Paste this line, save and exit:
```bash
ACTION=="add", SUBSYSTEM=="module", KERNEL=="br_netfilter", RUN+="/sbin/sysctl -p /etc/sysctl.d/bridge.conf"
```
This disables Netfilter on bridges at startup. Save, exit, and reboot to apply changes.
- Disable KVMs default networking. Find the default network interface with:
```bash
ip link
```
<br/>
<AccordionTemplate name="Console output">
The command output should look similar to this one:
```
root@nym-exit:~# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eno1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:14 brd ff:ff:ff:ff:ff:ff
altname enp2s0f0
3: eno2: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:15 brd ff:ff:ff:ff:ff:ff
altname enp2s0f1
4: eno49: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
link/ether 38:63:bb:2e:9d:20 brd ff:ff:ff:ff:ff:ff
altname enp4s0f0
5: eno3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:16 brd ff:ff:ff:ff:ff:ff
altname enp2s0f2
6: eno50: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 38:63:bb:2e:9d:24 brd ff:ff:ff:ff:ff:ff
altname enp4s0f1
7: eno4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:17 brd ff:ff:ff:ff:ff:ff
altname enp2s0f3
8: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default qlen 1000
link/ether 52:54:00:ac:d3:ba brd ff:ff:ff:ff:ff:ff
```
The `virbr0` interface is KVMs default network. Note your physical interfaces MAC address (e.g., `eno49`). It's the only interface that is currently `UP` and running (`LOWER_UP` state). Other interfaces are `DOWN` and not in use.
</AccordionTemplate>
- Remove the default KVM network:
```bash
virsh net-destroy default
```
- Remove the default network configuration:
```bash
virsh net-undefine default
```
- In case last two commands didn't work, try this:
```bash
ip link delete virbr0 type bridge
```
- Verify that the `virbr0` and `virbr0-nic` interfaces are deleted:
```bash
ip link
```
<AccordionTemplate name="Console output">
The command output should look similar to this one:
```
root@nym-exit:~# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eno1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:14 brd ff:ff:ff:ff:ff:ff
altname enp2s0f0
3: eno2: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:15 brd ff:ff:ff:ff:ff:ff
altname enp2s0f1
4: eno49: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
link/ether 38:63:bb:2e:9d:20 brd ff:ff:ff:ff:ff:ff
altname enp4s0f0
5: eno3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:16 brd ff:ff:ff:ff:ff:ff
altname enp2s0f2
6: eno50: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 38:63:bb:2e:9d:24 brd ff:ff:ff:ff:ff:ff
altname enp4s0f1
7: eno4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 14:02:ec:35:2e:17 brd ff:ff:ff:ff:ff:ff
altname enp2s0f3
```
KVM network is gone.
</AccordionTemplate>
##### 3. Setup KVM public bridge for new VMs
To create a KVM network bridge on Ubuntu, edit a config file located in `/etc/netplan/` either called `00-installer.yaml` or `00-installer-config.yaml` and add the bridge details.
- Before you edit the file, make a backup to stay on the save side:
```bash
cp /etc/netplan/00-installer-config.yaml /etc/netplan/00-installer-config.yaml.bak
# or
cp /etc/netplan/00-installer.yaml /etc/netplan/00-installer.yaml.bak
```
- Open `00-installer-config.yaml` or `00-installer.yaml.`config in a text editor:
```bash
nano /etc/netplan/00-installer.yaml
# or
nano /etc/netplan/00-installer-config.yaml
```
- Edit the block below and paste it to the config file, save and exit:
```ini
#####################################################
######## CHANGE ALL VARIABLES IN <> BRACKETS ########
#####################################################
# <INTERFACE> is your own one, you can get with command ip link show
# <HOST> is your server main IPv4 address
# <GATEWAY> value can be found by running: ip route | grep default
# This is the network config written by 'subiquity'
network:
version: 2
ethernets:
<INTERFACE>:
dhcp4: false
dhcp6: false
# Bridge interface configuration
bridges:
br0:
interfaces: [<INTERFACE>]
addresses: [<HOST>/24]
routes:
- to: default
via: <GATEWAY>
mtu: 1500
nameservers:
addresses:
- 8.8.8.8
- 1.1.1.1
- 77.88.8.8
parameters:
stp: false # Disable STP unless multiple bridges exist
forward-delay: 15 # Can be shortened, 15 sec is a common default
```
<Callout type="warning">
Ensure the indentation matches exactly as shown above. Incorrect spacing will prevent the bridged network interface from activating.
</Callout>
- Validate `netplan` configuration without applying to prevent breaking network changes:
```bash
netplan generate
# Correct configuration output will show nothing
```
- Safety test your changes to catch syntax errors before applying:
```bash
netplan try
```
- Apply your changes:
```bash
netplan --debug apply
```
- In case of proubems try some of these steps:
<AccordionTemplate name="Netplan configuration troubleshooting">
- Validate YAML configuration, given that YAML is syntax sensitive:
```bash
apt install yamllint -y
yamllint /etc/netplan/00-installer.yaml
# or
yamllint /etc/netplan/00-installer-config.yaml
```
- Apply correct permissions:
```bash
chmod 600 /etc/netplan/00-installer.yaml
chown root:root /etc/netplan/00-installer.yaml
```
- Manually bring up the bridge:
```bash
ip link add name br0 type bridge
ip link set br0 up
ip a show br0
```
- ensure `systemd-networkd` is enabled:
```bash
systemctl restart systemd-networkd
systemctl status systemd-networkd
# if inactive, enable it:
systemctl enable --now systemd-networkd
```
</AccordionTemplate>
- If things went wrong, you can always revert from the backed up file:
```bash
cp /etc/netplan/00-installer-config.yaml.bak /etc/netplan/00-installer-config.yaml
# or
cp /etc/netplan/00-installer.yaml.bak /etc/netplan/00-installer.yaml
# and
netplan apply
```
<Callout type="warning">
Using different IPs for your physical NIC and KVM bridge will disconnect SSH when applying changes. Reconnect using the bridge's new IP. If both share the same IP, no disruption occurs.
</Callout>
- Verify that the IP address has been assigned to the bridge interface:
```bash
ip a
```
<AccordionTemplate name="Console output">
The command output should look similar to this one:
```
root@nym-exit:~# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eno1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:14 brd ff:ff:ff:ff:ff:ff
altname enp2s0f0
3: eno2: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:15 brd ff:ff:ff:ff:ff:ff
altname enp2s0f1
4: eno3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:16 brd ff:ff:ff:ff:ff:ff
altname enp2s0f2
5: eno49: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq master br0 state UP group default qlen 1000
link/ether 38:63:bb:2e:9d:20 brd ff:ff:ff:ff:ff:ff
altname enp4s0f0
6: eno4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 14:02:ec:35:2e:17 brd ff:ff:ff:ff:ff:ff
altname enp2s0f3
7: eno50: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 38:63:bb:2e:9d:24 brd ff:ff:ff:ff:ff:ff
altname enp4s0f1
8: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 46:50:aa:c0:49:a5 brd ff:ff:ff:ff:ff:ff
inet 31.222.238.222/24 brd 31.222.238.255 scope global br0
valid_lft forever preferred_lft forever
inet6 fe80::4450:aaff:fec0:49a5/64 scope link
valid_lft forever preferred_lft forever
```
The bridged interface `br0` now has the IP `<HOST>`, and `<INTERFACE>` shows `master br0`, indicating it is part of the bridge.
</AccordionTemplate>
Alternatively you can use `brctl` command to display the KVM bridge network status:
```bash
brctl show br0
```
##### 4. Add Bridge Network to KVM
- Configure KVM to use the bridge by creating `host-bridge.xml`, open a text editor and pate the block below:
```bash
nano host-bridge.xml
```
```xml
<network>
<name>host-bridge</name>
<forward mode="bridge"/>
<bridge name="br0"/>
</network>
```
- Start the new bridge and set it as the default for VMs:
```bash
virsh net-define host-bridge.xml
virsh net-start host-bridge
virsh net-autostart host-bridge
```
- Verify that the KVM bridge is active:
```bash
virsh net-list --all
```
<AccordionTemplate name="Console output">
```bash
root@nym-exit:~# virsh net-list --all
Name State Autostart Persistent
------------------------------------------------
host-bridge active yes yes
```
</AccordionTemplate>
KVM bridge networking is successfully set up and active!
Your KVM installation is now ready to deploy and manage VMs.
</Steps>
### Setting Up Virtual Machines
After finishing the [installation of KVM](#installing-kvm-on-a-server-with-ubuntu-2204), we can move to the virtualisation configuration.
> **The steps below will guide you through a setup of one VM, therefore you will have to repeat this process for each VM**. That also means that you have to be mindful of space and memory allocation.
<Steps>
##### 1. Install OS for VMs
This is the OS on which the nodes themselves will run. You can chose any GNU/Linux of your preference. For this guide we are going to be using Ubuntu 24.04 LTS (Noble Numbat) cloud image from [cloud-images.ubuntu.com](https://cloud-images.ubuntu.com/noble/current/).
- Download Ubuntu Cloud image:
```bash
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
```
- Copy the image to to `/var/lib/libvirt/images/` asigning to it a name your VM
```bash
cp noble-server-cloudimg-amd64.img /var/lib/libvirt/images/<VM_NAME>.img
# for example:
# cp noble-server-cloudimg-amd64.img /var/lib/libvirt/images/ubuntu-1.img
```
##### 2. Create and resize a virtual machine
- Get `guestfs-tools` to be able to customize your login credentials:
```bash
apt install guestfs-tools
```
- Define login credentials:
```bash
virt-customize -a /var/lib/libvirt/images/<VM_NAME>.img --root-password password:<PASSWORD>
# for example
# virt-customize -a /var/lib/libvirt/images/ubuntu-1.img --root-password password:makesuretosaveyourpasswordslocallytoapasswordmanager
```
- Use `qemu-img` tool with a command `resize` to create a VM according your needs. You can see `qemu` [documentation page`](https://www.qemu.org/docs/master/tools/qemu-img.html) for more info on how to use it correctly.
```bash
qemu-img resize /var/lib/libvirt/images/<VM_NAME>.img +<SIZE_IN_GB>G
# for example
# qemu-img resize /var/lib/libvirt/images/ubuntu-1.img +100G
```
- Resize it from within it after `virt-install` command:
```bash
virt-install \
--name <VM_NAME> \
--ram=<SIZE_IN_MB> \
--vcpus=<NUMBER_OF_VIRTUAL_CPUS> \
--cpu host \
--hvm \
--disk bus=virtio,path=/var/lib/libvirt/images/<VM_NAME>.img \
--network bridge=br0 \
--graphics none \
--console pty,target_type=serial \
--osinfo <YOUR_CHOSEN_OS_NAME> \
--import
```
- In our example we go with 4 GB RAM on the same machine as before:
<br/>
<AccordionTemplate name="Command example">
```bash
virt-install \
--name ubuntu-1 \
--ram=4096 \
--vcpus=4 \
--cpu host \
--hvm \
--disk bus=virtio,path=/var/lib/libvirt/images/ubuntu-1.img \
--network bridge=br0 \
--graphics none \
--console pty,target_type=serial \
--osinfo ubuntunoble \
--import
```
</AccordionTemplate>
- After loading you should see a login console, you can also initiate it by:
```bash
virsh console <VM_NAME>
# for example
# virsh console ubuntu-1
```
- Log in to your new VM using your credentials.
##### 3. Validate your setup
- Make sure the `root` disk has the expected space by running:
```bash
df -h
```
- If not, run:
```bash
growpart /dev/vda 1
resize2fs /dev/vda1
```
##### 4. Configure networking for the VM
As this guide is based on a newer Ubuntu, we use `netplan`, this may be different on different OS.
- Open `/etc/netplan/01-network-config.yaml` in your favourite text editor:
```bash
nano /etc/netplan/01-network-config.yaml
```
- Insert this config, using your correct IP configuration, save and exit:
```ini
network:
version: 2
renderer: networkd
ethernets:
<INTERFACE>:
dhcp4: false
dhcp6: false # Set to true if you want automatic IPv6 assignment
addresses:
- <IPv4_VM>/24 # Assign IPv4 address to the VM
- <IPv6_VM>/64 # Assign IPv6 address to the VM
routes:
- to: default
via: <IPv4_GATEWAY_HOST_SERVER> # IPv4 gateway (host machine)
- to: default
via: <IPv6_GATEWAY_HOST_SERVER> # IPv6 gateway (host machine)
nameservers:
addresses:
- 1.1.1.1 # Cloudflare IPv4 DNS
- 8.8.8.8 # Google IPv4 DNS
- 2606:4700:4700::1111 # Cloudflare IPv6 DNS
- 2001:4860:4860::8888 # Google IPv6 DNS
```
- Fix wide permissions on the config file:
```bash
chmod 600 /etc/netplan/01-network-config.yaml
```
- Check if the config has any errors:
```bash
netplan generate
```
- Apply the configuration:
```bash
netplan --debug apply
```
- Verify by checking if IPv4 and IPv6 are assigned correctly and if they route:
```bash
ip -4 a
ip -6 a
```
```bash
ip -4 r
ip -6 r
```
```bash
# to ping through IPv6, use:
ping6 nym.com
```
- You should be able to ping your new VM from a local machine:
```bash
ping <IPv4_VM>
ping6 <IPv6_VM>
```
</Steps>
Your VM should be working and fully routable. To be able to use it properly, we will create a direct SSH access to the VM.
#### Configure VM SSH access
<Steps>
##### 1. Log in to your VM, update and upgrade your OS:
- Log in to your server using as `root` or as a non-root user with `sudo` privileges
```bash
apt update; apt upgrade
```
##### 2. Generate new host SSH keys
Since we used a `cloud-init` image without an SSH server, we need to generate SSH host keys for client authentication and server identity verification. All of them will be saved to this location: `/etc/ssh/<KEY>`.
- Generate a new RSA host key:
```bash
ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
```
- Generate a new DSA host key:
```bash
ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
```
- Generate a new ECDSA host key:
```bash
ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key
```
- Finally, generate a new ED25519 host key:
```bash
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key
```
##### 3. Restart the SSH service on the server
- Run:
```bash
systemctl restart ssh.service
```
##### 4. Check if the SSH serice is active
- Run:
```bash
systemctl status ssh.service
```
##### 5. Create file `~/.ssh/authorized_keys` and add you public key:
- Create `.ssh` directory:
```bash
mkdir ~/.ssh
```
- Open with your favourite text editor:
```bash
nano ~/.ssh/authorized_keys
```
- Paste your SSH public key, save and exit
- In case of non-root, setup a correct ownership and permissions:
```bash
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chown : ~/.ssh
```
##### 5. Test by connecting via SSH
- Now you should be able to connect to the VM directly from your local terminal
```bash
ssh root@<IPv4> -i ~/.ssh/your_ssh_key
```
</Steps>
Now your VM is almost ready for `nym-node` [setup](../../nym-node/setup). Before you proceed, ssh in and [configure all prerequisities](../vps-setup#vps-configuration) needed for `nym-node` installation and operation.
+7
View File
@@ -0,0 +1,7 @@
{
"git": {
"deploymentEnabled": {
"master": false
}
}
}
+3
View File
@@ -101,6 +101,9 @@ pub struct Debug {
pub maximum_open_connections: usize,
pub zk_nym_tickets: ZkNymTicketHandlerDebug,
/// Defines the maximum age of a signed authentication request before it's deemed too stale to process.
pub maximum_auth_request_age: Duration,
}
#[derive(Debug, Clone)]
@@ -6,11 +6,20 @@ use crate::node::client_handling::embedded_clients::LocalEmbeddedClientHandle;
use dashmap::DashMap;
use nym_sphinx::DestinationAddressBytes;
use std::sync::Arc;
use time::OffsetDateTime;
use tracing::warn;
#[derive(Clone)]
pub(crate) struct RemoteClientData {
// note, this does **NOT** indicate timestamp of when client connected
// it is (for v2 auth) timestamp the client **signed** when it created the request
pub(crate) session_request_timestamp: OffsetDateTime,
pub(crate) channels: ClientIncomingChannels,
}
enum ActiveClient {
/// Handle to a remote client connected via a network socket.
Remote(ClientIncomingChannels),
Remote(RemoteClientData),
/// Handle to a locally (inside the same process) running client.
Embedded(LocalEmbeddedClientHandle),
@@ -19,14 +28,14 @@ enum ActiveClient {
impl ActiveClient {
fn get_sender_ref(&self) -> &MixMessageSender {
match self {
ActiveClient::Remote(remote) => &remote.mix_message_sender,
ActiveClient::Remote(remote) => &remote.channels.mix_message_sender,
ActiveClient::Embedded(embedded) => &embedded.mix_message_sender,
}
}
fn get_sender(&self) -> MixMessageSender {
match self {
ActiveClient::Remote(remote) => remote.mix_message_sender.clone(),
ActiveClient::Remote(remote) => remote.channels.mix_message_sender.clone(),
ActiveClient::Embedded(embedded) => embedded.mix_message_sender.clone(),
}
}
@@ -78,18 +87,18 @@ impl ActiveClientsStore {
pub(crate) fn get_remote_client(
&self,
address: DestinationAddressBytes,
) -> Option<ClientIncomingChannels> {
) -> Option<RemoteClientData> {
let entry = self.inner.get(&address)?;
let handle = entry.value();
let ActiveClient::Remote(channels) = handle else {
let ActiveClient::Remote(remote) = handle else {
warn!("attempted to get a remote handle to a embedded network requester");
return None;
};
// if the entry is stale, remove it from the map
if !channels.mix_message_sender.is_closed() {
Some(channels.clone())
if !remote.channels.mix_message_sender.is_closed() {
Some(remote.clone())
} else {
// drop the reference to the map to prevent deadlocks
drop(entry);
@@ -137,10 +146,14 @@ impl ActiveClientsStore {
client: DestinationAddressBytes,
handle: MixMessageSender,
is_active_request_sender: IsActiveRequestSender,
session_request_timestamp: OffsetDateTime,
) {
let entry = ActiveClient::Remote(ClientIncomingChannels {
mix_message_sender: handle,
is_active_request_sender,
let entry = ActiveClient::Remote(RemoteClientData {
session_request_timestamp,
channels: ClientIncomingChannels {
mix_message_sender: handle,
is_active_request_sender,
},
});
if self.inner.insert(client, entry).is_some() {
panic!("inserted a duplicate remote client")
@@ -9,15 +9,23 @@ use nym_mixnet_client::forwarder::MixForwardingSender;
use nym_node_metrics::events::MetricEventsSender;
use nym_node_metrics::NymNodeMetrics;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
pub(crate) struct Config {
pub(crate) enforce_zk_nym: bool,
pub(crate) max_auth_request_age: Duration,
pub(crate) bandwidth: BandwidthFlushingBehaviourConfig,
}
// I can see this being possible expanded with say storage or client store
#[derive(Clone)]
pub(crate) struct CommonHandlerState {
pub(crate) cfg: Config,
pub(crate) ecash_verifier: Arc<EcashManager>,
pub(crate) storage: GatewayStorage,
pub(crate) local_identity: Arc<identity::KeyPair>,
pub(crate) only_coconut_credentials: bool,
pub(crate) bandwidth_cfg: BandwidthFlushingBehaviourConfig,
pub(crate) metrics: NymNodeMetrics,
pub(crate) metrics_sender: MetricEventsSender,
pub(crate) outbound_mix_sender: MixForwardingSender,
@@ -194,8 +194,8 @@ impl<R, S> AuthenticatedHandler<R, S> {
fresh.shared_state.storage.clone(),
ClientBandwidth::new(bandwidth.into()),
client.id,
fresh.shared_state.bandwidth_cfg,
fresh.shared_state.only_coconut_credentials,
fresh.shared_state.cfg.bandwidth,
fresh.shared_state.cfg.enforce_zk_nym,
),
inner: fresh,
client,
@@ -1,11 +1,13 @@
// Copyright 2021-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::client_handling::active_clients::RemoteClientData;
use crate::node::client_handling::websocket::common_state::CommonHandlerState;
use crate::node::client_handling::websocket::connection_handler::helpers::KeyWithAuthTimestamp;
use crate::node::client_handling::websocket::connection_handler::INITIAL_MESSAGE_TIMEOUT;
use crate::node::client_handling::websocket::{
connection_handler::{AuthenticatedHandler, ClientDetails, InitialAuthResult, SocketStream},
message_receiver::{IsActive, IsActiveRequestSender},
message_receiver::IsActive,
};
use futures::{
channel::{mpsc, oneshot},
@@ -13,14 +15,16 @@ use futures::{
};
use nym_credentials_interface::AvailableBandwidth;
use nym_crypto::aes::cipher::crypto_common::rand_core::RngCore;
use nym_crypto::asymmetric::identity;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::authenticate::AuthenticateRequest;
use nym_gateway_requests::authentication::encrypted_address::{
EncryptedAddressBytes, EncryptedAddressConversionError,
};
use nym_gateway_requests::{
registration::handshake::{error::HandshakeError, gateway_handshake},
types::{ClientControlRequest, ServerResponse},
BinaryResponse, SharedGatewayKey, CURRENT_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION,
AuthenticationFailure, BinaryResponse, SharedGatewayKey, CURRENT_PROTOCOL_VERSION,
INITIAL_PROTOCOL_VERSION,
};
use nym_gateway_storage::error::GatewayStorageError;
use nym_node_metrics::events::MetricsEvent;
@@ -30,6 +34,7 @@ use rand::CryptoRng;
use std::net::SocketAddr;
use std::time::Duration;
use thiserror::Error;
use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::time::timeout;
use tokio_tungstenite::tungstenite::{protocol::Message, Error as WsError};
@@ -37,6 +42,12 @@ use tracing::*;
#[derive(Debug, Error)]
pub(crate) enum InitialAuthenticationError {
#[error(transparent)]
AuthenticationFailure(#[from] AuthenticationFailure),
#[error("attempted to overwrite client session with a stale authentication")]
StaleSessionOverwrite,
#[error("Internal gateway storage error")]
StorageError(#[from] GatewayStorageError),
@@ -290,15 +301,15 @@ impl<R, S> FreshHandler<R, S> {
// of doing full parse of the init_data elsewhere
fn extract_remote_identity_from_register_init(
init_data: &[u8],
) -> Result<identity::PublicKey, InitialAuthenticationError> {
if init_data.len() < identity::PUBLIC_KEY_LENGTH {
) -> Result<ed25519::PublicKey, InitialAuthenticationError> {
if init_data.len() < ed25519::PUBLIC_KEY_LENGTH {
Err(InitialAuthenticationError::HandshakeError(
HandshakeError::MalformedRequest,
))
} else {
identity::PublicKey::from_bytes(&init_data[..identity::PUBLIC_KEY_LENGTH]).map_err(
|_| InitialAuthenticationError::HandshakeError(HandshakeError::MalformedRequest),
)
ed25519::PublicKey::from_bytes(&init_data[..ed25519::PUBLIC_KEY_LENGTH]).map_err(|_| {
InitialAuthenticationError::HandshakeError(HandshakeError::MalformedRequest)
})
}
}
@@ -351,6 +362,21 @@ impl<R, S> FreshHandler<R, S> {
Ok(())
}
async fn retrieve_shared_key(
&self,
client: DestinationAddressBytes,
) -> Result<Option<KeyWithAuthTimestamp>, InitialAuthenticationError> {
let shared_keys = self.shared_state.storage.get_shared_keys(client).await?;
let Some(stored_shared_keys) = shared_keys else {
return Ok(None);
};
let keys = KeyWithAuthTimestamp::try_from_stored(stored_shared_keys, client)?;
Ok(Some(keys))
}
/// Checks whether the stored shared keys match the received data, i.e. whether the upon decryption
/// the provided encrypted address matches the expected unencrypted address.
///
@@ -361,31 +387,18 @@ impl<R, S> FreshHandler<R, S> {
/// * `client_address`: address of the client.
/// * `encrypted_address`: encrypted address of the client, presumably encrypted using the shared keys.
/// * `iv`: nonce/iv created for this particular encryption.
async fn verify_stored_shared_key(
async fn auth_v1_verify_stored_shared_key(
&self,
client_address: DestinationAddressBytes,
encrypted_address: EncryptedAddressBytes,
nonce: &[u8],
) -> Result<Option<SharedGatewayKey>, InitialAuthenticationError> {
let shared_keys = self
.shared_state
.storage
.get_shared_keys(client_address)
.await?;
let Some(stored_shared_keys) = shared_keys else {
) -> Result<Option<KeyWithAuthTimestamp>, InitialAuthenticationError> {
let Some(keys) = self.retrieve_shared_key(client_address).await? else {
return Ok(None);
};
let keys = SharedGatewayKey::try_from(stored_shared_keys).map_err(|source| {
InitialAuthenticationError::MalformedStoredSharedKey {
client_id: client_address.as_base58_string(),
source,
}
})?;
// LEGACY ISSUE: we're not verifying HMAC key
if encrypted_address.verify(&client_address, &keys, nonce) {
if encrypted_address.verify(&client_address, &keys.key, nonce) {
Ok(Some(keys))
} else {
Ok(None)
@@ -428,49 +441,19 @@ impl<R, S> FreshHandler<R, S> {
}
}
/// Using the received challenge data, i.e. client's address as well the ciphertext of it plus
/// a fresh IV, attempts to authenticate the client by checking whether the ciphertext matches
/// the expected value if encrypted with the shared key.
///
/// Finally, upon completion, all previously stored messages are pushed back to the client.
///
/// # Arguments
///
/// * `client_address`: address of the client wishing to authenticate.
/// * `encrypted_address`: ciphertext of the address of the client wishing to authenticate.
/// * `iv`: fresh nonce/IV received with the request.
async fn authenticate_client(
&mut self,
client_address: DestinationAddressBytes,
encrypted_address: EncryptedAddressBytes,
nonce: &[u8],
) -> Result<Option<SharedGatewayKey>, InitialAuthenticationError>
where
S: AsyncRead + AsyncWrite + Unpin,
{
debug!(
"Processing authenticate client request for: {}",
client_address.as_base58_string()
);
let shared_keys = self
.verify_stored_shared_key(client_address, encrypted_address, nonce)
.await?;
if let Some(shared_keys) = shared_keys {
self.push_stored_messages_to_client(client_address, &shared_keys)
.await?;
Ok(Some(shared_keys))
} else {
Ok(None)
}
}
async fn handle_duplicate_client(
&mut self,
address: DestinationAddressBytes,
mut is_active_request_tx: IsActiveRequestSender,
remote_client_data: RemoteClientData,
new_session_start: OffsetDateTime,
) -> Result<(), InitialAuthenticationError> {
let mut is_active_request_tx = remote_client_data.channels.is_active_request_sender;
// new session must **always** be explicitly more recent
if new_session_start <= remote_client_data.session_request_timestamp {
return Err(InitialAuthenticationError::StaleSessionOverwrite);
}
// Ask the other connection to ping if they are still active.
// Use a oneshot channel to return the result to us
let (ping_result_sender, ping_result_receiver) = oneshot::channel();
@@ -519,6 +502,32 @@ impl<R, S> FreshHandler<R, S> {
Ok(())
}
#[allow(dead_code)]
async fn get_registered_client_id(
&self,
client_address: DestinationAddressBytes,
) -> Result<i64, InitialAuthenticationError> {
self.shared_state
.storage
.get_mixnet_client_id(client_address)
.await
.map_err(Into::into)
}
async fn get_registered_available_bandwidth(
&self,
client_id: i64,
) -> Result<AvailableBandwidth, InitialAuthenticationError> {
let available_bandwidth: AvailableBandwidth = self
.shared_state
.storage
.get_available_bandwidth(client_id)
.await?
.map(From::from)
.unwrap_or_default();
Ok(available_bandwidth)
}
/// Tries to handle the received authentication request by checking correctness of the received data.
///
/// # Arguments
@@ -531,7 +540,7 @@ impl<R, S> FreshHandler<R, S> {
address = %address,
)
)]
async fn handle_authenticate(
async fn handle_legacy_authenticate(
&mut self,
client_protocol_version: Option<u8>,
address: String,
@@ -541,7 +550,7 @@ impl<R, S> FreshHandler<R, S> {
where
S: AsyncRead + AsyncWrite + Unpin,
{
debug!("handling client registration");
debug!("handling client authentication (v1)");
let negotiated_protocol = self.negotiate_client_protocol(client_protocol_version)?;
// populate the negotiated protocol for future uses
@@ -554,38 +563,38 @@ impl<R, S> FreshHandler<R, S> {
.into_vec()
.map_err(InitialAuthenticationError::MalformedIV)?;
// Check for duplicate clients
if let Some(client_tx) = self
.shared_state
.active_clients_store
.get_remote_client(address)
{
warn!("Detected duplicate connection for client: {address}");
self.handle_duplicate_client(address, client_tx.is_active_request_sender)
.await?;
}
// validate the shared key
let Some(shared_keys) = self
.authenticate_client(address, encrypted_address, &nonce)
.auth_v1_verify_stored_shared_key(address, encrypted_address, &nonce)
.await?
else {
// it feels weird to be returning an 'Ok' here, but I didn't want to change the existing behaviour
return Ok(InitialAuthResult::new_failed(Some(negotiated_protocol)));
};
let client_id = self
// in v1 we don't have explicit data so we have to use current timestamp
// (which does nothing but just allows us to use the same codepath)
let session_request_start = OffsetDateTime::now_utc();
// Check for duplicate clients
if let Some(remote_client_data) = self
.shared_state
.storage
.get_mixnet_client_id(address)
.active_clients_store
.get_remote_client(address)
{
warn!("Detected duplicate connection for client: {address}");
self.handle_duplicate_client(address, remote_client_data, session_request_start)
.await?;
}
let client_id = shared_keys.client_id;
// if applicable, push stored messages
self.push_stored_messages_to_client(address, &shared_keys.key)
.await?;
let available_bandwidth: AvailableBandwidth = self
.shared_state
.storage
.get_available_bandwidth(client_id)
.await?
.map(From::from)
.unwrap_or_default();
// check the bandwidth
let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?;
let bandwidth_remaining = if available_bandwidth.expired() {
self.shared_state.storage.reset_bandwidth(client_id).await?;
@@ -595,7 +604,98 @@ impl<R, S> FreshHandler<R, S> {
};
Ok(InitialAuthResult::new(
Some(ClientDetails::new(client_id, address, shared_keys)),
Some(ClientDetails::new(
client_id,
address,
shared_keys.key,
session_request_start,
)),
ServerResponse::Authenticate {
protocol_version: Some(negotiated_protocol),
status: true,
bandwidth_remaining,
},
))
}
async fn handle_authenticate_v2(
&mut self,
request: Box<AuthenticateRequest>,
) -> Result<InitialAuthResult, InitialAuthenticationError>
where
S: AsyncRead + AsyncWrite + Unpin,
{
debug!("handling client authentication (v2)");
let negotiated_protocol =
self.negotiate_client_protocol(Some(request.content.protocol_version))?;
// populate the negotiated protocol for future uses
self.negotiated_protocol = Some(negotiated_protocol);
let address = request.content.client_identity.derive_destination_address();
// do cheap checks first
// is the provided timestamp relatively recent (and not in the future?)
request.verify_timestamp(self.shared_state.cfg.max_auth_request_age)?;
// does the message signature verify?
request.verify_signature()?;
// retrieve the actually stored key and check if the ciphertext matches
let Some(shared_key) = self.retrieve_shared_key(address).await? else {
return Err(AuthenticationFailure::NotRegistered)?;
};
request.verify_ciphertext(&shared_key.key)?;
let session_request_start = request.content.request_timestamp();
// if the client has already authenticated in the past, make sure this authentication timestamp
// is different and greater than the old one (in case it was replayed)
if let Some(prior_usage) = shared_key.last_used_authentication {
request.ensure_timestamp_not_reused(prior_usage)?;
}
// check for duplicate clients
if let Some(client_data) = self
.shared_state
.active_clients_store
.get_remote_client(address)
{
warn!("Detected duplicate connection for client: {address}");
self.handle_duplicate_client(address, client_data, session_request_start)
.await?;
}
let client_id = shared_key.client_id;
// update the auth timestamp for future uses
self.shared_state
.storage
.update_last_used_authentication_timestamp(client_id, session_request_start)
.await?;
// push any old stored messages to the client
// (this will be removed soon)
self.push_stored_messages_to_client(address, &shared_key.key)
.await?;
// finally check and retrieve client's bandwidth
let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?;
let bandwidth_remaining = if available_bandwidth.expired() {
self.shared_state.storage.reset_bandwidth(client_id).await?;
0
} else {
available_bandwidth.bytes
};
Ok(InitialAuthResult::new(
Some(ClientDetails::new(
client_id,
address,
shared_key.key,
session_request_start,
)),
ServerResponse::Authenticate {
protocol_version: Some(negotiated_protocol),
status: true,
@@ -688,7 +788,12 @@ impl<R, S> FreshHandler<R, S> {
debug!(client_id = %client_id, "managed to finalize client registration");
let client_details = ClientDetails::new(client_id, remote_address, shared_keys);
let client_details = ClientDetails::new(
client_id,
remote_address,
shared_keys,
OffsetDateTime::now_utc(),
);
Ok(InitialAuthResult::new(
Some(client_details),
@@ -734,9 +839,10 @@ impl<R, S> FreshHandler<R, S> {
enc_address,
iv,
} => {
self.handle_authenticate(protocol_version, address, enc_address, iv)
self.handle_legacy_authenticate(protocol_version, address, enc_address, iv)
.await
}
ClientControlRequest::AuthenticateV2(req) => self.handle_authenticate_v2(req).await,
ClientControlRequest::RegisterHandshakeInitRequest {
protocol_version,
data,
@@ -827,6 +933,7 @@ impl<R, S> FreshHandler<R, S> {
registration_details.address,
mix_sender,
is_active_request_sender,
registration_details.session_request_timestamp,
);
return AuthenticatedHandler::upgrade(
@@ -0,0 +1,37 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::client_handling::websocket::connection_handler::fresh::InitialAuthenticationError;
use nym_gateway_requests::SharedGatewayKey;
use nym_gateway_storage::models::PersistedSharedKeys;
use nym_sphinx::DestinationAddressBytes;
use time::OffsetDateTime;
pub(crate) struct KeyWithAuthTimestamp {
pub(crate) client_id: i64,
pub(crate) key: SharedGatewayKey,
pub(crate) last_used_authentication: Option<OffsetDateTime>,
}
impl KeyWithAuthTimestamp {
pub(crate) fn try_from_stored(
stored_shared_keys: PersistedSharedKeys,
client: DestinationAddressBytes,
) -> Result<Self, InitialAuthenticationError> {
let last_used_authentication = stored_shared_keys.last_used_authentication;
let client_id = stored_shared_keys.client_id;
let key = SharedGatewayKey::try_from(stored_shared_keys).map_err(|source| {
InitialAuthenticationError::MalformedStoredSharedKey {
client_id: client.as_base58_string(),
source,
}
})?;
Ok(KeyWithAuthTimestamp {
client_id,
key,
last_used_authentication,
})
}
}
@@ -8,16 +8,17 @@ use nym_gateway_requests::ServerResponse;
use nym_sphinx::DestinationAddressBytes;
use rand::{CryptoRng, Rng};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_tungstenite::WebSocketStream;
use tracing::{debug, instrument, trace, warn};
use zeroize::{Zeroize, ZeroizeOnDrop};
pub(crate) use self::authenticated::AuthenticatedHandler;
pub(crate) use self::fresh::FreshHandler;
pub(crate) mod authenticated;
mod fresh;
pub(crate) mod helpers;
const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(1_500);
const INITIAL_MESSAGE_TIMEOUT: Duration = Duration::from_millis(10_000);
@@ -40,12 +41,13 @@ impl<S> SocketStream<S> {
}
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct ClientDetails {
#[zeroize(skip)]
pub(crate) address: DestinationAddressBytes,
pub(crate) id: i64,
pub(crate) shared_keys: SharedGatewayKey,
// note, this does **NOT ALWAYS** indicate timestamp of when client connected
// it is (for v2 auth) timestamp the client **signed** when it created the request
pub(crate) session_request_timestamp: OffsetDateTime,
}
impl ClientDetails {
@@ -53,11 +55,13 @@ impl ClientDetails {
id: i64,
address: DestinationAddressBytes,
shared_keys: SharedGatewayKey,
session_request_timestamp: OffsetDateTime,
) -> Self {
ClientDetails {
address,
id,
shared_keys,
session_request_timestamp,
}
}
}
@@ -8,4 +8,4 @@ pub(crate) mod connection_handler;
pub(crate) mod listener;
pub(crate) mod message_receiver;
pub(crate) use common_state::CommonHandlerState;
pub(crate) use common_state::{CommonHandlerState, Config};
+5 -2
View File
@@ -249,11 +249,14 @@ impl GatewayTasksBuilder {
active_clients_store: ActiveClientsStore,
) -> Result<websocket::Listener, GatewayError> {
let shared_state = websocket::CommonHandlerState {
cfg: websocket::Config {
enforce_zk_nym: self.config.gateway.enforce_zk_nyms,
max_auth_request_age: self.config.debug.maximum_auth_request_age,
bandwidth: (&self.config).into(),
},
ecash_verifier: self.ecash_manager().await?,
storage: self.storage.clone(),
local_identity: Arc::clone(&self.identity_keypair),
only_coconut_credentials: self.config.gateway.enforce_zk_nyms,
bandwidth_cfg: (&self.config).into(),
metrics: self.metrics.clone(),
metrics_sender: self.metrics_sender.clone(),
outbound_mix_sender: self.mix_packet_sender.clone(),
+2 -1
View File
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-api
+13
View File
@@ -10,6 +10,7 @@ use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::reward_params::Performance;
use nym_mixnet_contract_common::{Interval, NodeId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use time::OffsetDateTime;
use utoipa::ToSchema;
@@ -212,3 +213,15 @@ pub struct FullFatNode {
// kinda temporary for now to make as few changes as possible for now
pub self_described: Option<NymNodeData>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, ToSchema)]
pub struct NodesByAddressesRequestBody {
#[schema(value_type = Vec<String>)]
pub addresses: Vec<IpAddr>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, ToSchema)]
pub struct NodesByAddressesResponse {
#[schema(value_type = HashMap<String, Option<u32>>)]
pub existence: HashMap<IpAddr, Option<NodeId>>,
}
+22 -2
View File
@@ -17,6 +17,7 @@ use nym_mixnet_contract_common::{NodeId, NymNodeDetails};
use nym_node_requests::api::client::{NymNodeApiClientError, NymNodeApiClientExt};
use nym_topology::node::{RoutingNode, RoutingNodeError};
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Duration;
use thiserror::Error;
use tracing::{debug, error, info};
@@ -84,10 +85,14 @@ impl NodeDescriptionTopologyExt for NymNodeDescription {
#[derive(Debug, Clone)]
pub struct DescribedNodes {
nodes: HashMap<NodeId, NymNodeDescription>,
addresses_cache: HashMap<IpAddr, NodeId>,
}
impl DescribedNodes {
pub fn force_update(&mut self, node: NymNodeDescription) {
for ip in &node.description.host_information.ip_address {
self.addresses_cache.insert(*ip, node.node_id);
}
self.nodes.insert(node.node_id, node);
}
@@ -129,6 +134,10 @@ impl DescribedNodes {
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.can_operate_exit_gateway())
}
pub fn node_with_address(&self, address: IpAddr) -> Option<NodeId> {
self.addresses_cache.get(&address).copied()
}
}
pub struct NodeDescriptionProvider {
@@ -396,9 +405,20 @@ impl CacheItemProvider for NodeDescriptionProvider {
.collect::<HashMap<_, _>>()
.await;
info!("refreshed self described data for {} nodes", nodes.len());
let mut addresses_cache = HashMap::new();
for node in nodes.values() {
for ip in &node.description.host_information.ip_address {
addresses_cache.insert(*ip, node.node_id);
}
}
Ok(DescribedNodes { nodes })
info!("refreshed self described data for {} nodes", nodes.len());
info!("with {} unique ip addresses", addresses_cache.len());
Ok(DescribedNodes {
nodes,
addresses_cache,
})
}
}
+42 -3
View File
@@ -20,6 +20,7 @@
//! - `/mixnodes/<tier>` => only returns mixnode role data
//! - `/gateway/<tier>` => only returns (entry) gateway role data
use crate::node_status_api::models::{AxumErrorResponse, AxumResult};
use crate::nym_nodes::handlers::unstable::full_fat::nodes_detailed;
use crate::nym_nodes::handlers::unstable::semi_skimmed::nodes_expanded;
use crate::nym_nodes::handlers::unstable::skimmed::{
@@ -29,10 +30,14 @@ use crate::nym_nodes::handlers::unstable::skimmed::{
};
use crate::support::http::helpers::PaginationRequest;
use crate::support::http::state::AppState;
use axum::routing::get;
use axum::Router;
use nym_api_requests::nym_nodes::NodeRoleQueryParam;
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use nym_api_requests::nym_nodes::{
NodeRoleQueryParam, NodesByAddressesRequestBody, NodesByAddressesResponse,
};
use serde::Deserialize;
use std::collections::HashMap;
use tower_http::compression::CompressionLayer;
pub(crate) mod full_fat;
@@ -74,6 +79,7 @@ pub(crate) fn nym_node_routes_unstable() -> Router<AppState> {
.nest("/full-fat", Router::new().route("/", get(nodes_detailed)))
.route("/gateways/skimmed", get(skimmed::deprecated_gateways_basic))
.route("/mixnodes/skimmed", get(skimmed::deprecated_mixnodes_basic))
.route("/by-addresses", post(nodes_by_addresses))
.layer(CompressionLayer::new())
}
@@ -129,3 +135,36 @@ impl<'a> From<&'a NodesParams> for PaginationRequest {
}
}
}
#[utoipa::path(
tag = "Unstable Nym Nodes",
post,
request_body = NodesByAddressesRequestBody,
path = "/by-addresses",
context_path = "/v1/unstable/nym-nodes",
responses(
(status = 200, body = NodesByAddressesResponse)
)
)]
async fn nodes_by_addresses(
state: State<AppState>,
Json(body): Json<NodesByAddressesRequestBody>,
) -> AxumResult<Json<NodesByAddressesResponse>> {
// if the request is too big, simply reject it
if body.addresses.len() > 100 {
return Err(AxumErrorResponse::bad_request(
"requested too many addresses",
));
}
// TODO: perhaps introduce different cache because realistically nym-api will receive
// request for the same couple addresses from all nodes in quick succession
let describe_cache = state.describe_nodes_cache_data().await?;
let mut existence = HashMap::new();
for address in body.addresses {
existence.insert(address, describe_cache.node_with_address(address));
}
Ok(Json(NodesByAddressesResponse { existence }))
}
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-credential-proxy/nym-credential-proxy
@@ -24,7 +25,7 @@ RUN cargo build --release
# see https://github.com/nymtech/nym/blob/develop/nym-credential-proxy/nym-credential-proxy/src/cli.rs for details
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
RUN apt update && apt install -yy curl ca-certificates
+2 -1
View File
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-network-monitor
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-agent"
version = "1.0.0-rc.1"
version = "1.0.0-rc.2"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
ARG GIT_REF=main
@@ -27,7 +28,7 @@ RUN cargo build --release
# see https://github.com/nymtech/nym/blob/develop/nym-node-status-agent/src/cli.rs for details
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
RUN apt-get update && apt-get install -y ca-certificates
@@ -37,4 +38,4 @@ COPY --from=builder /usr/src/nym/target/release/nym-node-status-agent ./
COPY --from=builder /usr/src/nym-vpn-client/nym-vpn-core/target/release/nym-gateway-probe ./
ENV NODE_STATUS_AGENT_PROBE_PATH=/nym/nym-gateway-probe
ENTRYPOINT [ "/nym/nym-node-status-agent", "run-probe" ]
ENTRYPOINT [ "/nym/nym-node-status-agent", "run-probe" ]
@@ -1,9 +1,9 @@
#!/bin/bash
set -eu
export ENVIRONMENT=${ENVIRONMENT:-"sandbox"}
export ENVIRONMENT=${ENVIRONMENT:-"mainnet"}
probe_git_ref="nym-vpn-core-v1.1.0"
probe_git_ref="nym-vpn-core-v1.3.2"
crate_root=$(dirname $(realpath "$0"))
monorepo_root=$(realpath "${crate_root}/../..")
@@ -21,6 +21,7 @@ export NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1"
export NODE_STATUS_AGENT_SERVER_PORT="8000"
export NODE_STATUS_AGENT_PROBE_PATH="$crate_root/nym-gateway-probe"
export NODE_STATUS_AGENT_AUTH_KEY="BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT"
export NODE_STATUS_AGENT_PROBE_EXTRA_ARGS="netstack-download-timeout-sec=30,netstack-num-ping=2,netstack-send-timeout-sec=1,netstack-recv-timeout-sec=1"
workers=${1:-1}
echo "Running $workers workers in parallel"
@@ -54,7 +55,7 @@ function swarm() {
echo "All agents completed"
}
# copy_gw_probe
copy_gw_probe
build_agent
swarm $workers
@@ -35,6 +35,13 @@ pub(crate) enum Command {
/// path of binary to run
#[arg(long, env = "NODE_STATUS_AGENT_PROBE_PATH")]
probe_path: String,
#[arg(
long,
env = "NODE_STATUS_AGENT_PROBE_EXTRA_ARGS",
value_delimiter = ','
)]
probe_extra_args: Vec<String>,
},
GenerateKeypair {
@@ -51,11 +58,13 @@ impl Args {
server_port,
ns_api_auth_key,
probe_path,
probe_extra_args,
} => run_probe::run_probe(
server_address,
server_port.to_owned(),
ns_api_auth_key,
probe_path,
probe_extra_args,
)
.await
.inspect_err(|err| {
@@ -7,6 +7,7 @@ pub(crate) async fn run_probe(
server_port: u16,
ns_api_auth_key: &str,
probe_path: &str,
probe_extra_args: &Vec<String>,
) -> anyhow::Result<()> {
let auth_key = PrivateKey::from_base58_string(ns_api_auth_key)
.context("Couldn't parse auth key, exiting")?;
@@ -19,7 +20,7 @@ pub(crate) async fn run_probe(
tracing::info!("Probe version:\n{}", version);
if let Some(testrun) = ns_api_client.request_testrun().await? {
let log = probe.run_and_get_log(&Some(testrun.gateway_identity_key));
let log = probe.run_and_get_log(&Some(testrun.gateway_identity_key), probe_extra_args);
ns_api_client
.submit_results(testrun.testrun_id, log, testrun.assigned_at_utc)
@@ -29,7 +29,11 @@ impl GwProbe {
}
}
pub(crate) fn run_and_get_log(&self, gateway_key: &Option<String>) -> String {
pub(crate) fn run_and_get_log(
&self,
gateway_key: &Option<String>,
probe_extra_args: &Vec<String>,
) -> String {
let mut command = std::process::Command::new(&self.path);
command.stdout(std::process::Stdio::piped());
@@ -37,6 +41,16 @@ impl GwProbe {
command.arg("--gateway").arg(gateway_id);
}
tracing::info!("Extra args for the probe:");
for arg in probe_extra_args {
let mut split = arg.splitn(2, '=');
let name = split.next().unwrap_or_default();
let value = split.next().unwrap_or_default();
tracing::info!("{} {}", name, value);
command.arg(format!("--{name}")).arg(value);
}
match command.spawn() {
Ok(child) => {
if let Ok(output) = child.wait_with_output() {
@@ -16,11 +16,13 @@ rust-version.workspace = true
ammonia = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true, features = ["tokio", "macros"] }
bip39 = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["cargo", "derive", "env", "string"] }
cosmwasm-std = { workspace = true }
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-bin-common = { path = "../../common/bin-common", features = ["models"] }
@@ -33,6 +35,8 @@ 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"] }
rand = { workspace = true }
rand_chacha = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-node-status-api
@@ -26,7 +27,7 @@ RUN cargo build --release
# see https://github.com/nymtech/nym/blob/develop/nym-node-status-api/src/cli.rs for details
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
RUN apt-get update && apt-get install -y ca-certificates
@@ -1,4 +1,4 @@
FROM ubuntu:22.04
FROM harbor.nymte.ch/dockerhub/ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates
@@ -3,7 +3,7 @@
set -e
user_rust_log_preference=$RUST_LOG
export ENVIRONMENT=${ENVIRONMENT:-"sandbox"}
export ENVIRONMENT=${ENVIRONMENT:-"mainnet"}
export NYM_API_CLIENT_TIMEOUT=60
export EXPLORER_CLIENT_TIMEOUT=60
export NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL=120
@@ -83,9 +83,6 @@ pub(crate) struct Cli {
env = "NYM_NODE_STATUS_API_MAX_AGENT_COUNT"
)]
pub(crate) max_agent_count: i64,
#[clap(long, default_value = "", env = "NYM_NODE_STATUS_API_HM_URL")]
pub(crate) hm_url: String,
}
fn parse_duration(arg: &str) -> Result<std::time::Duration, std::num::ParseIntError> {
@@ -2,7 +2,7 @@ use std::str::FromStr;
use crate::{
http::{self, models::SummaryHistory},
monitor::NumericalCheckedCast,
utils::NumericalCheckedCast,
};
use anyhow::Context;
use nym_contracts_common::Percent;
@@ -16,7 +16,7 @@ use strum_macros::{EnumString, FromRepr};
use time::{Date, OffsetDateTime};
use utoipa::ToSchema;
pub(crate) struct GatewayRecord {
pub(crate) struct GatewayInsertRecord {
pub(crate) identity_key: String,
pub(crate) bonded: bool,
pub(crate) self_described: String,
@@ -360,14 +360,24 @@ impl TryFrom<GatewaySessionsRecord> for http::models::SessionStats {
}
}
pub(crate) enum MixingNodeKind {
LegacyMixnode,
NymNode,
pub(crate) enum ScrapeNodeKind {
LegacyMixnode { mix_id: i64 },
MixingNymNode { node_id: i64 },
EntryExitNymNode { node_id: i64, identity_key: String },
}
impl ScrapeNodeKind {
pub(crate) fn node_id(&self) -> &i64 {
match self {
ScrapeNodeKind::LegacyMixnode { mix_id } => mix_id,
ScrapeNodeKind::MixingNymNode { node_id } => node_id,
ScrapeNodeKind::EntryExitNymNode { node_id, .. } => node_id,
}
}
}
pub(crate) struct ScraperNodeInfo {
pub node_id: i64,
pub node_kind: MixingNodeKind,
pub node_kind: ScrapeNodeKind,
pub hosts: Vec<String>,
pub http_api_port: i64,
}
@@ -390,6 +400,10 @@ impl ScraperNodeInfo {
urls
}
pub(crate) fn node_id(&self) -> &i64 {
self.node_kind.node_id()
}
}
#[derive(sqlx::Decode, Debug)]
@@ -1,6 +1,8 @@
use std::collections::HashSet;
use crate::{
db::{
models::{GatewayDto, GatewayRecord},
models::{GatewayDto, GatewayInsertRecord},
DbPool,
},
http::models::Gateway,
@@ -30,7 +32,7 @@ pub(crate) async fn select_gateway_identity(
pub(crate) async fn insert_gateways(
pool: &DbPool,
gateways: Vec<GatewayRecord>,
gateways: Vec<GatewayInsertRecord>,
) -> anyhow::Result<()> {
let mut db = pool.acquire().await?;
for record in gateways {
@@ -98,3 +100,21 @@ pub(crate) async fn get_all_gateways(pool: &DbPool) -> anyhow::Result<Vec<Gatewa
tracing::trace!("Fetched {} gateways from DB", items.len());
Ok(items)
}
pub(crate) async fn get_all_gateway_id_keys(pool: &DbPool) -> anyhow::Result<HashSet<String>> {
let mut conn = pool.acquire().await?;
let items = sqlx::query!(
r#"
SELECT gateway_identity_key
FROM gateways
WHERE bonded = true
"#
)
.fetch_all(&mut *conn)
.await?
.into_iter()
.map(|record| record.gateway_identity_key)
.collect::<HashSet<_>>();
Ok(items)
}
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use futures_util::TryStreamExt;
use tracing::error;
@@ -83,8 +85,7 @@ pub(crate) async fn get_all_mixnodes(pool: &DbPool) -> anyhow::Result<Vec<Mixnod
Ok(items)
}
/// `offset` = slides our fixed-day period further into the past by N days
pub(crate) async fn get_daily_stats(pool: &DbPool, offset: i64) -> anyhow::Result<Vec<DailyStats>> {
pub(crate) async fn get_daily_stats(pool: &DbPool) -> anyhow::Result<Vec<DailyStats>> {
let mut conn = pool.acquire().await?;
let items = sqlx::query_as!(
DailyStats,
@@ -115,11 +116,8 @@ pub(crate) async fn get_daily_stats(pool: &DbPool, offset: i64) -> anyhow::Resul
WHERE nym_node_daily_mixing_stats.node_id IS NULL
)
GROUP BY date_utc
ORDER BY date_utc DESC
LIMIT 30
OFFSET ?
ORDER BY date_utc ASC
"#,
offset
)
.fetch(&mut *conn)
.try_collect::<Vec<DailyStats>>()
@@ -127,3 +125,21 @@ pub(crate) async fn get_daily_stats(pool: &DbPool, offset: i64) -> anyhow::Resul
Ok(items)
}
pub(crate) async fn get_all_mix_ids(pool: &DbPool) -> anyhow::Result<HashSet<i64>> {
let mut conn = pool.acquire().await?;
let items = sqlx::query!(
r#"
SELECT mix_id
FROM mixnodes
WHERE bonded = true
"#
)
.fetch_all(&mut *conn)
.await?
.into_iter()
.map(|record| record.mix_id)
.collect::<HashSet<_>>();
Ok(items)
}
@@ -8,13 +8,15 @@ pub(crate) mod scraper;
mod summary;
pub(crate) mod testruns;
pub(crate) use gateways::{get_all_gateways, insert_gateways, select_gateway_identity};
pub(crate) use gateways::{
get_all_gateway_id_keys, get_all_gateways, insert_gateways, select_gateway_identity,
};
pub(crate) use gateways_stats::{delete_old_records, get_sessions_stats, insert_session_records};
pub(crate) use misc::insert_summaries;
pub(crate) use mixnodes::{get_all_mixnodes, get_daily_stats, insert_mixnodes};
pub(crate) use mixnodes::{get_all_mix_ids, get_all_mixnodes, get_daily_stats, insert_mixnodes};
pub(crate) use nym_nodes::{get_nym_nodes, insert_nym_nodes};
pub(crate) use packet_stats::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
};
pub(crate) use scraper::{get_mixing_nodes_for_scraping, insert_scraped_node_description};
pub(crate) use scraper::{get_nodes_for_scraping, insert_scraped_node_description};
pub(crate) use summary::{get_summary, get_summary_history};
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use anyhow::Context;
use futures_util::TryStreamExt;
use nym_validator_client::{client::NymNodeDetails, nym_api::SkimmedNode};
use tracing::instrument;
@@ -9,7 +10,7 @@ use crate::{
models::{NymNodeDto, NymNodeInsertRecord},
DbPool,
},
monitor::decimal_to_i64,
utils::decimal_to_i64,
};
pub(crate) async fn get_nym_nodes(pool: &DbPool) -> anyhow::Result<Vec<SkimmedNode>> {
@@ -100,7 +101,8 @@ pub(crate) async fn insert_nym_nodes(
record.last_updated_utc,
)
.execute(&mut *conn)
.await?;
.await
.with_context(|| format!("node_id={}", record.node_id))?;
}
Ok(())
@@ -1,27 +1,26 @@
use crate::db::{
models::{MixingNodeKind, NodeStats, ScraperNodeInfo},
models::{NodeStats, ScrapeNodeKind, ScraperNodeInfo},
DbPool,
};
use anyhow::Result;
pub(crate) async fn insert_node_packet_stats(
pool: &DbPool,
node_id: i64,
node_kind: &MixingNodeKind,
node_kind: &ScrapeNodeKind,
stats: &NodeStats,
timestamp_utc: i64,
) -> Result<()> {
let mut conn = pool.acquire().await?;
match node_kind {
MixingNodeKind::LegacyMixnode => {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
sqlx::query!(
r#"
INSERT INTO mixnode_packet_stats_raw (
mix_id, timestamp_utc, packets_received, packets_sent, packets_dropped
) VALUES (?, ?, ?, ?, ?)
"#,
node_id,
mix_id,
timestamp_utc,
stats.packets_received,
stats.packets_sent,
@@ -30,7 +29,8 @@ pub(crate) async fn insert_node_packet_stats(
.execute(&mut *conn)
.await?;
}
MixingNodeKind::NymNode => {
ScrapeNodeKind::MixingNymNode { node_id }
| ScrapeNodeKind::EntryExitNymNode { node_id, .. } => {
sqlx::query!(
r#"
INSERT INTO nym_nodes_packet_stats_raw (
@@ -60,7 +60,7 @@ pub(crate) async fn get_raw_node_stats(
let packets = match node.node_kind {
// if no packets are found, it's fine to assume 0 because that's also
// SQL default value if none provided
MixingNodeKind::LegacyMixnode => {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
sqlx::query_as!(
NodeStats,
r#"
@@ -73,12 +73,13 @@ pub(crate) async fn get_raw_node_stats(
ORDER BY timestamp_utc DESC
LIMIT 1 OFFSET 1
"#,
node.node_id
mix_id
)
.fetch_optional(&mut *conn)
.await?
}
MixingNodeKind::NymNode => {
ScrapeNodeKind::MixingNymNode { node_id }
| ScrapeNodeKind::EntryExitNymNode { node_id, .. } => {
sqlx::query_as!(
NodeStats,
r#"
@@ -91,7 +92,7 @@ pub(crate) async fn get_raw_node_stats(
ORDER BY timestamp_utc DESC
LIMIT 1 OFFSET 1
"#,
node.node_id
node_id
)
.fetch_optional(&mut *conn)
.await?
@@ -110,7 +111,7 @@ pub(crate) async fn insert_daily_node_stats(
let mut conn = pool.acquire().await?;
match node.node_kind {
MixingNodeKind::LegacyMixnode => {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
let total_stake = sqlx::query_scalar!(
r#"
SELECT
@@ -118,7 +119,7 @@ pub(crate) async fn insert_daily_node_stats(
FROM mixnodes
WHERE mix_id = ?
"#,
node.node_id
mix_id
)
.fetch_one(&mut *conn)
.await?;
@@ -136,7 +137,7 @@ pub(crate) async fn insert_daily_node_stats(
packets_sent = mixnode_daily_stats.packets_sent + excluded.packets_sent,
packets_dropped = mixnode_daily_stats.packets_dropped + excluded.packets_dropped
"#,
node.node_id,
mix_id,
date_utc,
total_stake,
packets.packets_received,
@@ -146,7 +147,8 @@ pub(crate) async fn insert_daily_node_stats(
.execute(&mut *conn)
.await?;
}
MixingNodeKind::NymNode => {
ScrapeNodeKind::MixingNymNode { node_id }
| ScrapeNodeKind::EntryExitNymNode { node_id, .. } => {
let total_stake = sqlx::query_scalar!(
r#"
SELECT
@@ -154,7 +156,7 @@ pub(crate) async fn insert_daily_node_stats(
FROM nym_nodes
WHERE node_id = ?
"#,
node.node_id
node_id
)
.fetch_one(&mut *conn)
.await?;
@@ -167,12 +169,12 @@ pub(crate) async fn insert_daily_node_stats(
packets_sent, packets_dropped
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(node_id, date_utc) DO UPDATE SET
total_stake = nym_node_daily_mixing_stats.total_stake + excluded.total_stake,
total_stake = excluded.total_stake,
packets_received = nym_node_daily_mixing_stats.packets_received + excluded.packets_received,
packets_sent = nym_node_daily_mixing_stats.packets_sent + excluded.packets_sent,
packets_dropped = nym_node_daily_mixing_stats.packets_dropped + excluded.packets_dropped
"#,
node.node_id,
node_id,
date_utc,
total_stake,
packets.packets_received,
@@ -1,6 +1,6 @@
use crate::{
db::{
models::{MixingNodeKind, ScraperNodeInfo},
models::{ScrapeNodeKind, ScraperNodeInfo},
queries, DbPool,
},
mixnet_scraper::helpers::NodeDescriptionResponse,
@@ -8,16 +8,36 @@ use crate::{
use anyhow::Result;
use chrono::Utc;
pub(crate) async fn get_mixing_nodes_for_scraping(pool: &DbPool) -> Result<Vec<ScraperNodeInfo>> {
pub(crate) async fn get_nodes_for_scraping(pool: &DbPool) -> Result<Vec<ScraperNodeInfo>> {
let mut nodes_to_scrape = Vec::new();
let mixnode_ids = queries::get_all_mix_ids(pool).await?;
let gateway_keys = queries::get_all_gateway_id_keys(pool).await?;
let mut entry_exit_nodes = 0;
queries::get_nym_nodes(pool)
.await?
.into_iter()
.for_each(|node| {
// due to polyfilling, Nym nodes table might contain legacy mixnodes
// as well. Mark them as such here.
let node_kind = if mixnode_ids.contains(&node.node_id.into()) {
ScrapeNodeKind::LegacyMixnode {
mix_id: node.node_id.into(),
}
} else if gateway_keys.contains(&node.ed25519_identity_pubkey.to_base58_string()) {
entry_exit_nodes += 1;
ScrapeNodeKind::EntryExitNymNode {
node_id: node.node_id.into(),
identity_key: node.ed25519_identity_pubkey.to_base58_string(),
}
} else {
ScrapeNodeKind::MixingNymNode {
node_id: node.node_id.into(),
}
};
nodes_to_scrape.push(ScraperNodeInfo {
node_id: node.node_id.into(),
node_kind: MixingNodeKind::NymNode,
node_kind,
hosts: node
.ip_addresses
.into_iter()
@@ -27,7 +47,8 @@ pub(crate) async fn get_mixing_nodes_for_scraping(pool: &DbPool) -> Result<Vec<S
})
});
tracing::debug!("Fetched {} 🌟 nym nodes", nodes_to_scrape.len());
tracing::debug!("Fetched {} 🌟 total nym nodes", nodes_to_scrape.len());
tracing::debug!("Fetched {} 🚪 entry/exit nodes", entry_exit_nodes);
let mut conn = pool.acquire().await?;
let mixnodes = sqlx::query!(
@@ -41,7 +62,7 @@ pub(crate) async fn get_mixing_nodes_for_scraping(pool: &DbPool) -> Result<Vec<S
.await?;
drop(conn);
tracing::debug!("Fetched {} 🦖 mixnodes", nodes_to_scrape.len());
tracing::debug!("Fetched {} 🦖 mixnodes", mixnodes.len());
let mut duplicates = 0;
let mut legacy_not_in_nym_node_list = 0;
@@ -49,26 +70,22 @@ pub(crate) async fn get_mixing_nodes_for_scraping(pool: &DbPool) -> Result<Vec<S
for mixnode in mixnodes {
if nodes_to_scrape
.iter()
.all(|node| node.node_id != mixnode.node_id)
.all(|node| node.node_id() != &mixnode.node_id)
{
// in case polyfilling on Nym API gets removed, this part ensures
// mixnodes are added to the final list of nodes to scrape
nodes_to_scrape.push(ScraperNodeInfo {
node_kind: ScrapeNodeKind::LegacyMixnode {
mix_id: mixnode.node_id,
},
hosts: vec![mixnode.host],
http_api_port: mixnode.http_api_port,
});
legacy_not_in_nym_node_list += 1;
} else {
duplicates += 1;
}
// technically, mixnodes shouldn't be in nym_nodes table, but it's
// possible due to polyfilling on Nym API
if nodes_to_scrape
.iter()
.all(|node| node.node_id != mixnode.node_id)
{
nodes_to_scrape.push(ScraperNodeInfo {
node_id: mixnode.node_id,
node_kind: MixingNodeKind::LegacyMixnode,
hosts: vec![mixnode.host],
http_api_port: mixnode.http_api_port,
})
}
}
tracing::debug!(
"{}/{} legacy mixnodes already included in nym_node list",
@@ -85,19 +102,16 @@ pub(crate) async fn get_mixing_nodes_for_scraping(pool: &DbPool) -> Result<Vec<S
Ok(nodes_to_scrape)
}
// TODO: add stuff for gateways
pub(crate) async fn insert_scraped_node_description(
pool: &DbPool,
node_kind: &MixingNodeKind,
node_id: i64,
node_kind: &ScrapeNodeKind,
description: &NodeDescriptionResponse,
) -> Result<()> {
let timestamp = Utc::now().timestamp();
let mut conn = pool.acquire().await?;
match node_kind {
MixingNodeKind::LegacyMixnode => {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
sqlx::query!(
r#"
INSERT INTO mixnode_description (
@@ -110,7 +124,7 @@ pub(crate) async fn insert_scraped_node_description(
details = excluded.details,
last_updated_utc = excluded.last_updated_utc
"#,
node_id,
mix_id,
description.moniker,
description.website,
description.security_contact,
@@ -120,7 +134,7 @@ pub(crate) async fn insert_scraped_node_description(
.execute(&mut *conn)
.await?;
}
MixingNodeKind::NymNode => {
ScrapeNodeKind::MixingNymNode { node_id } => {
sqlx::query!(
r#"
INSERT INTO nym_node_descriptions (
@@ -143,6 +157,34 @@ pub(crate) async fn insert_scraped_node_description(
.execute(&mut *conn)
.await?;
}
ScrapeNodeKind::EntryExitNymNode { identity_key, .. } => {
sqlx::query!(
r#"
INSERT INTO gateway_description (
gateway_identity_key,
moniker,
website,
security_contact,
details,
last_updated_utc
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (gateway_identity_key) DO UPDATE SET
moniker = excluded.moniker,
website = excluded.website,
security_contact = excluded.security_contact,
details = excluded.details,
last_updated_utc = excluded.last_updated_utc
"#,
identity_key,
description.moniker,
description.website,
description.security_contact,
description.details,
timestamp,
)
.execute(&mut *conn)
.await?;
}
}
Ok(())
@@ -99,7 +99,10 @@ async fn get_stats(
Query(MixStatsQueryParams { offset }): Query<MixStatsQueryParams>,
State(state): State<AppState>,
) -> HttpResult<Json<Vec<DailyStats>>> {
let offset = offset.unwrap_or(0);
let offset: usize = offset
.unwrap_or(0)
.try_into()
.map_err(|_| HttpError::invalid_input("Offset must be non-negative"))?;
let last_30_days = state
.cache()
.get_mixnode_stats(state.db_pool(), offset)
@@ -17,18 +17,10 @@ pub(crate) async fn start_http_api(
nym_http_cache_ttl: u64,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
hm_url: String,
) -> anyhow::Result<ShutdownHandles> {
let router_builder = RouterBuilder::with_default_routes();
let state = AppState::new(
db_pool,
nym_http_cache_ttl,
agent_key_list,
agent_max_count,
hm_url,
)
.await;
let state = AppState::new(db_pool, nym_http_cache_ttl, agent_key_list, agent_max_count).await;
let router = router_builder.with_state(state);
let bind_addr = format!("0.0.0.0:{}", http_port);
@@ -25,11 +25,10 @@ impl AppState {
cache_ttl: u64,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
hm_url: String,
) -> Self {
Self {
db_pool,
cache: HttpCache::new(cache_ttl, hm_url).await,
cache: HttpCache::new(cache_ttl).await,
agent_key_list,
agent_max_count,
}
@@ -52,96 +51,14 @@ impl AppState {
}
}
#[derive(Debug, Clone)]
struct HistoricMixingStats {
historic_stats: Vec<DailyStats>,
}
impl HistoricMixingStats {
/// Collect historic stats only on initialization. From this point onwards,
/// service will collect its own stats
async fn init(hm_url: String) -> Self {
tracing::info!("Fetching historic mixnode stats from {}", hm_url);
let target_url = format!("{}/v2/mixnodes/stats", hm_url);
if let Ok(response) = reqwest::get(&target_url)
.await
.and_then(|res| res.error_for_status())
.inspect_err(|err| tracing::error!("Failed to fetch cache from HM: {}", err))
{
if let Ok(mut daily_stats) = response.json::<Vec<DailyStats>>().await {
// sorting required for seamless comparison later (descending, newest first)
daily_stats.sort_by(|left, right| right.date_utc.cmp(&left.date_utc));
tracing::info!(
"Successfully fetched {} historic entries from {}",
daily_stats.len(),
hm_url
);
return Self {
historic_stats: daily_stats,
};
}
};
tracing::warn!("Failed to get historic daily stats from {}", hm_url);
Self {
historic_stats: Vec::new(),
}
}
/// polyfill with historical data obtained from Harbour Master
fn merge_with_historic_stats(&self, mut new_stats: Vec<DailyStats>) -> Vec<DailyStats> {
// newest first
new_stats.sort_by(|left, right| right.date_utc.cmp(&left.date_utc));
// historic stats are only used for dates when we don't have new data
let oldest_date_in_new_stats = new_stats
.last()
.map(|day| day.date_utc.to_owned())
.unwrap_or(String::from("1900-01-01"));
// given 2 arrays
// index historic_stats new_stats
// 0 30-01 31-01
// 1 29-01 30-01
// 2 28-01
// ...
// N 01-01
// cutoff point would be at historic_stats[1]
// (first date smaller than oldest we've already got)
if let Some(cutoff) = self
.historic_stats
.iter()
.position(|elem| elem.date_utc < oldest_date_in_new_stats)
{
// missing data = (all historic data) - (however many days we already have)
let missing_data = self.historic_stats.iter().skip(cutoff).cloned();
// extend new data with missing days
tracing::debug!(
"Polyfilled with {} historic records from {:?} to {:?}",
missing_data.len(),
self.historic_stats.last(),
self.historic_stats.get(cutoff)
);
new_stats.extend(missing_data);
// oldest first
new_stats.into_iter().rev().collect::<Vec<_>>()
} else {
// if all historic data is older than what we've got, don't use it
new_stats
}
}
}
static GATEWAYS_LIST_KEY: &str = "gateways";
static MIXNODES_LIST_KEY: &str = "mixnodes";
static MIXSTATS_LIST_KEY: &str = "mixstats";
static SUMMARY_HISTORY_LIST_KEY: &str = "summary-history";
static SESSION_STATS_LIST_KEY: &str = "session-stats";
const MIXNODE_STATS_HISTORY_DAYS: usize = 30;
#[derive(Debug, Clone)]
pub(crate) struct HttpCache {
gateways: Cache<String, Arc<RwLock<Vec<Gateway>>>>,
@@ -149,11 +66,10 @@ pub(crate) struct HttpCache {
mixstats: Cache<String, Arc<RwLock<Vec<DailyStats>>>>,
history: Cache<String, Arc<RwLock<Vec<SummaryHistory>>>>,
session_stats: Cache<String, Arc<RwLock<Vec<SessionStats>>>>,
mixnode_historic_daily_stats: HistoricMixingStats,
}
impl HttpCache {
pub async fn new(ttl_seconds: u64, hm_url: String) -> Self {
pub async fn new(ttl_seconds: u64) -> Self {
HttpCache {
gateways: Cache::builder()
.max_capacity(2)
@@ -175,7 +91,6 @@ impl HttpCache {
.max_capacity(2)
.time_to_live(Duration::from_secs(ttl_seconds))
.build(),
mixnode_historic_daily_stats: HistoricMixingStats::init(hm_url).await,
}
}
@@ -285,26 +200,27 @@ impl HttpCache {
.await
}
pub async fn get_mixnode_stats(&self, db: &DbPool, offset: i64) -> Vec<DailyStats> {
match self.mixstats.get(MIXSTATS_LIST_KEY).await {
pub async fn get_mixnode_stats(&self, db: &DbPool, offset: usize) -> Vec<DailyStats> {
let mut stats = match self.mixstats.get(MIXSTATS_LIST_KEY).await {
Some(guard) => {
let read_lock = guard.read().await;
read_lock.to_vec()
}
None => {
let new_node_stats = crate::db::queries::get_daily_stats(db, offset)
let new_node_stats = crate::db::queries::get_daily_stats(db)
.await
.unwrap_or_default();
// for every day that's missing, fill it with cached historic data
let mut mixnode_stats = self
.mixnode_historic_daily_stats
.merge_with_historic_stats(new_node_stats);
mixnode_stats.truncate(30);
self.upsert_mixnode_stats(mixnode_stats.clone()).await;
mixnode_stats
.unwrap_or_default()
.into_iter()
.rev()
.collect::<Vec<_>>();
// cache result without offset
self.upsert_mixnode_stats(new_node_stats.clone()).await;
new_node_stats
}
}
};
stats.truncate(MIXNODE_STATS_HISTORY_DAYS + offset);
stats.into_iter().skip(offset).rev().collect()
}
pub async fn get_summary_history(&self, db: &DbPool) -> Vec<SummaryHistory> {
@@ -34,6 +34,8 @@ pub(crate) fn setup_tracing_logger() -> anyhow::Result<()> {
"tower_http",
"axum",
"html5ever",
"hickory_proto",
"hickory_resolver",
];
for crate_name in warn_crates {
filter = filter.add_directive(directive_checked(format!("{}=warn", crate_name))?);
@@ -10,6 +10,7 @@ mod mixnet_scraper;
mod monitor;
mod node_scraper;
mod testruns;
mod utils;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -66,7 +67,6 @@ async fn main() -> anyhow::Result<()> {
args.nym_http_cache_ttl,
agent_key_list.to_owned(),
args.max_agent_count,
args.hm_url,
)
.await
.expect("Failed to start server");
@@ -1,12 +1,15 @@
use crate::db::{
models::{NodeStats, ScraperNodeInfo},
queries::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
insert_scraped_node_description,
use crate::{
db::{
models::{NodeStats, ScraperNodeInfo},
queries::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
insert_scraped_node_description,
},
},
utils::generate_node_name,
};
use ammonia::Builder;
use anyhow::Result;
use anyhow::{anyhow, Result};
use chrono::{DateTime, Datelike, Utc};
use reqwest;
use serde::{Deserialize, Serialize};
@@ -80,22 +83,33 @@ pub fn build_client() -> Result<reqwest::Client> {
.map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))
}
pub fn sanitize_description(description: NodeDescriptionResponse) -> NodeDescriptionResponse {
pub fn sanitize_description(
description: NodeDescriptionResponse,
node_id: i64,
) -> NodeDescriptionResponse {
let mut sanitizer = Builder::new();
sanitizer
.tags(std::collections::HashSet::new())
.generic_attributes(std::collections::HashSet::new())
.url_schemes(std::collections::HashSet::new());
const UNKNOWN: &str = "N/A";
let sanitize_field = |opt: Option<String>| -> Option<String> {
Some(
opt.filter(|s| !s.trim().is_empty())
.map_or_else(|| "N/A".to_string(), |s| sanitizer.clean(&s).to_string()),
.map_or_else(|| UNKNOWN.to_string(), |s| sanitizer.clean(&s).to_string()),
)
};
let mut moniker = sanitize_field(description.moniker);
if let Some(sanitized) = &moniker {
if sanitized == UNKNOWN {
moniker = Some(generate_node_name(node_id));
}
};
NodeDescriptionResponse {
moniker: sanitize_field(description.moniker),
moniker,
website: sanitize_field(description.website),
security_contact: sanitize_field(description.security_contact),
details: sanitize_field(description.details),
@@ -108,18 +122,26 @@ pub async fn scrape_and_store_description(pool: &SqlitePool, node: &ScraperNodeI
let mut description = None;
let mut error = None;
let mut tried_url_list = Vec::new();
for mut url in urls {
url = format!("{}{}", url.trim_end_matches('/'), DESCRIPTION_URL);
tried_url_list.push(url.clone());
match client.get(&url).send().await {
match client
.get(&url)
.send()
.await
// convert 404 and similar to error
.and_then(|res| res.error_for_status())
{
Ok(response) => {
if let Ok(desc) = response.json::<NodeDescriptionResponse>().await {
description = Some(desc);
break;
}
}
Err(e) => error = Some(e),
Err(e) => error = Some(anyhow!("{:?} ({})", tried_url_list, e)),
}
}
@@ -128,9 +150,8 @@ pub async fn scrape_and_store_description(pool: &SqlitePool, node: &ScraperNodeI
anyhow::anyhow!("Failed to fetch description from any URL: {}", err_msg)
})?;
let sanitized_description = sanitize_description(description);
insert_scraped_node_description(pool, &node.node_kind, node.node_id, &sanitized_description)
.await?;
let sanitized_description = sanitize_description(description, *node.node_id());
insert_scraped_node_description(pool, &node.node_kind, &sanitized_description).await?;
Ok(())
}
@@ -144,9 +165,11 @@ pub async fn scrape_and_store_packet_stats(
let mut stats = None;
let mut error = None;
let mut tried_url_list = Vec::new();
for mut url in urls {
url = format!("{}{}", url.trim_end_matches('/'), PACKET_STATS_URL);
tried_url_list.push(url.clone());
match client.get(&url).send().await {
Ok(response) => {
@@ -155,18 +178,18 @@ pub async fn scrape_and_store_packet_stats(
break;
}
}
Err(e) => error = Some(e),
Err(e) => error = Some(anyhow!("{:?} ({})", tried_url_list, e)),
}
}
let stats = stats.ok_or_else(|| {
let err_msg = error.map_or_else(|| "Unknown error".to_string(), |e| e.to_string());
anyhow::anyhow!("Failed to fetch stats from any URL: {}", err_msg)
anyhow::anyhow!("Failed to fetch description from any URL: {}", err_msg)
})?;
let timestamp = Utc::now();
let timestamp_utc = timestamp.timestamp();
insert_node_packet_stats(pool, node.node_id, &node.node_kind, &stats, timestamp_utc).await?;
insert_node_packet_stats(pool, &node.node_kind, &stats, timestamp_utc).await?;
// Update daily stats
update_daily_stats(pool, node, timestamp, &stats).await?;
@@ -8,7 +8,7 @@ use sqlx::SqlitePool;
use tracing::{debug, error, instrument, warn};
use crate::db::models::ScraperNodeInfo;
use crate::db::queries::get_mixing_nodes_for_scraping;
use crate::db::queries::get_nodes_for_scraping;
const DESCRIPTION_SCRAPE_INTERVAL: Duration = Duration::from_secs(60 * 60 * 4);
const PACKET_SCRAPE_INTERVAL: Duration = Duration::from_secs(60 * 60);
@@ -74,7 +74,7 @@ impl Scraper {
pool: &SqlitePool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
let nodes = get_mixing_nodes_for_scraping(pool).await?;
let nodes = get_nodes_for_scraping(pool).await?;
if let Ok(mut queue_lock) = queue.lock() {
queue_lock.extend(nodes);
} else {
@@ -82,7 +82,7 @@ impl Scraper {
return Ok(());
}
Self::process_description_queue(pool, queue).await?;
Self::process_description_queue(pool, queue).await;
Ok(())
}
@@ -91,7 +91,7 @@ impl Scraper {
pool: &SqlitePool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
let nodes = get_mixing_nodes_for_scraping(pool).await?;
let nodes = get_nodes_for_scraping(pool).await?;
tracing::info!("Querying {} mixing nodes", nodes.len());
if let Ok(mut queue_lock) = queue.lock() {
queue_lock.extend(nodes);
@@ -100,14 +100,11 @@ impl Scraper {
return Ok(());
}
Self::process_packet_queue(pool, queue).await?;
Self::process_packet_queue(pool, queue).await;
Ok(())
}
async fn process_description_queue(
pool: &SqlitePool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
async fn process_description_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
@@ -132,12 +129,15 @@ impl Scraper {
tokio::spawn(async move {
match scrape_and_store_description(&pool, &node).await {
Ok(_) => debug!(
"✅ Description task #{} for node {} complete",
task_id, node.node_id
"📝 ✅ Description task #{} for node {} complete",
task_id,
node.node_id()
),
Err(e) => debug!(
"❌ Description task #{} for node {} failed: {}",
task_id, node.node_id, e
"📝 ❌ Description task #{} for node {} failed: {}",
task_id,
node.node_id(),
e
),
}
TASK_COUNTER.fetch_sub(1, Ordering::Relaxed);
@@ -146,13 +146,9 @@ impl Scraper {
tokio::time::sleep(QUEUE_CHECK_INTERVAL).await;
}
}
Ok(())
}
async fn process_packet_queue(
pool: &SqlitePool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
async fn process_packet_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
@@ -177,12 +173,15 @@ impl Scraper {
tokio::spawn(async move {
match scrape_and_store_packet_stats(&pool, &node).await {
Ok(_) => debug!(
"✅ Packet stats task #{} for node {} complete",
task_id, node.node_id
"📊 ✅ Packet stats task #{} for node {} complete",
task_id,
node.node_id()
),
Err(e) => debug!(
"❌ Packet stats task #{} for node {} failed: {}",
task_id, node.node_id, e
"📊 ❌ Packet stats task #{} for node {} failed: {}",
task_id,
node.node_id(),
e
),
}
TASK_COUNTER.fetch_sub(1, Ordering::Relaxed);
@@ -191,6 +190,5 @@ impl Scraper {
tokio::time::sleep(QUEUE_CHECK_INTERVAL).await;
}
}
Ok(())
}
}
@@ -1,14 +1,14 @@
#![allow(deprecated)]
use crate::db::models::{
gateway, mixnode, GatewayRecord, MixnodeRecord, NetworkSummary, ASSIGNED_ENTRY_COUNT,
gateway, mixnode, GatewayInsertRecord, MixnodeRecord, NetworkSummary, ASSIGNED_ENTRY_COUNT,
ASSIGNED_EXIT_COUNT, ASSIGNED_MIXING_COUNT, GATEWAYS_BONDED_COUNT, GATEWAYS_HISTORICAL_COUNT,
MIXNODES_HISTORICAL_COUNT, MIXNODES_LEGACY_COUNT, NYMNODES_DESCRIBED_COUNT, NYMNODE_COUNT,
};
use crate::db::{queries, DbPool};
use crate::monitor::geodata::{Location, NodeGeoData};
use crate::utils::{decimal_to_i64, LogError, NumericalCheckedCast};
use anyhow::anyhow;
use cosmwasm_std::Decimal;
use moka::future::Cache;
use nym_network_defaults::NymNetworkDetails;
use nym_validator_client::client::{NodeId, NymApiClientExt};
@@ -29,7 +29,6 @@ pub(crate) use geodata::IpInfoClient;
mod geodata;
// TODO dz should be configurable
const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60);
static DELEGATION_PROGRAM_WALLET: &str = "n1rnxpdpx3kldygsklfft0gech7fhfcux4zst5lw";
@@ -109,7 +108,11 @@ impl Monitor {
let gateways = described_nodes
.iter()
.filter(|node| node.description.declared_role.entry)
.filter(|node| {
node.description.declared_role.entry
|| node.description.declared_role.exit_ipr
|| node.description.declared_role.exit_nr
})
.collect::<Vec<_>>();
let bonded_node_info = api_client
@@ -120,12 +123,18 @@ impl Monitor {
// for faster reads
.collect::<HashMap<_, _>>();
tracing::info!("🟣 bonded_nodes: {}", bonded_node_info.len());
let nym_nodes = api_client
.get_all_basic_nodes()
.await
.log_error("get_all_basic_nodes")?;
queries::insert_nym_nodes(&self.db_pool, nym_nodes.clone(), &bonded_node_info).await?;
queries::insert_nym_nodes(&self.db_pool, nym_nodes.clone(), &bonded_node_info)
.await
.map(|_| {
tracing::debug!("{} nym nodes written to DB!", nym_nodes.len());
})?;
let mut gateway_geodata = Vec::new();
for gateway in gateways.iter() {
@@ -198,10 +207,11 @@ impl Monitor {
let gateway_records = self.prepare_gateway_data(&gateways, gateway_geodata, &nym_nodes)?;
let pool = self.db_pool.clone();
let gateways_count = gateway_records.len();
queries::insert_gateways(&pool, gateway_records)
.await
.map(|_| {
tracing::debug!("Gateway info written to DB!");
tracing::debug!("{} gateway records written to DB!", gateways_count);
})?;
let mixnode_records = self.prepare_mixnode_data(
@@ -209,10 +219,11 @@ impl Monitor {
mixnodes_described,
delegation_program_members,
)?;
let mixnodes_count = mixnode_records.len();
queries::insert_mixnodes(&pool, mixnode_records)
.await
.map(|_| {
tracing::debug!("Mixnode info written to DB!");
tracing::debug!("{} mixnode info written to DB!", mixnodes_count);
})?;
let (all_historical_gateways, all_historical_mixnodes) = calculate_stats(&pool).await?;
@@ -299,13 +310,13 @@ impl Monitor {
fn prepare_gateway_data(
&self,
gateways: &[&NymNodeDescription],
described_gateways: &[&NymNodeDescription],
gateway_geodata: Vec<NodeGeoData>,
skimmed_gateways: &[SkimmedNode],
) -> anyhow::Result<Vec<GatewayRecord>> {
) -> anyhow::Result<Vec<GatewayInsertRecord>> {
let mut gateway_records = Vec::new();
for gateway in gateways {
for gateway in described_gateways {
let identity_key = gateway.ed25519_identity_key().to_base58_string();
let bonded = true;
let last_updated_utc = chrono::offset::Utc::now().timestamp();
@@ -329,7 +340,7 @@ impl Monitor {
.unwrap_or_default()
.round_to_integer();
gateway_records.push(GatewayRecord {
gateway_records.push(GatewayInsertRecord {
identity_key: identity_key.to_owned(),
bonded,
self_described,
@@ -400,33 +411,6 @@ impl Monitor {
}
}
// TODO dz is there a common monorepo place this can be put?
pub trait NumericalCheckedCast<T>
where
T: TryFrom<Self>,
<T as TryFrom<Self>>::Error: std::error::Error,
Self: std::fmt::Display + Copy,
{
fn cast_checked(self) -> anyhow::Result<T> {
T::try_from(self).map_err(|e| {
anyhow::anyhow!(
"Couldn't cast {} to {}: {}",
self,
std::any::type_name::<T>(),
e
)
})
}
}
impl<T, U> NumericalCheckedCast<U> for T
where
U: TryFrom<T>,
<U as TryFrom<T>>::Error: std::error::Error,
T: std::fmt::Display + Copy,
{
}
async fn calculate_stats(pool: &DbPool) -> anyhow::Result<(usize, usize)> {
let mut conn = pool.acquire().await?;
@@ -464,39 +448,3 @@ async fn get_delegation_program_details(
Ok(mix_ids)
}
pub(crate) fn decimal_to_i64(decimal: Decimal) -> i64 {
// Convert the underlying Uint128 to a u128
let atomics = decimal.atomics().u128();
let precision = 1_000_000_000_000_000_000u128;
// Get the fractional part
let fractional = atomics % precision;
// Get the integer part
let integer = atomics / precision;
// Combine them into a float
let float_value = integer as f64 + (fractional as f64 / 1_000_000_000_000_000_000_f64);
// Limit to 6 decimal places
let rounded_value = (float_value * 1_000_000.0).round() / 1_000_000.0;
rounded_value as i64
}
trait LogError<T, E> {
fn log_error(self, msg: &str) -> Result<T, E>;
}
impl<T, E> LogError<T, E> for anyhow::Result<T, E>
where
E: std::error::Error,
{
fn log_error(self, msg: &str) -> Result<T, E> {
if let Err(e) = &self {
tracing::error!("[{msg}]:\t{e}");
}
self
}
}
@@ -17,15 +17,14 @@ use tracing::instrument;
mod error;
const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60);
const REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6); //6h, data only update once a day
const REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6);
const STALE_DURATION: Duration = Duration::from_secs(86400 * 365); //one year
#[instrument(level = "debug", name = "node_scraper", skip_all)]
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
pub(crate) async fn spawn_in_background(db_pool: DbPool, nym_api_client_timeout: Duration) {
let network_defaults = nym_network_defaults::NymNetworkDetails::new_from_env();
loop {
//No graceful shutdown?
tracing::info!("Refreshing node self-described metrics...");
if let Err(e) = run(&db_pool, &network_defaults, nym_api_client_timeout).await {
@@ -123,7 +122,7 @@ impl MetricsScrapingData {
}
}
#[instrument(level = "debug", name = "metrics_scraper", skip_all)]
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
async fn try_scrape_metrics(&self) -> Option<SessionStats> {
match self.try_get_client().await {
Ok(client) => {
@@ -0,0 +1,104 @@
use cosmwasm_std::Decimal;
use itertools::Itertools;
use rand::prelude::SliceRandom;
use rand::SeedableRng;
// pub(crate) fn generate_node_name(identity: ed25519::PublicKey) -> String {
pub(crate) fn generate_node_name(node_id: i64) -> String {
let seed = {
let node_id_bytes = node_id.to_le_bytes();
let mut seed = [0u8; 32];
for i in 0..4 {
seed[i * 8..(i + 1) * 8].copy_from_slice(&node_id_bytes);
}
seed
};
let mut rng = rand_chacha::ChaCha20Rng::from_seed(seed);
let words = bip39::Language::English.word_list();
words.choose_multiple(&mut rng, 3).join(" ")
}
#[allow(clippy::items_after_test_module)]
#[cfg(test)]
mod test {
use rand::Rng;
use super::*;
#[test]
fn generate_node_name_should_be_deterministic() {
let mut rng = rand::thread_rng();
let node_id: i64 = rng.gen();
let different_node_id: i64 = rng.gen();
let node_name = generate_node_name(node_id);
let node_name_different = generate_node_name(different_node_id);
assert_ne!(node_name, node_name_different);
let node_name_same = generate_node_name(node_id);
assert_eq!(node_name, node_name_same);
}
}
pub trait NumericalCheckedCast<T>
where
T: TryFrom<Self>,
<T as TryFrom<Self>>::Error: std::error::Error,
Self: std::fmt::Display + Copy,
{
fn cast_checked(self) -> anyhow::Result<T> {
T::try_from(self).map_err(|e| {
anyhow::anyhow!(
"Couldn't cast {} to {}: {}",
self,
std::any::type_name::<T>(),
e
)
})
}
}
impl<T, U> NumericalCheckedCast<U> for T
where
U: TryFrom<T>,
<U as TryFrom<T>>::Error: std::error::Error,
T: std::fmt::Display + Copy,
{
}
pub(crate) fn decimal_to_i64(decimal: Decimal) -> i64 {
// Convert the underlying Uint128 to a u128
let atomics = decimal.atomics().u128();
let precision = 1_000_000_000_000_000_000u128;
// Get the fractional part
let fractional = atomics % precision;
// Get the integer part
let integer = atomics / precision;
// Combine them into a float
let float_value = integer as f64 + (fractional as f64 / 1_000_000_000_000_000_000_f64);
// Limit to 6 decimal places
let rounded_value = (float_value * 1_000_000.0).round() / 1_000_000.0;
rounded_value as i64
}
pub(crate) trait LogError<T, E> {
fn log_error(self, msg: &str) -> Result<T, E>;
}
impl<T, E> LogError<T, E> for anyhow::Result<T, E>
where
E: std::error::Error,
{
fn log_error(self, msg: &str) -> Result<T, E> {
if let Err(e) = &self {
tracing::error!("[{msg}]:\t{e}");
}
self
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-node
@@ -65,7 +66,7 @@ RUN cargo build --release
# see https://github.com/nymtech/nym/blob/develop/nym-node/src/env.rs for details
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
WORKDIR /nym
-1
View File
@@ -63,7 +63,6 @@ impl MixingStats {
.or_default()
.forward_packets
.received += 1;
*self.ingress.received_versions.entry(version).or_default() += 1;
}
+7 -2
View File
@@ -4,8 +4,8 @@
use crate::config::upgrade_helpers::try_load_current_config;
use crate::error::NymNodeError;
use crate::node::bonding_information::BondingInformation;
use crate::node::mixnet::packet_forwarding::global::is_global_ip;
use crate::node::NymNode;
use nym_config::helpers::SPECIAL_ADDRESSES;
use std::fs;
use std::net::IpAddr;
use tracing::{debug, info, trace, warn};
@@ -17,7 +17,7 @@ pub(crate) use args::Args;
fn check_public_ips(ips: &[IpAddr], local: bool) -> Result<(), NymNodeError> {
let mut suspicious_ip = Vec::new();
for ip in ips {
if SPECIAL_ADDRESSES.contains(ip) {
if !is_global_ip(ip) {
if !local {
return Err(NymNodeError::InvalidPublicIp { address: *ip });
}
@@ -92,6 +92,11 @@ pub(crate) async fn execute(mut args: Args) -> Result<(), NymNodeError> {
}
check_public_ips(&config.host.public_ips, local)?;
let mut config = config;
if local {
config.debug.testnet = true
}
let nym_node = NymNode::new(config)
.await?
.with_accepted_operator_terms_and_conditions(accepted_operator_terms_and_conditions);
+12 -1
View File
@@ -50,6 +50,13 @@ pub struct Debug {
/// The maximum number of client connections the gateway will keep open at once.
pub maximum_open_connections: usize,
/// Specifies the minimum performance of mixnodes in the network that are to be used in internal topologies
/// of the services providers
pub minimum_mix_performance: u8,
/// Defines the maximum age of a signed authentication request before it's deemed too stale to process.
pub maximum_auth_request_age: Duration,
pub stale_messages: StaleMessageDebug,
pub client_bandwidth: ClientBandwidthDebug,
@@ -58,7 +65,9 @@ pub struct Debug {
}
impl Debug {
const DEFAULT_MESSAGE_RETRIEVAL_LIMIT: i64 = 100;
pub const DEFAULT_MESSAGE_RETRIEVAL_LIMIT: i64 = 100;
pub const DEFAULT_MINIMUM_MIX_PERFORMANCE: u8 = 50;
pub const DEFAULT_MAXIMUM_AUTH_REQUEST_AGE: Duration = Duration::from_secs(30);
const DEFAULT_MAXIMUM_OPEN_CONNECTIONS: usize = 8192;
}
@@ -67,6 +76,8 @@ impl Default for Debug {
Debug {
message_retrieval_limit: Self::DEFAULT_MESSAGE_RETRIEVAL_LIMIT,
maximum_open_connections: Self::DEFAULT_MAXIMUM_OPEN_CONNECTIONS,
maximum_auth_request_age: Self::DEFAULT_MAXIMUM_AUTH_REQUEST_AGE,
minimum_mix_performance: Self::DEFAULT_MINIMUM_MIX_PERFORMANCE,
stale_messages: Default::default(),
client_bandwidth: Default::default(),
zk_nym_tickets: Default::default(),
+1
View File
@@ -60,6 +60,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config {
.zk_nym_tickets
.maximum_time_between_redemption,
},
maximum_auth_request_age: config.gateway_tasks.debug.maximum_auth_request_age,
},
)
}
+11 -1
View File
@@ -780,16 +780,26 @@ pub struct Debug {
/// Specifies the time to live of the internal topology provider cache.
#[serde(with = "humantime_serde")]
pub topology_cache_ttl: Duration,
/// Specifies the time between attempting to resolve any pending unknown nodes in the routing filter
#[serde(with = "humantime_serde")]
pub routing_nodes_check_interval: Duration,
/// Specifies whether this node runs in testnet mode thus allowing it to route packets on local interfaces
pub testnet: bool,
}
impl Debug {
pub const DEFAULT_TOPOLOGY_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
pub const DEFAULT_TOPOLOGY_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
pub const DEFAULT_ROUTING_NODES_CHECK_INTERVAL: Duration = Duration::from_secs(5 * 60);
}
impl Default for Debug {
fn default() -> Self {
Debug {
topology_cache_ttl: Self::DEFAULT_TOPOLOGY_CACHE_TTL,
routing_nodes_check_interval: Self::DEFAULT_ROUTING_NODES_CHECK_INTERVAL,
testnet: false,
}
}
}
+6
View File
@@ -4,6 +4,7 @@
use crate::node::http::error::NymNodeHttpError;
use crate::wireguard::error::WireguardError;
use nym_ip_packet_router::error::ClientCoreError;
use nym_validator_client::ValidatorClientError;
use std::io;
use std::net::IpAddr;
use std::path::PathBuf;
@@ -141,6 +142,11 @@ pub enum NymNodeError {
source: ipnetwork::IpNetworkError,
},
#[error(
"failed to retrieve initial network topology - can't start the node without it: {source}"
)]
InitialTopologyQueryFailure { source: ValidatorClientError },
#[error(transparent)]
GatewayFailure(#[from] nym_gateway::GatewayError),
@@ -0,0 +1,79 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
// use `ip` feature without nightly
// issue: https://github.com/rust-lang/rust/issues/27709
pub(crate) const fn is_global_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(addr) => is_global_ipv4(addr),
IpAddr::V6(addr) => is_global_ipv6(addr),
}
}
const fn is_shared_ipv4(ip: &Ipv4Addr) -> bool {
ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000)
}
const fn is_benchmarking_ipv4(ip: &Ipv4Addr) -> bool {
ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18
}
const fn is_reserved_ipv4(ip: &Ipv4Addr) -> bool {
ip.octets()[0] & 240 == 240 && !ip.is_broadcast()
}
const fn is_global_ipv4(ip: &Ipv4Addr) -> bool {
!(ip.octets()[0] == 0 // "This network"
|| ip.is_private()
|| is_shared_ipv4(ip)
|| ip.is_loopback()
|| ip.is_link_local()
// addresses reserved for future protocols (`192.0.0.0/24`)
// .9 and .10 are documented as globally reachable so they're excluded
|| (
ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
&& ip.octets()[3] != 9 && ip.octets()[3] != 10
)
|| ip.is_documentation()
|| is_benchmarking_ipv4(ip)
|| is_reserved_ipv4(ip)
|| ip.is_broadcast())
}
const fn is_documentation_ipv6(ip: &Ipv6Addr) -> bool {
(ip.segments()[0] == 0x2001) && (ip.segments()[1] == 0xdb8)
}
const fn is_global_ipv6(ip: &Ipv6Addr) -> bool {
!(ip.is_unspecified()
|| ip.is_loopback()
// IPv4-mapped Address (`::ffff:0:0/96`)
|| matches!(ip.segments(), [0, 0, 0, 0, 0, 0xffff, _, _])
// IPv4-IPv6 Translat. (`64:ff9b:1::/48`)
|| matches!(ip.segments(), [0x64, 0xff9b, 1, _, _, _, _, _])
// Discard-Only Address Block (`100::/64`)
|| matches!(ip.segments(), [0x100, 0, 0, 0, _, _, _, _])
// IETF Protocol Assignments (`2001::/23`)
|| (matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if b < 0x200)
&& !(
// Port Control Protocol Anycast (`2001:1::1`)
u128::from_be_bytes(ip.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0001
// Traversal Using Relays around NAT Anycast (`2001:1::2`)
|| u128::from_be_bytes(ip.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0002
// AMT (`2001:3::/32`)
|| matches!(ip.segments(), [0x2001, 3, _, _, _, _, _, _])
// AS112-v6 (`2001:4:112::/48`)
|| matches!(ip.segments(), [0x2001, 4, 0x112, _, _, _, _, _])
// ORCHIDv2 (`2001:20::/28`)
// Drone Remote ID Protocol Entity Tags (DETs) Prefix (`2001:30::/28`)`
|| matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if b >= 0x20 && b <= 0x3F)
))
// 6to4 (`2002::/16`) it's not explicitly documented as globally reachable,
// IANA says N/A.
|| matches!(ip.segments(), [0x2002, _, _, _, _, _, _, _])
|| is_documentation_ipv6(ip)
|| ip.is_unique_local()
|| ip.is_unicast_link_local())
}
@@ -1,6 +1,8 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::mixnet::packet_forwarding::global::is_global_ip;
use crate::node::shared_network::RoutingFilter;
use futures::StreamExt;
use nym_mixnet_client::forwarder::{
mix_forwarding_channels, MixForwardingReceiver, MixForwardingSender, PacketToForward,
@@ -11,14 +13,20 @@ use nym_nonexhaustive_delayqueue::{Expired, NonExhaustiveDelayQueue};
use nym_sphinx_forwarding::packet::MixPacket;
use nym_task::ShutdownToken;
use std::io;
use std::net::IpAddr;
use tokio::time::Instant;
use tracing::{debug, error, trace, warn};
pub(crate) mod global;
pub struct PacketForwarder<C> {
testnet: bool,
delay_queue: NonExhaustiveDelayQueue<MixPacket>,
mixnet_client: C,
metrics: NymNodeMetrics,
routing_filter: RoutingFilter,
packet_sender: MixForwardingSender,
packet_receiver: MixForwardingReceiver,
@@ -26,13 +34,21 @@ pub struct PacketForwarder<C> {
}
impl<C> PacketForwarder<C> {
pub fn new(client: C, metrics: NymNodeMetrics, shutdown: ShutdownToken) -> Self {
pub fn new(
client: C,
testnet: bool,
routing_filter: RoutingFilter,
metrics: NymNodeMetrics,
shutdown: ShutdownToken,
) -> Self {
let (packet_sender, packet_receiver) = mix_forwarding_channels();
PacketForwarder {
testnet,
delay_queue: NonExhaustiveDelayQueue::new(),
mixnet_client: client,
metrics,
routing_filter,
packet_sender,
packet_receiver,
shutdown,
@@ -43,11 +59,29 @@ impl<C> PacketForwarder<C> {
self.packet_sender.clone()
}
fn should_route(&mut self, ip_addr: IpAddr) -> bool {
// only allow non-global ips on testnets
if self.testnet && !is_global_ip(&ip_addr) {
return true;
}
self.routing_filter.attempt_resolve(ip_addr).should_route()
}
fn forward_packet(&mut self, packet: MixPacket)
where
C: SendWithoutResponse,
{
let next_hop = packet.next_hop();
if !self.should_route(next_hop.as_ref().ip()) {
debug!("dropping packet as the egress address does not belong to any known node");
self.metrics
.mixnet
.egress_dropped_forward_packet(next_hop.into());
return;
}
let packet_type = packet.packet_type();
let packet = packet.into_packet();
+26 -12
View File
@@ -28,7 +28,9 @@ use crate::node::metrics::handler::pending_egress_packets_updater::PendingEgress
use crate::node::mixnet::packet_forwarding::PacketForwarder;
use crate::node::mixnet::shared::ProcessingConfig;
use crate::node::mixnet::SharedFinalHopData;
use crate::node::shared_topology::NymNodeTopologyProvider;
use crate::node::shared_network::{
CachedNetwork, CachedTopologyProvider, NetworkRefresher, RoutingFilter,
};
use nym_bin_common::bin_info;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_gateway::node::{ActiveClientsStore, GatewayTasksBuilder};
@@ -67,7 +69,7 @@ pub mod helpers;
pub(crate) mod http;
pub(crate) mod metrics;
pub(crate) mod mixnet;
mod shared_topology;
mod shared_network;
pub struct GatewayTasksData {
mnemonic: Arc<Zeroizing<bip39::Mnemonic>>,
@@ -530,16 +532,15 @@ impl NymNode {
self.x25519_noise_keys.public_key()
}
// the reason it's here as opposed to in the gateway directly,
// is that other nym-node tasks will also eventually need it
// (such as the ones for obtaining noise keys of other nodes)
fn build_topology_provider(&self) -> Result<NymNodeTopologyProvider, NymNodeError> {
Ok(NymNodeTopologyProvider::new(
self.as_gateway_topology_node()?,
self.config.debug.topology_cache_ttl,
async fn build_network_refresher(&self) -> Result<NetworkRefresher, NymNodeError> {
NetworkRefresher::initialise_new(
self.user_agent(),
self.config.mixnet.nym_api_urls.clone(),
))
self.config.debug.topology_cache_ttl,
self.config.debug.routing_nodes_check_interval,
self.shutdown_manager.clone_token("network-refresher"),
)
.await
}
fn as_gateway_topology_node(&self) -> Result<nym_topology::RoutingNode, NymNodeError> {
@@ -583,13 +584,19 @@ impl NymNode {
async fn start_gateway_tasks(
&mut self,
cached_network: CachedNetwork,
metrics_sender: MetricEventsSender,
active_clients_store: ActiveClientsStore,
mix_packet_sender: MixForwardingSender,
task_client: TaskClient,
) -> Result<(), NymNodeError> {
let config = gateway_tasks_config(&self.config);
let topology_provider = Box::new(self.build_topology_provider()?);
let topology_provider = Box::new(CachedTopologyProvider::new(
self.as_gateway_topology_node()?,
cached_network,
self.config.gateway_tasks.debug.minimum_mix_performance,
));
let mut gateway_tasks_builder = GatewayTasksBuilder::new(
config.gateway,
@@ -952,6 +959,7 @@ impl NymNode {
pub(crate) fn start_mixnet_listener(
&self,
active_clients_store: &ActiveClientsStore,
routing_filter: RoutingFilter,
shutdown: ShutdownToken,
) -> (MixForwardingSender, ActiveConnections) {
let processing_config = ProcessingConfig::new(&self.config);
@@ -980,6 +988,8 @@ impl NymNode {
let mut packet_forwarder = PacketForwarder::new(
mixnet_client,
self.config.debug.testnet,
routing_filter,
self.metrics.clone(),
shutdown.clone_with_suffix("mix-packet-forwarder"),
);
@@ -1028,13 +1038,14 @@ impl NymNode {
});
self.try_refresh_remote_nym_api_cache().await;
self.start_verloc_measurements();
let network_refresher = self.build_network_refresher().await?;
let active_clients_store = ActiveClientsStore::new();
let (mix_packet_sender, active_egress_mixnet_connections) = self.start_mixnet_listener(
&active_clients_store,
network_refresher.routing_filter(),
self.shutdown_manager.clone_token("mixnet-traffic"),
);
@@ -1045,6 +1056,7 @@ impl NymNode {
);
self.start_gateway_tasks(
network_refresher.cached_network(),
metrics_sender,
active_clients_store,
mix_packet_sender,
@@ -1052,6 +1064,8 @@ impl NymNode {
)
.await?;
network_refresher.start();
self.shutdown_manager.close();
self.shutdown_manager.wait_for_shutdown_signal().await;
+434
View File
@@ -0,0 +1,434 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::NymNodeError;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use nym_gateway::node::UserAgent;
use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS};
use nym_task::ShutdownToken;
use nym_topology::node::RoutingNode;
use nym_topology::{EpochRewardedSet, NymTopology, Role, TopologyProvider};
use nym_validator_client::nym_nodes::{NodesByAddressesResponse, SkimmedNode};
use nym_validator_client::{NymApiClient, ValidatorClientError};
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::interval;
use tracing::log::error;
use tracing::{debug, trace, warn};
use url::Url;
#[derive(Clone)]
pub(crate) struct RoutingFilter {
resolved: KnownNodes,
// while this is technically behind a lock, it should not be called too often as once resolved it will
// be present on the arcswap in either allowed or denied section
pending: UnknownNodes,
}
impl RoutingFilter {
fn new_empty() -> Self {
RoutingFilter {
resolved: Default::default(),
pending: Default::default(),
}
}
pub(crate) fn attempt_resolve(&self, ip: IpAddr) -> Resolution {
if self.resolved.inner.allowed.load().contains(&ip) {
Resolution::Accept
} else if self.resolved.inner.denied.load().contains(&ip) {
Resolution::Deny
} else {
self.pending.try_insert(ip);
Resolution::Unknown
}
}
}
#[derive(Clone, Default)]
struct UnknownNodes(Arc<RwLock<HashSet<IpAddr>>>);
impl UnknownNodes {
fn try_insert(&self, ip: IpAddr) {
// if we can immediately grab the lock to push it into the pending queue, amazing, let's do it
// otherwise we can do it next time we see this ip
// (if we can't hold the lock, it means it's being updated at this very moment which is actually a good thing)
if let Ok(mut guard) = self.0.try_write() {
guard.insert(ip);
}
}
async fn clear(&self) {
self.0.write().await.clear();
}
async fn nodes(&self) -> HashSet<IpAddr> {
self.0.read().await.clone()
}
}
// for now we don't care about keys, etc.
// we only want to know if given ip belongs to a known node
#[derive(Debug, Default, Clone)]
pub(crate) struct KnownNodes {
inner: Arc<KnownNodesInner>,
}
#[derive(Debug, Default)]
struct KnownNodesInner {
allowed: ArcSwap<HashSet<IpAddr>>,
denied: ArcSwap<HashSet<IpAddr>>,
}
pub(crate) enum Resolution {
Unknown,
Deny,
Accept,
}
impl From<bool> for Resolution {
fn from(value: bool) -> Self {
if value {
Resolution::Accept
} else {
Resolution::Deny
}
}
}
impl Resolution {
pub(crate) fn should_route(&self) -> bool {
matches!(self, Resolution::Accept)
}
}
impl KnownNodes {
fn swap_allowed(&self, new: HashSet<IpAddr>) {
self.inner.allowed.store(Arc::new(new))
}
fn swap_denied(&self, new: HashSet<IpAddr>) {
self.inner.denied.store(Arc::new(new))
}
}
struct NodesQuerier {
client: NymApiClient,
nym_api_urls: Vec<Url>,
currently_used_api: usize,
}
impl NodesQuerier {
fn use_next_nym_api(&mut self) {
if self.nym_api_urls.len() == 1 {
warn!("There's only a single nym API available - it won't be possible to use a different one");
return;
}
self.currently_used_api = (self.currently_used_api + 1) % self.nym_api_urls.len();
self.client
.change_nym_api(self.nym_api_urls[self.currently_used_api].clone())
}
async fn rewarded_set(&mut self) -> Result<EpochRewardedSet, ValidatorClientError> {
let res = self
.client
.get_current_rewarded_set()
.await
.inspect_err(|err| error!("failed to get current rewarded set: {err}"));
if res.is_err() {
self.use_next_nym_api()
}
res
}
async fn current_nymnodes(&mut self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
let res = self
.client
.get_all_basic_nodes()
.await
.inspect_err(|err| error!("failed to get network nodes: {err}"));
if res.is_err() {
self.use_next_nym_api()
}
res
}
async fn query_for_info(
&mut self,
ips: Vec<IpAddr>,
) -> Result<NodesByAddressesResponse, ValidatorClientError> {
let res = self
.client
.nodes_by_addresses(ips)
.await
.inspect_err(|err| error!("failed to obtain node information: {err}"));
if res.is_err() {
self.use_next_nym_api()
}
res
}
}
#[derive(Clone)]
pub struct CachedTopologyProvider {
gateway_node: Arc<RoutingNode>,
cached_network: CachedNetwork,
min_mix_performance: u8,
}
impl CachedTopologyProvider {
pub(crate) fn new(
gateway_node: RoutingNode,
cached_network: CachedNetwork,
min_mix_performance: u8,
) -> Self {
CachedTopologyProvider {
gateway_node: Arc::new(gateway_node),
cached_network,
min_mix_performance,
}
}
}
#[async_trait]
impl TopologyProvider for CachedTopologyProvider {
async fn get_new_topology(&mut self) -> Option<NymTopology> {
let network_guard = self.cached_network.inner.read().await;
let self_node = self.gateway_node.identity_key;
let mut topology = NymTopology::new_empty(network_guard.rewarded_set.clone())
.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
}
}));
if !topology.has_node_details(self.gateway_node.node_id) {
debug!("{self_node} didn't exist in topology. inserting it.",);
topology.insert_node_details(self.gateway_node.as_ref().clone());
}
topology.force_set_active(self.gateway_node.node_id, Role::EntryGateway);
Some(topology)
}
}
#[derive(Clone)]
pub(crate) struct CachedNetwork {
inner: Arc<RwLock<CachedNetworkInner>>,
}
impl CachedNetwork {
fn new_empty() -> Self {
CachedNetwork {
inner: Arc::new(RwLock::new(CachedNetworkInner {
rewarded_set: Default::default(),
network_nodes: vec![],
})),
}
}
}
struct CachedNetworkInner {
rewarded_set: EpochRewardedSet,
network_nodes: Vec<SkimmedNode>,
}
pub struct NetworkRefresher {
querier: NodesQuerier,
full_refresh_interval: Duration,
pending_check_interval: Duration,
shutdown_token: ShutdownToken,
network: CachedNetwork,
routing_filter: RoutingFilter,
}
impl NetworkRefresher {
pub(crate) async fn initialise_new(
user_agent: UserAgent,
nym_api_urls: Vec<Url>,
full_refresh_interval: Duration,
pending_check_interval: Duration,
shutdown_token: ShutdownToken,
) -> Result<Self, NymNodeError> {
let mut this = NetworkRefresher {
querier: NodesQuerier {
client: NymApiClient::new_with_user_agent(nym_api_urls[0].clone(), user_agent),
nym_api_urls,
currently_used_api: 0,
},
full_refresh_interval,
pending_check_interval,
shutdown_token,
network: CachedNetwork::new_empty(),
routing_filter: RoutingFilter::new_empty(),
};
this.obtain_initial_network().await?;
Ok(this)
}
fn allowed_nodes_copy(&self) -> HashSet<IpAddr> {
self.routing_filter
.resolved
.inner
.allowed
.load_full()
.as_ref()
.clone()
}
fn denied_nodes_copy(&self) -> HashSet<IpAddr> {
self.routing_filter
.resolved
.inner
.denied
.load_full()
.as_ref()
.clone()
}
async fn inspect_pending(&mut self) {
let to_resolve = self.routing_filter.pending.nodes().await;
// no pending requests to resolve
if to_resolve.is_empty() {
return;
}
let mut allowed = self.allowed_nodes_copy();
let mut denied = self.denied_nodes_copy();
// short circuit: check if the pending nodes are not already resolved
// (it could happen due to lack of full sync between pending lock and arcswap(s))
if to_resolve
.iter()
.all(|p| allowed.contains(p) || denied.contains(p))
{
return;
}
// 1. attempt to use the new nym-api query to get information just by ips
let nodes = to_resolve.into_iter().collect();
if let Ok(res) = self.querier.query_for_info(nodes).await {
for (ip, maybe_id) in res.existence {
if maybe_id.is_some() {
allowed.insert(ip);
} else {
denied.insert(ip);
}
}
self.routing_filter.resolved.swap_allowed(allowed);
self.routing_filter.resolved.swap_denied(denied);
self.routing_filter.pending.clear().await;
return;
}
// 2. we assume nym-api doesn't support that query yet - we have to do the full refresh
self.refresh_network_nodes().await;
}
async fn refresh_network_nodes_inner(&mut self) -> Result<(), ValidatorClientError> {
let rewarded_set = self.querier.rewarded_set().await?;
let nodes = self.querier.current_nymnodes().await?;
// collect all known/allowed nodes information
let known_nodes = nodes
.iter()
.flat_map(|n| n.ip_addresses.iter())
.copied()
.collect::<HashSet<_>>();
let pending = self.routing_filter.pending.nodes().await;
let mut current_denied = self.denied_nodes_copy();
for allowed in &known_nodes {
// if some node has become known, it should be removed from the denied set
current_denied.remove(allowed);
}
// any pending node, if not present in the new set of allowed nodes, should be added in the denied set
for pending_node in pending {
if !known_nodes.contains(&pending_node) {
current_denied.insert(pending_node);
}
}
self.routing_filter.resolved.swap_allowed(known_nodes);
self.routing_filter.resolved.swap_denied(current_denied);
self.routing_filter.pending.clear().await;
let mut network_guard = self.network.inner.write().await;
network_guard.network_nodes = nodes;
network_guard.rewarded_set = rewarded_set;
Ok(())
}
async fn refresh_network_nodes(&mut self) {
let timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::ProcessTopologyQueryResolutionLatency);
if self.refresh_network_nodes_inner().await.is_err() {
// don't use the histogram observation as some queries didn't complete
if let Some(obs) = timer {
obs.stop_and_discard();
}
}
}
pub(crate) async fn obtain_initial_network(&mut self) -> Result<(), NymNodeError> {
self.refresh_network_nodes_inner()
.await
.map_err(|source| NymNodeError::InitialTopologyQueryFailure { source })
}
pub(crate) fn routing_filter(&self) -> RoutingFilter {
self.routing_filter.clone()
}
pub(crate) fn cached_network(&self) -> CachedNetwork {
self.network.clone()
}
pub(crate) async fn run(&mut self) {
let mut full_refresh_interval = interval(self.full_refresh_interval);
full_refresh_interval.reset();
let mut pending_check_interval = interval(self.pending_check_interval);
pending_check_interval.reset();
while !self.shutdown_token.is_cancelled() {
tokio::select! {
biased;
_ = self.shutdown_token.cancelled() => {
trace!("NetworkRefresher: Received shutdown");
}
_ = pending_check_interval.tick() => {
self.inspect_pending().await;
}
_ = full_refresh_interval.tick() => {
self.refresh_network_nodes().await;
}
}
}
trace!("NetworkRefresher: Exiting");
}
pub(crate) fn start(mut self) {
tokio::spawn(async move { self.run().await });
}
}
-106
View File
@@ -1,106 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use async_trait::async_trait;
use nym_gateway::node::{NymApiTopologyProvider, NymApiTopologyProviderConfig, UserAgent};
use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS};
use nym_topology::node::RoutingNode;
use nym_topology::{NymTopology, Role, TopologyProvider};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use tracing::debug;
use url::Url;
// I wouldn't be surprised if this became the start of the node topology cache
#[derive(Clone)]
pub struct NymNodeTopologyProvider {
inner: Arc<Mutex<NymNodeTopologyProviderInner>>,
}
impl NymNodeTopologyProvider {
pub fn new(
gateway_node: RoutingNode,
cache_ttl: Duration,
user_agent: UserAgent,
nym_api_url: Vec<Url>,
) -> NymNodeTopologyProvider {
NymNodeTopologyProvider {
inner: Arc::new(Mutex::new(NymNodeTopologyProviderInner {
inner: NymApiTopologyProvider::new(
NymApiTopologyProviderConfig {
min_mixnode_performance: 50,
min_gateway_performance: 0,
use_extended_topology: false,
ignore_egress_epoch_role: true,
},
nym_api_url,
Some(user_agent),
),
cache_ttl,
cached_at: OffsetDateTime::UNIX_EPOCH,
cached: None,
gateway_node,
})),
}
}
}
struct NymNodeTopologyProviderInner {
inner: NymApiTopologyProvider,
cache_ttl: Duration,
cached_at: OffsetDateTime,
cached: Option<NymTopology>,
gateway_node: RoutingNode,
}
impl NymNodeTopologyProviderInner {
fn cached_topology(&self) -> Option<NymTopology> {
if let Some(cached_topology) = &self.cached {
if self.cached_at + self.cache_ttl > OffsetDateTime::now_utc() {
return Some(cached_topology.clone());
}
}
None
}
async fn update_cache(&mut self) -> Option<NymTopology> {
let updated_cache = match self.inner.get_new_topology().await {
None => None,
Some(mut base) => {
if !base.has_node_details(self.gateway_node.node_id) {
debug!(
"{} didn't exist in topology. inserting it.",
self.gateway_node.identity_key
);
base.insert_node_details(self.gateway_node.clone());
}
base.force_set_active(self.gateway_node.node_id, Role::EntryGateway);
Some(base)
}
};
self.cached_at = OffsetDateTime::now_utc();
self.cached = updated_cache.clone();
updated_cache
}
}
#[async_trait]
impl TopologyProvider for NymNodeTopologyProvider {
async fn get_new_topology(&mut self) -> Option<NymTopology> {
let mut guard = self.inner.lock().await;
// check the cache
if let Some(cached) = guard.cached_topology() {
return Some(cached);
}
// the observation will be included on drop
let _timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::ProcessTopologyQueryResolutionLatency);
guard.update_cache().await
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nym-validator-rewarder
@@ -16,7 +17,7 @@ RUN cargo build --release
# see https://github.com/nymtech/nym/blob/develop/nym-validator-rewarder/src/cli/mod.rs for details
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
RUN apt-get update && apt-get install -y ca-certificates
+29 -20
View File
@@ -174,9 +174,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.86"
version = "0.1.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
dependencies = [
"proc-macro2",
"quote",
@@ -1621,12 +1621,12 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.0.35"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c"
checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc"
dependencies = [
"crc32fast",
"miniz_oxide 0.8.0",
"miniz_oxide 0.8.5",
]
[[package]]
@@ -1905,9 +1905,9 @@ dependencies = [
[[package]]
name = "getset"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded738faa0e88d3abc9d1a13cb11adc2073c400969eeb8793cf7132589959fc"
checksum = "f3586f256131df87204eb733da72e3d3eb4f343c639f4b7be279ac7c48baeafe"
dependencies = [
"proc-macro-error2",
"proc-macro2",
@@ -2822,6 +2822,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.8"
@@ -3094,9 +3103,9 @@ dependencies = [
[[package]]
name = "miniz_oxide"
version = "0.8.0"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1"
checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
dependencies = [
"adler2",
]
@@ -3335,7 +3344,7 @@ dependencies = [
"digest 0.9.0",
"ff",
"group",
"itertools 0.13.0",
"itertools 0.14.0",
"nym-network-defaults",
"nym-pemstore",
"rand 0.8.5",
@@ -3603,7 +3612,7 @@ dependencies = [
"cosmwasm-std",
"eyre",
"hmac",
"itertools 0.13.0",
"itertools 0.14.0",
"log",
"nym-config",
"nym-crypto",
@@ -3642,7 +3651,7 @@ dependencies = [
"eyre",
"flate2",
"futures",
"itertools 0.13.0",
"itertools 0.14.0",
"nym-api-requests",
"nym-coconut-bandwidth-contract-common",
"nym-coconut-dkg-common",
@@ -4927,9 +4936,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
@@ -4940,9 +4949,9 @@ dependencies = [
[[package]]
name = "schemars_derive"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
@@ -5077,9 +5086,9 @@ dependencies = [
[[package]]
name = "serde_bytes"
version = "0.11.15"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a"
checksum = "364fec0df39c49a083c9a8a18a23a6bcfd9af130fe9fe321d18520a0d113e09e"
dependencies = [
"serde",
]
@@ -5108,9 +5117,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.138"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa 1.0.9",
"memchr",
@@ -121,6 +121,19 @@ export const SendInputModal = ({
initialValue={amount?.amount}
denom={denom}
/>
<TextField
name="memo"
label="Memo"
onChange={(e) => onMemoChange(e.target.value)}
value={memo}
error={!memoIsValid}
placeholder="Optional"
helperText={
!memoIsValid ? ' The text is invalid, only alphanumeric characters and white spaces are allowed' : undefined
}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<Typography fontSize="smaller" sx={{ color: 'error.main' }}>
{error}
</Typography>
+3 -2
View File
@@ -1,4 +1,5 @@
FROM rust:latest AS builder
# this will only work with VPN, otherwise remove the harbor part
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
COPY ./ /usr/src/nym
WORKDIR /usr/src/nym/nyx-chain-watcher
@@ -21,7 +22,7 @@ RUN cargo build --release
# and https://github.com/nymtech/nym/blob/develop/nyx-chain-watcher/src/env.rs for env vars
#-------------------------------------------------------------------
FROM ubuntu:24.04
FROM harbor.nymte.ch/dockerhub/ubuntu:24.04
RUN apt update && apt install -yy curl ca-certificates
+2 -3
View File
@@ -1,13 +1,12 @@
use anyhow::Result;
use sqlx::{sqlite::SqliteConnectOptions, Connection, SqliteConnection};
use std::env::var;
use std::io::Write;
use std::{collections::HashMap, fs::File, path::PathBuf, str::FromStr};
#[tokio::main]
async fn main() -> Result<()> {
let db_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(".build")
.join("nyx_chain_watcher.sqlite");
let db_path = PathBuf::from(var("OUT_DIR").unwrap()).join("nyx_chain_watcher.sqlite");
// Create the database directory if it doesn't exist
if let Some(parent) = db_path.parent() {
+4
View File
@@ -167,6 +167,8 @@ joke_through_tunnel() {
else
echo -e "${red}IPv4 connectivity is not working for $interface. verify your routing and NAT settings.${reset}"
fi
else
echo -e "${red}no IPv4 address found on $interface. unable to fetch a joke via IPv4.${reset}"
fi
if [[ -n "$ipv6_address" ]]; then
@@ -183,6 +185,8 @@ joke_through_tunnel() {
else
echo -e "${red}IPv6 connectivity is not working for $interface. verify your routing and NAT settings.${reset}"
fi
else
echo -e "${red}no IPv6 address found on $interface. unable to fetch a joke via IPv6.${reset}"
fi
echo -e "${green}joke fetching processes completed for $interface.${reset}"
+13
View File
@@ -6,6 +6,14 @@ license.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "nym-proxy-server"
path = "src/tcp_proxy/bin/proxy_server.rs"
[[bin]]
name = "nym-proxy-client"
path = "src/tcp_proxy/bin/proxy_client.rs"
[dependencies]
async-trait = { workspace = true }
bip39 = { workspace = true }
@@ -33,6 +41,10 @@ nym-validator-client = { path = "../../../common/client-libs/validator-client",
nym-socks5-requests = { path = "../../../common/socks5/requests" }
nym-ordered-buffer = { path = "../../../common/socks5/ordered-buffer" }
nym-service-providers-common = { path = "../../../service-providers/common" }
nym-sphinx-addressing = { path = "../../../common/nymsphinx/addressing" }
nym-bin-common = { path = "../../../common/bin-common", features = [
"basic_tracing",
] }
bytecodec = { workspace = true }
httpcodec = { workspace = true }
bytes = { workspace = true }
@@ -48,6 +60,7 @@ url = { workspace = true }
toml = { workspace = true }
# tcpproxy dependencies
clap = { workspace = true, features = ["derive"] }
anyhow.workspace = true
dashmap.workspace = true
tokio.workspace = true
+8
View File
@@ -0,0 +1,8 @@
# Nym Rust SDK
This repo contains several components:
- `mixnet`: exposes Nym Client builders and methods. This is useful if you want to interact directly with the Client, or build transport abstractions.
- `tcp_proxy`: exposes functionality to set up client/server instances that expose a localhost TcpSocket to read/write to like a 'normal' socket connection. `tcp_proxy/bin/` contains standalone `nym-proxy-client` and `nym-proxy-server` binaries.
- `clientpool`: a configurable pool of ephemeral Nym Clients which can be created as a background process and quickly grabbed.
Documentation can be found [here](https://nym.com/docs/developers/rust).
@@ -0,0 +1,54 @@
use anyhow::Result;
use clap::Parser;
use nym_sdk::tcp_proxy;
use nym_sphinx_addressing::Recipient;
#[derive(Parser, Debug)]
struct Args {
/// Send timeout in seconds
#[clap(long, default_value_t = 30)]
close_timeout: u64,
/// Nym address of the NymProxyServer e.g. EjYsntVxxBJrcRugiX5VnbKMbg7gyBGSp9SLt7RgeVFV.EzRtVdHCHoP2ho3DJgKMisMQ3zHkqMtAFAW4pxsq7Y2a@Hs463Wh5LtWZU@NyAmt4trcCbNVsuUhry1wpEXpVnAAfn
#[clap(short, long)]
server_address: String,
/// Listen address
#[clap(long, default_value = "127.0.0.1")]
listen_address: String,
/// Listen port
#[clap(long, default_value = "8080")]
listen_port: String,
/// Optional env filepath - if none is supplied then the proxy defaults to using mainnet else just use a path to one of the supplied files in envs/ e.g. ./envs/sandbox.env
#[clap(short, long)]
env_path: Option<String>,
/// How many clients to have running in reserve for quick access by incoming connections
#[clap(long, default_value_t = 2)]
client_pool_reserve: usize,
}
#[tokio::main]
async fn main() -> Result<()> {
nym_bin_common::logging::setup_tracing_logger();
let args = Args::parse();
let nym_addr: Recipient =
Recipient::try_from_base58_string(&args.server_address).expect("Invalid server address");
let proxy_client = tcp_proxy::NymProxyClient::new(
nym_addr,
&args.listen_address,
&args.listen_port,
args.close_timeout,
args.env_path.clone(),
args.client_pool_reserve,
)
.await?;
proxy_client.run().await.unwrap();
Ok(())
}
@@ -0,0 +1,37 @@
use anyhow::Result;
use clap::Parser;
use nym_sdk::tcp_proxy;
#[derive(Parser, Debug)]
struct Args {
/// Upstream address of the server process we want to proxy traffic to e.g. 127.0.0.1:9067
#[clap(short, long)]
upstream_tcp_address: String,
/// Config directory
#[clap(short, long, default_value = "/tmp/nym-tcp-proxy-server")]
config_dir: String,
/// Optional env filepath - if none is supplied then the proxy defaults to using mainnet else just use a path to one of the supplied files in envs/ e.g. ./envs/sandbox.env
#[clap(short, long)]
env_path: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
nym_bin_common::logging::setup_logging();
let args = Args::parse();
let home_dir = dirs::home_dir().expect("Unable to get home directory");
let conf_path = format!("{}{}", home_dir.display(), args.config_dir);
let mut proxy_server = tcp_proxy::NymProxyServer::new(
&args.upstream_tcp_address,
&conf_path,
args.env_path.clone(),
)
.await?;
proxy_server.run_with_shutdown().await
}