From cd0881462bc8532e9a55e36ff84768b18df23ad3 Mon Sep 17 00:00:00 2001 From: Simon Wicky Date: Fri, 30 Jan 2026 16:55:29 +0100 Subject: [PATCH] [LP Gateway Probe] CLI and behavior improvements (#6400) * attempt to de-spaghettificationize the gateway probe * applying suggestions --- .github/workflows/ci-build.yml | 1 + Cargo.lock | 7 +- nym-gateway-probe/Cargo.toml | 37 +- .../src/common/bandwidth_helpers.rs | 25 +- nym-gateway-probe/src/common/helpers.rs | 49 +- nym-gateway-probe/src/common/netstack.rs | 3 +- nym-gateway-probe/src/common/nodes.rs | 118 +- nym-gateway-probe/src/common/probe_tests.rs | 166 +-- nym-gateway-probe/src/common/wireguard.rs | 2 +- nym-gateway-probe/src/config/credentials.rs | 93 +- nym-gateway-probe/src/config/mod.rs | 39 +- nym-gateway-probe/src/config/netstack.rs | 24 +- nym-gateway-probe/src/config/socks5.rs | 12 +- nym-gateway-probe/src/config/test_mode.rs | 273 ++-- nym-gateway-probe/src/lib.rs | 1140 +++++------------ nym-gateway-probe/src/run.rs | 656 +++------- .../nym-node-status-agent/Cargo.toml | 2 +- .../src/cli/run_probe.rs | 2 +- .../nym-node-status-agent/src/probe.rs | 7 +- 19 files changed, 900 insertions(+), 1756 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 5ba0612c73..063c6e242c 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,6 +10,7 @@ on: - 'nym-api/**' - 'nym-authenticator-client/**' - 'nym-credential-proxy/**' + - 'nym-gateway-probe/**' - 'nym-ip-packet-client/**' - 'nym-network-monitor/**' - 'nym-node/**' diff --git a/Cargo.lock b/Cargo.lock index 2e041700b7..d11569e9d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6454,8 +6454,6 @@ version = "1.18.0" dependencies = [ "anyhow", "base64 0.22.1", - "bincode", - "bs58", "bytes", "clap", "futures", @@ -6478,7 +6476,6 @@ dependencies = [ "nym-ip-packet-requests", "nym-kkt-ciphersuite", "nym-lp", - "nym-mixnet-contract-common", "nym-network-defaults", "nym-node-requests", "nym-node-status-client", @@ -6492,7 +6489,6 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.17", "time", "tokio", "tokio-util", @@ -6501,7 +6497,6 @@ dependencies = [ "url", "utoipa", "vergen-gitcl", - "x25519-dalek", ] [[package]] @@ -7237,7 +7232,7 @@ dependencies = [ [[package]] name = "nym-node-status-agent" -version = "1.1.1" +version = "1.1.2" dependencies = [ "anyhow", "clap", diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 72336bb532..bf8c621e59 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -15,8 +15,6 @@ workspace = true [dependencies] anyhow.workspace = true base64.workspace = true -bs58.workspace = true -bincode.workspace = true bytes.workspace = true clap = { workspace = true, features = ["cargo", "derive"] } futures.workspace = true @@ -27,7 +25,7 @@ rand.workspace = true reqwest = { workspace = true, features = ["socks"] } serde.workspace = true serde_json.workspace = true -thiserror.workspace = true +time = { workspace = true } tokio = { workspace = true, features = [ "process", "rt-multi-thread", @@ -39,43 +37,34 @@ tokio-util.workspace = true tracing-subscriber.workspace = true url = { workspace = true } utoipa = { workspace = true, optional = true } -x25519-dalek = { workspace = true, features = [ - "reusable_secrets", - "static_secrets", -] } nym-api-requests = { path = "../nym-api/nym-api-requests" } +nym-authenticator-client = { workspace = true } nym-authenticator-requests = { workspace = true } nym-bandwidth-controller = { workspace = true } nym-bin-common = { workspace = true } nym-client-core = { workspace = true } -nym-crypto = { workspace = true } nym-config = { workspace = true } nym-connection-monitor = { path = "../common/nym-connection-monitor" } +nym-credentials = { workspace = true } nym-credentials-interface = { workspace = true } nym-credential-utils = { workspace = true } -nym-ip-packet-client = { workspace = true } -nym-authenticator-client = { workspace = true } -nym-ip-packet-requests = { workspace = true } -nym-sdk = { workspace = true } -nym-validator-client = { workspace = true } -nym-credentials = { workspace = true } -nym-http-api-client-macro = { path = "../common/http-api-client-macro" } +nym-crypto = { workspace = true } nym-http-api-client = { path = "../common/http-api-client" } -nym-node-status-client = { path = "../nym-node-status-api/nym-node-status-client" } -nym-node-requests = { path = "../nym-node/nym-node-requests" } -nym-registration-client = { path = "../nym-registration-client" } -nym-lp = { path = "../common/nym-lp" } +nym-http-api-client-macro = { path = "../common/http-api-client-macro" } +nym-ip-packet-client = { workspace = true } +nym-ip-packet-requests = { workspace = true } nym-kkt-ciphersuite = { workspace = true } - -nym-mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract" } +nym-lp = { path = "../common/nym-lp" } nym-network-defaults = { path = "../common/network-defaults" } +nym-node-requests = { path = "../nym-node/nym-node-requests" } +nym-node-status-client = { path = "../nym-node-status-api/nym-node-status-client" } +nym-registration-client = { path = "../nym-registration-client" } nym-registration-common = { path = "../common/registration" } -time = { workspace = true } - -# TEMP: REMOVE BEFORE PR +nym-sdk = { workspace = true } nym-topology = { workspace = true } +nym-validator-client = { workspace = true } [features] utoipa = ["dep:utoipa"] diff --git a/nym-gateway-probe/src/common/bandwidth_helpers.rs b/nym-gateway-probe/src/common/bandwidth_helpers.rs index dea3d6bfd5..ee4bb8583e 100644 --- a/nym-gateway-probe/src/common/bandwidth_helpers.rs +++ b/nym-gateway-probe/src/common/bandwidth_helpers.rs @@ -8,29 +8,38 @@ use nym_bandwidth_controller::mock::MockBandwidthController; use nym_client_core::client::base_client::storage::OnDiskPersistent; use nym_credentials_interface::TicketType; use nym_node_status_client::models::AttachedTicketMaterials; +use nym_sdk::NymNetworkDetails; use nym_sdk::bandwidth::BandwidthImporter; use nym_sdk::mixnet::{CredentialStorage, DisconnectedMixnetClient, EphemeralCredentialStorage}; -use nym_validator_client::QueryHttpRpcNyxdClient; use nym_validator_client::nyxd::error::NyxdError; use std::time::Duration; use tracing::{error, info}; pub(crate) fn build_bandwidth_controller( - rpc_client: QueryHttpRpcNyxdClient, - on_disk_storage: S, + network: &NymNetworkDetails, + storage: S, use_mock_ecash: bool, -) -> Box +) -> anyhow::Result> where S: CredentialStorage + 'static, S::StorageError: Send + Sync + 'static, { if !use_mock_ecash { - Box::new(nym_bandwidth_controller::BandwidthController::new( - on_disk_storage, - rpc_client, + let config = nym_validator_client::nyxd::Config::try_from_nym_network_details(network)?; + + let nyxd_url = network + .endpoints + .first() + .map(|ep| ep.nyxd_url()) + .ok_or(anyhow::anyhow!("missing nyxd url"))?; + let rpc_client = + nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; + + Ok(Box::new( + nym_bandwidth_controller::BandwidthController::new(storage, rpc_client), )) } else { - Box::new(MockBandwidthController::default()) + Ok(Box::new(MockBandwidthController::default())) } } diff --git a/nym-gateway-probe/src/common/helpers.rs b/nym-gateway-probe/src/common/helpers.rs index e80912abc5..5693b8a7a9 100644 --- a/nym-gateway-probe/src/common/helpers.rs +++ b/nym-gateway-probe/src/common/helpers.rs @@ -7,8 +7,13 @@ use nym_ip_packet_requests::v8::response::{ ControlResponse, DataResponse, InfoLevel, IpPacketResponse, IpPacketResponseData, }; use nym_lp::peer::LpRemotePeer; -use nym_sdk::mixnet::ReconstructedMessage; +use nym_sdk::{ + DebugConfig, NymApiTopologyProvider, NymApiTopologyProviderConfig, NymNetworkDetails, + TopologyProvider, mixnet::ReconstructedMessage, +}; +use nym_topology::NymTopology; use tracing::*; +use url::Url; pub fn to_lp_remote_peer(identity: ed25519::PublicKey, data: TestedNodeLpDetails) -> LpRemotePeer { LpRemotePeer::new(identity, data.x25519).with_key_digests( @@ -62,3 +67,45 @@ pub fn unpack_data_response(reconstructed_message: &ReconstructedMessage) -> Opt } } } + +pub async fn fetch_topology( + network_details: &NymNetworkDetails, + debug_config: &DebugConfig, +) -> Result { + // get Nym API URLs from network_details + let nym_api_urls: Vec = network_details + .nym_api_urls + .as_ref() + .map(|urls| urls.iter().filter_map(|u| u.url.parse().ok()).collect()) + .or_else(|| { + network_details + .endpoints + .first() + .and_then(|e| e.api_url()) + .map(|url| vec![url]) + }) + .unwrap_or_default(); + + if nym_api_urls.is_empty() { + return Err(String::from("No nym-api URLs available to fetch topology")); + } + + let topology_config = NymApiTopologyProviderConfig { + min_mixnode_performance: debug_config.topology.minimum_mixnode_performance, + min_gateway_performance: debug_config.topology.minimum_gateway_performance, + use_extended_topology: debug_config.topology.use_extended_topology, + ignore_egress_epoch_role: debug_config.topology.ignore_egress_epoch_role, + }; + + let api_client = nym_http_api_client::Client::new_url(nym_api_urls[0].clone(), None) + .map_err(|e| e.to_string())?; + let mut provider = NymApiTopologyProvider::new(topology_config, nym_api_urls, api_client); + + match provider.get_new_topology().await { + Some(topology) => { + info!("Fetched network topology"); + Ok(topology) + } + None => Err(String::from("Failed to fetch network topology")), + } +} diff --git a/nym-gateway-probe/src/common/netstack.rs b/nym-gateway-probe/src/common/netstack.rs index 9b033f5898..c7dff8a5cb 100644 --- a/nym-gateway-probe/src/common/netstack.rs +++ b/nym-gateway-probe/src/common/netstack.rs @@ -1,6 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::config::NetstackArgs; use anyhow::Context; use serde::Deserialize; use std::ffi::{CStr, CString}; @@ -15,8 +16,6 @@ mod sys { } } -use crate::config::NetstackArgs; - #[derive(serde::Serialize)] pub struct NetstackRequest { private_key: String, diff --git a/nym-gateway-probe/src/common/nodes.rs b/nym-gateway-probe/src/common/nodes.rs index 3e99c02608..bb88848e1a 100644 --- a/nym-gateway-probe/src/common/nodes.rs +++ b/nym-gateway-probe/src/common/nodes.rs @@ -4,9 +4,8 @@ use anyhow::{Context, anyhow, bail}; use nym_api_requests::models::{ AuthenticatorDetailsV2, DeclaredRolesV2, DescribedNodeTypeV2, HostInformationV2, - IpPacketRouterDetailsV2, LewesProtocolDetailsV1, NetworkRequesterDetailsV1, - NetworkRequesterDetailsV2, NymNodeDataV2, OffsetDateTimeJsonSchemaWrapper, WebSocketsV2, - WireguardDetailsV2, + IpPacketRouterDetailsV2, NetworkRequesterDetailsV2, NymNodeDataV2, + OffsetDateTimeJsonSchemaWrapper, WebSocketsV2, WireguardDetailsV2, }; use nym_authenticator_requests::AuthenticatorVersion; use nym_bin_common::build_information::BinaryBuildInformationOwned; @@ -27,7 +26,7 @@ use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; use time::OffsetDateTime; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use url::Url; // in the old behaviour we were getting all skimmed nodes to retrieve performance // that was ultimately unused @@ -108,6 +107,11 @@ impl DirectoryNode { .as_ref() .map(|ipr| ipr.address.parse().context("malformed ipr address")) .transpose()?; + let network_requester_address = description + .network_requester + .as_ref() + .map(|nr| nr.address.parse().context("malformed nr address")) + .transpose()?; let authenticator_address = description .authenticator .as_ref() @@ -140,12 +144,11 @@ impl DirectoryNode { }), _ => None, }; - let network_requester_details = self.described.description.network_requester.clone(); Ok(TestedNodeDetails { identity: self.identity(), exit_router_address, - network_requester_details, + network_requester_address, authenticator_address, authenticator_version, ip_address: Some(ip_address), @@ -217,12 +220,33 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result anyhow::Result = None; // Not needed for LP testing + let network_requester: Option = + nr_result.map(|nr| NetworkRequesterDetailsV2 { + address: nr.address, + uses_exit_policy: false, // Field not availabe, to change if it becomes useful here + }); let ip_packet_router: Option = ipr_result.map(|ipr| IpPacketRouterDetailsV2 { address: ipr.address, @@ -260,8 +288,6 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result = lp_result.ok().map(Into::into); - // Construct NymNodeData let node_data = NymNodeDataV2 { last_polled: OffsetDateTimeJsonSchemaWrapper(OffsetDateTime::now_utc()), @@ -293,7 +319,7 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result anyhow::Result { + pub fn exit_gateway(&self, identity: &NodeIdentity) -> anyhow::Result { let Some(maybe_entry) = self.nodes.get(identity).cloned() else { - bail!("{identity} not found in directory") + bail!("{identity} does not exist") }; - if !maybe_entry.described.description.declared_role.exit_nr { - bail!("{identity} doesn't support exit NR mode") + if !maybe_entry + .described + .description + .declared_role + .can_operate_exit_gateway() + { + bail!("{identity} is not an exit node") }; Ok(maybe_entry) } } -#[derive(Default, Debug)] -pub enum TestedNode { - #[default] - SameAsEntry, - Custom { - identity: NodeIdentity, - shares_entry: bool, - }, -} - -impl TestedNode { - pub fn is_same_as_entry(&self) -> bool { - matches!( - self, - TestedNode::SameAsEntry - | TestedNode::Custom { - shares_entry: true, - .. - } - ) - } -} - #[derive(Debug, Clone)] pub struct TestedNodeDetails { pub identity: NodeIdentity, pub exit_router_address: Option, - pub network_requester_details: Option, + pub network_requester_address: Option, pub authenticator_address: Option, pub authenticator_version: AuthenticatorVersion, pub ip_address: Option, @@ -484,29 +492,3 @@ pub struct TestedNodeLpDetails { pub x25519: x25519::PublicKey, pub lp_version: u8, } - -impl TestedNodeDetails { - /// Create from CLI args (localnet mode - no HTTP query needed) - pub fn from_cli(identity: NodeIdentity, lp_data: TestedNodeLpDetails) -> Self { - Self { - identity, - ip_address: Some(lp_data.address.ip()), - lp_data: Some(lp_data), - network_requester_details: None, - // These are None in localnet mode - only needed for mixnet/authenticator - exit_router_address: None, - authenticator_address: None, - authenticator_version: AuthenticatorVersion::UNKNOWN, - } - } - - /// Check if this node has sufficient info for LP testing - pub fn can_test_lp(&self) -> bool { - self.lp_data.is_some() - } - - /// Check if this node has sufficient info for mixnet testing - pub fn can_test_mixnet(&self) -> bool { - self.exit_router_address.is_some() || self.authenticator_address.is_some() - } -} diff --git a/nym-gateway-probe/src/common/probe_tests.rs b/nym-gateway-probe/src/common/probe_tests.rs index b96564c020..ddeab981fd 100644 --- a/nym-gateway-probe/src/common/probe_tests.rs +++ b/nym-gateway-probe/src/common/probe_tests.rs @@ -1,7 +1,6 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::NymApiDirectory; use crate::common::helpers::mixnet_debug_config; use crate::common::nodes::{TestedNodeDetails, TestedNodeLpDetails}; use crate::common::socks5_test::HttpsConnectivityTest; @@ -12,7 +11,7 @@ use crate::common::wireguard::{ TwoHopWgTunnelConfig, WgTunnelConfig, run_tunnel_tests, run_two_hop_tunnel_tests, }; use crate::common::{helpers, icmp}; -use crate::config::NetstackArgs; +use crate::config::{NetstackArgs, Socks5Args}; use anyhow::bail; use base64::{Engine, engine::general_purpose}; use bytes::BytesMut; @@ -30,11 +29,8 @@ use nym_crypto::asymmetric::{ed25519, x25519}; use nym_ip_packet_client::IprClientConnect; use nym_ip_packet_requests::{IpPair, codec::MultiIpPacketCodec}; use nym_registration_client::{LpRegistrationClient, NestedLpSession}; +use nym_sdk::NymNetworkDetails; use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder, NodeIdentity, Recipient, Socks5}; -use nym_sdk::{ - DebugConfig, NymApiTopologyProvider, NymApiTopologyProviderConfig, NymNetworkDetails, - TopologyProvider, -}; use nym_topology::{HardcodedTopologyProvider, NymTopology}; use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, @@ -44,13 +40,12 @@ use std::{ use tokio::net::TcpStream; use tokio_util::{codec::Decoder, sync::CancellationToken}; use tracing::*; -use url::Url; pub async fn wg_probe( mut auth_client: AuthenticatorClient, gateway_ip: IpAddr, auth_version: AuthenticatorVersion, - awg_args: String, + awg_args: Option, netstack_args: NetstackArgs, // TODO: update type credential: CredentialSpendingData, @@ -123,9 +118,8 @@ pub async fn wg_probe( }; let peer_public = registered_data.pub_key().inner(); - let static_private = x25519_dalek::StaticSecret::from(private_key.to_bytes()); let public_key_bs64 = general_purpose::STANDARD.encode(peer_public.as_bytes()); - let private_key_hex = hex::encode(static_private.to_bytes()); + let private_key_hex = hex::encode(private_key.to_bytes()); let public_key_hex = hex::encode(peer_public.as_bytes()); info!("WG connection details"); @@ -152,7 +146,12 @@ pub async fn wg_probe( wg_endpoint, ); - run_tunnel_tests(&tunnel_config, &netstack_args, &awg_args, &mut wg_outcome); + run_tunnel_tests( + &tunnel_config, + &netstack_args, + &awg_args.unwrap_or_default(), + &mut wg_outcome, + ); Ok(wg_outcome) } @@ -166,7 +165,7 @@ pub async fn lp_registration_probe( let lp_version = gateway_lp_data.lp_version; let peer = helpers::to_lp_remote_peer(gateway_identity, gateway_lp_data); - info!("Starting LP registration probe for gateway at {lp_address}",); + info!("Starting LP registration probe for gateway at {lp_address}"); let mut lp_outcome = LpProbeResults::default(); @@ -274,7 +273,7 @@ pub async fn wg_probe_lp( entry_gateway: &TestedNodeDetails, exit_gateway: &TestedNodeDetails, bandwidth_controller: &dyn BandwidthTicketProvider, - awg_args: String, + awg_args: Option, netstack_args: NetstackArgs, ) -> anyhow::Result { // Validate that both gateways have required information @@ -340,9 +339,7 @@ pub async fn wg_probe_lp( exit_lp_version, ); - // Convert exit gateway identity to ed25519 public key for registration - let exit_gateway_pubkey = ed25519::PublicKey::from_bytes(&exit_gateway.identity.to_bytes()) - .map_err(|e| anyhow::anyhow!("Invalid exit gateway identity: {}", e))?; + let exit_gateway_pubkey = exit_gateway.identity; // Perform handshake and registration with exit gateway via forwarding let exit_gateway_data = match nested_session @@ -424,12 +421,12 @@ pub async fn wg_probe_lp( entry_private_key_hex, entry_public_key_hex, entry_wg_endpoint, - awg_args.clone(), // Entry AWG args + awg_args.clone().unwrap_or_default(), // Entry AWG args exit_gateway_data.private_ipv4.to_string(), exit_private_key_hex, exit_public_key_hex, exit_wg_endpoint, - awg_args, // Exit AWG args + awg_args.unwrap_or_default(), // Exit AWG args ); // Run two-hop tunnel connectivity tests @@ -632,39 +629,23 @@ pub async fn listen_for_icmp_ping_replies( /// Creates a SOCKS5 proxy connection through the mixnet to the exit GW /// and performs necessary tests. -#[allow(clippy::too_many_arguments)] #[instrument(level = "info", name = "socks5_test", skip_all)] pub(crate) async fn do_socks5_connectivity_test( - network_requester_address: &str, + nr_recipient: &Recipient, + entry_gateway_id: NodeIdentity, network_details: NymNetworkDetails, - directory: &NymApiDirectory, - json_rpc_endpoints: Vec, - mixnet_client_timeout: u64, - test_run_count: u64, - failure_count_cutoff: usize, - topology: Option, + min_gw_performance: Option, + socks5_args: Socks5Args, + maybe_topology: Option, ) -> anyhow::Result { info!( "Starting SOCKS5 test through Network Requester: {}", - network_requester_address + nr_recipient ); - if json_rpc_endpoints.is_empty() { + if socks5_args.socks5_json_rpc_url_list.is_empty() { bail!("You need to define JSON RPC URLs in order to test SOCKS5") } - // parse the network requester address - let nr_recipient = match network_requester_address.parse::() { - Ok(addr) => addr, - Err(e) => { - error!("Invalid Network Requester address: {}", e); - - return Ok(Socks5ProbeResults::error_before_connecting(format!( - "Invalid NR address: {}", - e - ))); - } - }; - info!( "Network Requester gateway: {}", nr_recipient.gateway().to_base58_string() @@ -675,53 +656,30 @@ pub(crate) async fn do_socks5_connectivity_test( ); // create ephemeral SOCKS5 client - let socks5_config = Socks5::new(network_requester_address.to_string()); - - // since we define both entry & exit gateways to be the same tested GW, - // this shouldn't negatively affect mixnet layers but it will force route - // construction in case GW would get filtered out of topology - let min_gw_performance = Some(0); + let socks5_config = Socks5::new(nr_recipient.to_string()); // debug config similar to main probe let debug_config = mixnet_debug_config(min_gw_performance, true); - // Verify the NR gateway exists in the directory with exit_nr role - let nr_gateway_id = nr_recipient.gateway(); - if let Err(e) = directory.exit_gateway_nr(&nr_gateway_id) { - return Ok(Socks5ProbeResults::error_before_connecting(e.to_string())); - } else { - info!("✔️ Network Requester gateway found in directory with exit_nr role"); - } - - // use intended exit as entry as well - let entry_gateway = nr_gateway_id; - - // use existing topology if available, otherwise fetch it - let topology_provider: Box = match topology { - Some(t) => { - info!("✔️ Reusing topology from main mixnet client"); - Box::new(HardcodedTopologyProvider::new(t)) - } - None => { - info!("Fetching topology for SOCKS5 client..."); - match hardcoded_topology(&network_details, &debug_config).await { - Ok(provider) => provider, - Err(e) => return Ok(Socks5ProbeResults::error_before_connecting(e)), - } - } - }; - - let socks5_client_builder = MixnetClientBuilder::new_ephemeral() + let mut socks5_client_builder = MixnetClientBuilder::new_ephemeral() // Specify entry gateway explicitly - .request_gateway(entry_gateway.to_base58_string()) + .request_gateway(entry_gateway_id.to_base58_string()) .socks5_config(socks5_config) .network_details(network_details) - .debug_config(debug_config) - .custom_topology_provider(topology_provider) - .build()?; + .debug_config(debug_config); + + if let Some(topology) = maybe_topology { + socks5_client_builder = socks5_client_builder + .custom_topology_provider(Box::new(HardcodedTopologyProvider::new(topology))); + } + + let disconnected_socks5_client = socks5_client_builder.build()?; // connect to mixnet via SOCKS5 - let socks5_client = match socks5_client_builder.connect_to_mixnet_via_socks5().await { + let socks5_client = match disconnected_socks5_client + .connect_to_mixnet_via_socks5() + .await + { Ok(client) => { info!("🌐 Successfully connected to mixnet via SOCKS5 proxy"); info!( @@ -740,10 +698,10 @@ pub(crate) async fn do_socks5_connectivity_test( }; let test = match HttpsConnectivityTest::new( - test_run_count, - mixnet_client_timeout, - failure_count_cutoff, - json_rpc_endpoints, + socks5_args.test_count, + socks5_args.mixnet_client_timeout_sec, + socks5_args.failure_count_cutoff, + socks5_args.socks5_json_rpc_url_list, socks5_client.socks5_url(), ) { Ok(test) => test, @@ -762,45 +720,3 @@ pub(crate) async fn do_socks5_connectivity_test( Ok(Socks5ProbeResults::with_http_result(result)) } - -async fn hardcoded_topology( - network_details: &NymNetworkDetails, - debug_config: &DebugConfig, -) -> Result, String> { - // get Nym API URLs from network_details - let nym_api_urls: Vec = network_details - .nym_api_urls - .as_ref() - .map(|urls| urls.iter().filter_map(|u| u.url.parse().ok()).collect()) - .or_else(|| { - network_details - .endpoints - .first() - .and_then(|e| e.api_url()) - .map(|url| vec![url]) - }) - .unwrap_or_default(); - - if nym_api_urls.is_empty() { - return Err(String::from("No nym-api URLs available to fetch topology")); - } - - let topology_config = NymApiTopologyProviderConfig { - min_mixnode_performance: debug_config.topology.minimum_mixnode_performance, - min_gateway_performance: debug_config.topology.minimum_gateway_performance, - use_extended_topology: debug_config.topology.use_extended_topology, - ignore_egress_epoch_role: debug_config.topology.ignore_egress_epoch_role, - }; - - let api_client = nym_http_api_client::Client::new_url(nym_api_urls[0].clone(), None) - .map_err(|e| e.to_string())?; - let mut provider = NymApiTopologyProvider::new(topology_config, nym_api_urls, api_client); - - match provider.get_new_topology().await { - Some(topology) => { - info!("Fetched network topology"); - Ok(Box::new(HardcodedTopologyProvider::new(topology))) - } - None => Err(String::from("Failed to fetch network topology")), - } -} diff --git a/nym-gateway-probe/src/common/wireguard.rs b/nym-gateway-probe/src/common/wireguard.rs index 2a9e2ba5b4..20a0e7ec8d 100644 --- a/nym-gateway-probe/src/common/wireguard.rs +++ b/nym-gateway-probe/src/common/wireguard.rs @@ -9,11 +9,11 @@ use nym_config::defaults::{WG_METADATA_PORT, WG_TUN_DEVICE_IP_ADDRESS_V4}; use tracing::{error, info}; +use crate::NetstackArgs; use crate::common::netstack::{ NetstackRequest, NetstackRequestGo, NetstackResult, TwoHopNetstackRequestGo, }; use crate::common::types::WgProbeResults; -use crate::config::NetstackArgs; /// Safe division that returns 0.0 when divisor is 0 (instead of NaN/Inf) fn safe_ratio(received: u16, sent: u16) -> f32 { diff --git a/nym-gateway-probe/src/config/credentials.rs b/nym-gateway-probe/src/config/credentials.rs index 50cf308b4b..71bc92b56f 100644 --- a/nym-gateway-probe/src/config/credentials.rs +++ b/nym-gateway-probe/src/config/credentials.rs @@ -1,29 +1,94 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use clap::Args; +use clap::{ArgGroup, Args}; +use nym_credentials_interface::TicketType; use nym_node_status_client::models::AttachedTicketMaterials; +use nym_sdk::mixnet::{ + CredentialStorage, DisconnectedMixnetClient, Ephemeral, MixnetClientStorage, OnDiskPersistent, +}; -#[derive(Args)] +use crate::common::bandwidth_helpers::{acquire_bandwidth, import_bandwidth}; + +#[derive(Debug, Args)] pub struct CredentialArgs { + /// Serialized credential data #[arg(long)] - ticket_materials: Option, + pub ticket_materials: String, + /// Version of the serialized credential #[arg(long, default_value_t = 1)] - ticket_materials_revision: u8, + pub ticket_materials_revision: u8, } impl CredentialArgs { - pub fn decode_attached_ticket_materials(&self) -> anyhow::Result { - let ticket_materials = self - .ticket_materials - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ticket_materials is required"))? - .clone(); - - Ok(AttachedTicketMaterials::from_serialised_string( - ticket_materials, + pub async fn import_credential( + self, + mixnet_client: &DisconnectedMixnetClient, + ) -> anyhow::Result<()> { + let tickets_materials = AttachedTicketMaterials::from_serialised_string( + self.ticket_materials, self.ticket_materials_revision, - )?) + )?; + let bandwidth_import = mixnet_client.begin_bandwidth_import(); + import_bandwidth(bandwidth_import, tickets_materials).await?; + Ok(()) + } +} + +/// Two ways to inject credentials when not running as agent +/// 1. Mnemonic : expected to be used on lower envs +/// - mnemonic +/// 2. Mock ecash : expected to be used for local setups +/// - use_mock_ecash +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("credential_mode") + .args(["use_mock_ecash","mnemonic"]) + .required(true) + .multiple(false) +))] +pub struct CredentialMode { + /// Use mock ecash credentials for testing (requires gateway with --lp-use-mock-ecash) + #[arg(long, action = clap::ArgAction::SetTrue)] + pub use_mock_ecash: bool, + + /// Mnemonic to get credentials from the blockchain. It needs NYMs. + #[arg(long)] + pub mnemonic: Option, +} + +impl CredentialMode { + pub async fn acquire( + &self, + disconnected_mixnet_client: &DisconnectedMixnetClient, + storage: &OnDiskPersistent, + ) -> anyhow::Result<()> { + // Return immediately as there is nothing to do + if self.use_mock_ecash { + return Ok(()); + } + let ticketbook_count = storage + .credential_store() + .get_ticketbooks_info() + .await? + .len(); + tracing::info!("Credential store contains {} ticketbooks", ticketbook_count); + + if ticketbook_count < 1 { + let mnemonic = self.mnemonic.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "We are not using mock ecash and mnemonic is not set, this should not happen" + ) + })?; + for ticketbook_type in [ + TicketType::V1MixnetEntry, + TicketType::V1WireguardEntry, + TicketType::V1WireguardExit, + ] { + acquire_bandwidth(mnemonic, disconnected_mixnet_client, ticketbook_type).await?; + } + } + Ok(()) } } diff --git a/nym-gateway-probe/src/config/mod.rs b/nym-gateway-probe/src/config/mod.rs index a87be29839..6ad78c942f 100644 --- a/nym-gateway-probe/src/config/mod.rs +++ b/nym-gateway-probe/src/config/mod.rs @@ -1,12 +1,49 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use clap::Args; + mod credentials; mod netstack; mod socks5; mod test_mode; -pub use credentials::CredentialArgs; +pub use credentials::{CredentialArgs, CredentialMode}; pub use netstack::NetstackArgs; pub use socks5::Socks5Args; pub use test_mode::TestMode; + +#[derive(Args, Debug)] +pub struct ProbeConfig { + /// Only choose gateway with that minimum performance + #[arg(long)] + pub min_gateway_mixnet_performance: Option, + + /// Test mode - explicitly specify which tests to run + /// + /// Modes: + /// core. - Traditional mixnet testing (entry/exit pings + WireGuard via authenticator) + /// wg-mix - Wireguard via authenticator + /// wg-lp - Entry LP + Exit LP (nested forwarding) + WireGuard + /// lp-only - LP registration only (no WireGuard) + /// socks5-only - Socks5 network requester test + /// all - Mixnet, wireguard over authenticator and LP registration + /// + #[arg(long, default_value_t = TestMode::default(), verbatim_doc_comment)] + pub test_mode: TestMode, + + #[arg(long, global = true)] + pub ignore_egress_epoch_role: bool, + + /// Arguments to be appended to the wireguard config enabling amnezia-wg configuration + #[arg(long)] + pub amnezia_args: Option, + + /// Arguments to manage netstack downloads + #[command(flatten)] + pub netstack_args: NetstackArgs, + + /// Arguments to configure socks5 probe + #[command(flatten)] + pub socks5_args: Socks5Args, +} diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs index de6dda47d7..1e704bcf55 100644 --- a/nym-gateway-probe/src/config/netstack.rs +++ b/nym-gateway-probe/src/config/netstack.rs @@ -3,39 +3,39 @@ use clap::Args; -#[derive(Args, Clone)] +#[derive(Args, Clone, Debug)] pub struct NetstackArgs { - #[arg(long, default_value_t = 180)] + #[arg(long, hide = true, default_value_t = 180)] pub netstack_download_timeout_sec: u64, - #[arg(long, default_value_t = 30)] + #[arg(long, hide = true, default_value_t = 30)] pub metadata_timeout_sec: u64, - #[arg(long, default_value = "1.1.1.1")] + #[arg(long, hide = true, default_value = "1.1.1.1")] pub netstack_v4_dns: String, - #[arg(long, default_value = "2606:4700:4700::1111")] + #[arg(long, hide = true, default_value = "2606:4700:4700::1111")] pub netstack_v6_dns: String, - #[arg(long, default_value_t = 5)] + #[arg(long, hide = true, default_value_t = 5)] pub netstack_num_ping: u8, - #[arg(long, default_value_t = 3)] + #[arg(long, hide = true, default_value_t = 3)] pub netstack_send_timeout_sec: u64, - #[arg(long, default_value_t = 3)] + #[arg(long, hide = true, default_value_t = 3)] pub netstack_recv_timeout_sec: u64, - #[arg(long, default_values_t = vec!["nym.com".to_string()])] + #[arg(long, hide= true, default_values_t = vec!["nym.com".to_string()])] pub netstack_ping_hosts_v4: Vec, - #[arg(long, default_values_t = vec!["1.1.1.1".to_string()])] + #[arg(long, hide= true, default_values_t = vec!["1.1.1.1".to_string()])] pub netstack_ping_ips_v4: Vec, - #[arg(long, default_values_t = vec!["cloudflare.com".to_string()])] + #[arg(long, hide= true, default_values_t = vec!["cloudflare.com".to_string()])] pub netstack_ping_hosts_v6: Vec, - #[arg(long, default_values_t = vec!["2001:4860:4860::8888".to_string(), "2606:4700:4700::1111".to_string(), "2620:fe::fe".to_string()])] + #[arg(long, hide= true, default_values_t = vec!["2001:4860:4860::8888".to_string(), "2606:4700:4700::1111".to_string(), "2620:fe::fe".to_string()])] pub netstack_ping_ips_v6: Vec, } diff --git a/nym-gateway-probe/src/config/socks5.rs b/nym-gateway-probe/src/config/socks5.rs index 5c2a29c80a..3832dd4320 100644 --- a/nym-gateway-probe/src/config/socks5.rs +++ b/nym-gateway-probe/src/config/socks5.rs @@ -2,19 +2,21 @@ use clap::Args; use crate::common::socks5_test::JsonRpcClient; -#[derive(Args)] +const DEFAULT_RPC_ENDPOINT: &str = "https://cloudflare-eth.com"; + +#[derive(Args, Debug)] pub struct Socks5Args { - #[arg(long, value_delimiter = ';')] + #[arg(long, hide = true, value_delimiter = ';', default_value = DEFAULT_RPC_ENDPOINT)] pub socks5_json_rpc_url_list: Vec, - #[arg(long, default_value_t = 30)] + #[arg(long, hide = true, default_value_t = 30)] pub mixnet_client_timeout_sec: u64, - #[arg(long, default_value_t = 10)] + #[arg(long, hide = true, default_value_t = 10)] pub test_count: u64, /// stops socks5 test early after this many failed attempts - #[arg(long, default_value_t = 3)] + #[arg(long, hide = true, default_value_t = 3)] pub failure_count_cutoff: usize, } diff --git a/nym-gateway-probe/src/config/test_mode.rs b/nym-gateway-probe/src/config/test_mode.rs index 5c6e45450d..eec509f0e9 100644 --- a/nym-gateway-probe/src/config/test_mode.rs +++ b/nym-gateway-probe/src/config/test_mode.rs @@ -4,87 +4,72 @@ //! Test mode definitions for gateway probe. //! //! This module defines the different test modes supported by the gateway probe: -//! - Mixnet: Traditional mixnet path testing -//! - SingleHop: LP registration + WireGuard on single gateway -//! - TwoHop: Entry LP + Exit LP (nested forwarding) + WireGuard +//! - Core: Traditional mixnet path testing and Wireguard via authenticator +//! - WgMix: Wireguard via authenticator +//! - WgLp: Entry LP + Exit LP (nested forwarding) + WireGuard //! - LpOnly: LP registration only, no WireGuard +//! - Socks5Only: Socks5 test +//! - All: Mixnet, wireguard over authenticator and LP registration /// Test mode for the gateway probe. /// /// Determines which tests are performed and how connections are established. -// This enum replaces the scattered boolean flags (only_wireguard, -// only_lp_registration, test_lp_wg) with explicit, named modes for clarity. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TestMode { - /// Traditional mixnet testing - connects via mixnet, tests entry/exit pings + WireGuard via authenticator + /// Mixnet tests + WireGuard via authenticator #[default] - Mixnet, - /// LP registration + WireGuard on single gateway (no mixnet, no forwarding) - SingleHop, - /// Entry LP + Exit LP (nested session forwarding) + WireGuard tunnel - TwoHop, - /// LP registration only - test handshake and registration, skip WireGuard + Core, + /// Wireguard via authenticator + WgMix, + /// Wireguard over LP + WgLp, + /// LP registration only - test handshake and registration LpOnly, + /// Socks5 test only + Socks5Only, + /// Mixnet tests, Wireguard tests, LP tests, Socks5 test + All, } impl TestMode { - /// Infer test mode from legacy boolean flags (backward compatibility) - pub fn from_flags( - only_wireguard: bool, - only_lp_registration: bool, - test_lp_wg: bool, - has_exit_gateway: bool, - ) -> Self { - if only_lp_registration { - TestMode::LpOnly - } else if test_lp_wg { - if has_exit_gateway { - TestMode::TwoHop - } else { - TestMode::SingleHop - } - } else if only_wireguard { - // WireGuard via authenticator (still uses mixnet path) - TestMode::Mixnet - } else { - TestMode::Mixnet - } + // Wether we need to run mixnet tests + pub fn mixnet_tests(&self) -> bool { + matches!(self, TestMode::Core | TestMode::All) + } + + // Wether we need to run Wiregurd tests + pub fn wireguard_tests(&self) -> bool { + matches!( + self, + TestMode::Core | TestMode::WgMix | TestMode::WgLp | TestMode::All + ) + } + + // Wether we need to run Lp tests + pub fn lp_tests(&self) -> bool { + matches!(self, TestMode::WgLp | TestMode::LpOnly | TestMode::All) + } + + // Wether we need to run socks5 tests + pub fn socks5_tests(&self) -> bool { + matches!(self, TestMode::Socks5Only | TestMode::All) } /// Whether this mode requires a mixnet client pub fn needs_mixnet(&self) -> bool { - matches!(self, TestMode::Mixnet) - } - - /// Whether this mode uses LP registration - pub fn uses_lp(&self) -> bool { - matches!( - self, - TestMode::SingleHop | TestMode::TwoHop | TestMode::LpOnly - ) - } - - /// Whether this mode tests WireGuard tunnels - pub fn tests_wireguard(&self) -> bool { - matches!( - self, - TestMode::Mixnet | TestMode::SingleHop | TestMode::TwoHop - ) - } - - /// Whether this mode requires an exit gateway - pub fn needs_exit_gateway(&self) -> bool { - matches!(self, TestMode::TwoHop) + matches!(self, TestMode::Core | TestMode::All | TestMode::WgMix) } } impl std::fmt::Display for TestMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - TestMode::Mixnet => write!(f, "mixnet"), - TestMode::SingleHop => write!(f, "single-hop"), - TestMode::TwoHop => write!(f, "two-hop"), + TestMode::Core => write!(f, "core"), + TestMode::WgMix => write!(f, "wg-mix"), + TestMode::WgLp => write!(f, "wg-lp"), TestMode::LpOnly => write!(f, "lp-only"), + TestMode::Socks5Only => write!(f, "socks5-only"), + TestMode::All => write!(f, "all"), } } } @@ -94,12 +79,14 @@ impl std::str::FromStr for TestMode { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "mixnet" => Ok(TestMode::Mixnet), - "single-hop" | "singlehop" | "single_hop" => Ok(TestMode::SingleHop), - "two-hop" | "twohop" | "two_hop" => Ok(TestMode::TwoHop), + "mixnet" | "core" => Ok(TestMode::Core), + "wg-mix" | "wgmix" | "wg_mix" => Ok(TestMode::WgMix), + "wg-lp" | "wglp" | "wg_lp" => Ok(TestMode::WgLp), "lp-only" | "lponly" | "lp_only" => Ok(TestMode::LpOnly), + "socks5-only" | "socks5only" | "socks5_only" => Ok(TestMode::Socks5Only), + "all" => Ok(TestMode::All), _ => Err(format!( - "Unknown test mode: '{}'. Valid modes: mixnet, single-hop, two-hop, lp-only", + "Unknown test mode: '{}'. Valid modes: core, wg-mix, wg-lp, lp-only, socks5-only, all", s )), } @@ -110,152 +97,80 @@ impl std::str::FromStr for TestMode { mod tests { use super::*; - // ============ from_flags() tests ============ - - #[test] - fn test_from_flags_default_is_mixnet() { - // All flags false -> Mixnet (default) - assert_eq!( - TestMode::from_flags(false, false, false, false), - TestMode::Mixnet - ); - } - - #[test] - fn test_from_flags_only_wireguard_is_mixnet() { - // only_wireguard still uses mixnet path (WG via authenticator) - assert_eq!( - TestMode::from_flags(true, false, false, false), - TestMode::Mixnet - ); - } - - #[test] - fn test_from_flags_only_lp_registration() { - // only_lp_registration -> LpOnly (takes priority) - assert_eq!( - TestMode::from_flags(false, true, false, false), - TestMode::LpOnly - ); - // Even with other flags set, only_lp_registration wins - assert_eq!( - TestMode::from_flags(true, true, true, true), - TestMode::LpOnly - ); - } - - #[test] - fn test_from_flags_test_lp_wg_single_hop() { - // test_lp_wg without exit gateway -> SingleHop - assert_eq!( - TestMode::from_flags(false, false, true, false), - TestMode::SingleHop - ); - } - - #[test] - fn test_from_flags_test_lp_wg_two_hop() { - // test_lp_wg with exit gateway -> TwoHop - assert_eq!( - TestMode::from_flags(false, false, true, true), - TestMode::TwoHop - ); - } - - #[test] - fn test_from_flags_has_exit_gateway_alone_is_mixnet() { - // has_exit_gateway alone doesn't change mode - assert_eq!( - TestMode::from_flags(false, false, false, true), - TestMode::Mixnet - ); - } - // ============ Helper method tests ============ #[test] fn test_needs_mixnet() { - assert!(TestMode::Mixnet.needs_mixnet()); - assert!(!TestMode::SingleHop.needs_mixnet()); - assert!(!TestMode::TwoHop.needs_mixnet()); + assert!(TestMode::Core.needs_mixnet()); + assert!(TestMode::WgMix.needs_mixnet()); + assert!(!TestMode::WgLp.needs_mixnet()); assert!(!TestMode::LpOnly.needs_mixnet()); - } - - #[test] - fn test_uses_lp() { - assert!(!TestMode::Mixnet.uses_lp()); - assert!(TestMode::SingleHop.uses_lp()); - assert!(TestMode::TwoHop.uses_lp()); - assert!(TestMode::LpOnly.uses_lp()); - } - - #[test] - fn test_tests_wireguard() { - assert!(TestMode::Mixnet.tests_wireguard()); - assert!(TestMode::SingleHop.tests_wireguard()); - assert!(TestMode::TwoHop.tests_wireguard()); - assert!(!TestMode::LpOnly.tests_wireguard()); - } - - #[test] - fn test_needs_exit_gateway() { - assert!(!TestMode::Mixnet.needs_exit_gateway()); - assert!(!TestMode::SingleHop.needs_exit_gateway()); - assert!(TestMode::TwoHop.needs_exit_gateway()); - assert!(!TestMode::LpOnly.needs_exit_gateway()); + assert!(!TestMode::Socks5Only.needs_mixnet()); + assert!(TestMode::All.needs_mixnet()); } // ============ Display tests ============ #[test] fn test_display() { - assert_eq!(TestMode::Mixnet.to_string(), "mixnet"); - assert_eq!(TestMode::SingleHop.to_string(), "single-hop"); - assert_eq!(TestMode::TwoHop.to_string(), "two-hop"); + assert_eq!(TestMode::Core.to_string(), "core"); + assert_eq!(TestMode::WgMix.to_string(), "wg-mix"); + assert_eq!(TestMode::WgLp.to_string(), "wg-lp"); assert_eq!(TestMode::LpOnly.to_string(), "lp-only"); + assert_eq!(TestMode::Socks5Only.to_string(), "socks5-only"); + assert_eq!(TestMode::All.to_string(), "all"); } // ============ FromStr tests ============ #[test] fn test_from_str_canonical() { - assert_eq!("mixnet".parse::().unwrap(), TestMode::Mixnet); - assert_eq!( - "single-hop".parse::().unwrap(), - TestMode::SingleHop - ); - assert_eq!("two-hop".parse::().unwrap(), TestMode::TwoHop); + assert_eq!("core".parse::().unwrap(), TestMode::Core); + assert_eq!("wg-mix".parse::().unwrap(), TestMode::WgMix); + assert_eq!("wg-lp".parse::().unwrap(), TestMode::WgLp); assert_eq!("lp-only".parse::().unwrap(), TestMode::LpOnly); + assert_eq!( + "socks5-only".parse::().unwrap(), + TestMode::Socks5Only + ); + assert_eq!("all".parse::().unwrap(), TestMode::All); } #[test] fn test_from_str_alternate_formats() { + // Default aliases + assert_eq!("mixnet".parse::().unwrap(), TestMode::Core); + // snake_case - assert_eq!( - "single_hop".parse::().unwrap(), - TestMode::SingleHop - ); - assert_eq!("two_hop".parse::().unwrap(), TestMode::TwoHop); + assert_eq!("wg_mix".parse::().unwrap(), TestMode::WgMix); + assert_eq!("wg_lp".parse::().unwrap(), TestMode::WgLp); assert_eq!("lp_only".parse::().unwrap(), TestMode::LpOnly); + assert_eq!( + "socks5_only".parse::().unwrap(), + TestMode::Socks5Only + ); // no separator - assert_eq!( - "singlehop".parse::().unwrap(), - TestMode::SingleHop - ); - assert_eq!("twohop".parse::().unwrap(), TestMode::TwoHop); + assert_eq!("wgmix".parse::().unwrap(), TestMode::WgMix); + assert_eq!("wglp".parse::().unwrap(), TestMode::WgLp); assert_eq!("lponly".parse::().unwrap(), TestMode::LpOnly); + assert_eq!( + "socks5only".parse::().unwrap(), + TestMode::Socks5Only + ); } #[test] fn test_from_str_case_insensitive() { - assert_eq!("MIXNET".parse::().unwrap(), TestMode::Mixnet); - assert_eq!( - "Single-Hop".parse::().unwrap(), - TestMode::SingleHop - ); - assert_eq!("TWO_HOP".parse::().unwrap(), TestMode::TwoHop); + assert_eq!("cOrE".parse::().unwrap(), TestMode::Core); + assert_eq!("WG-MIX".parse::().unwrap(), TestMode::WgMix); + assert_eq!("Wg_Lp".parse::().unwrap(), TestMode::WgLp); assert_eq!("LpOnly".parse::().unwrap(), TestMode::LpOnly); + assert_eq!( + "soCkS5-oNlY".parse::().unwrap(), + TestMode::Socks5Only + ); + assert_eq!("ALL".parse::().unwrap(), TestMode::All); } #[test] @@ -270,10 +185,12 @@ mod tests { #[test] fn test_display_fromstr_roundtrip() { for mode in [ - TestMode::Mixnet, - TestMode::SingleHop, - TestMode::TwoHop, + TestMode::Core, + TestMode::WgMix, + TestMode::WgLp, TestMode::LpOnly, + TestMode::Socks5Only, + TestMode::All, ] { let s = mode.to_string(); let parsed: TestMode = s.parse().unwrap(); diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index a694d6de86..b7a6a23b19 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -1,897 +1,449 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::common::bandwidth_helpers::build_bandwidth_controller; use crate::common::helpers; +use crate::common::nodes::TestedNodeDetails; use crate::common::probe_tests::{ do_ping, do_socks5_connectivity_test, lp_registration_probe, wg_probe, wg_probe_lp, }; -use crate::common::types::{Entry, Exit, Socks5ProbeResults, WgProbeResults}; -use crate::config::Socks5Args; -use anyhow::bail; -use nym_api_requests::models::NetworkRequesterDetailsV1; +use crate::common::types::{Entry, LpProbeResults}; +use crate::config::{CredentialArgs, CredentialMode, NetstackArgs, ProbeConfig}; use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; +use nym_bandwidth_controller::BandwidthTicketProvider; use nym_client_core::config::ForgetMe; use nym_config::defaults::NymNetworkDetails; use nym_credentials_interface::TicketType; -use nym_crypto::asymmetric::x25519::KeyPair; +use nym_crypto::asymmetric::x25519; use nym_sdk::mixnet::{ - CredentialStorage, Ephemeral, KeyStore, MixnetClient, MixnetClientBuilder, MixnetClientStorage, - NodeIdentity, StoragePaths, + Ephemeral, KeyStore, MixnetClient, MixnetClientBuilder, MixnetClientStorage, StoragePaths, }; -use nym_topology::NymTopology; +use nym_topology::{HardcodedTopologyProvider, NymTopology}; use rand::rngs::OsRng; use std::path::PathBuf; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::*; -use url::Url; + +pub use crate::common::nodes::{NymApiDirectory, query_gateway_by_ip}; +pub use crate::common::types::{ProbeOutcome, ProbeResult}; mod common; pub use common::types; pub mod config; -use crate::common::bandwidth_helpers::{ - acquire_bandwidth, build_bandwidth_controller, import_bandwidth, -}; -pub use crate::common::nodes::{ - DirectoryNode, NymApiDirectory, TestedNode, TestedNodeDetails, TestedNodeLpDetails, - query_gateway_by_ip, -}; -pub use crate::common::types::{IpPingReplies, ProbeOutcome, ProbeResult}; -pub use crate::config::{CredentialArgs, NetstackArgs, TestMode}; - pub struct Probe { - entrypoint: NodeIdentity, - tested_node: TestedNode, - amnezia_args: String, - netstack_args: NetstackArgs, - credentials_args: CredentialArgs, - /// Pre-queried gateway node (used when --gateway-ip is specified) - direct_gateway_node: Option, - /// Pre-queried exit gateway node (used when --exit-gateway-ip is specified for LP forwarding) - exit_gateway_node: Option, - /// Localnet entry gateway info (used when --entry-gateway-identity is specified) - localnet_entry: Option, - /// Localnet exit gateway info (used when --exit-gateway-identity is specified) - localnet_exit: Option, - socks5_args: Socks5Args, + /// Entry node + entry_node: TestedNodeDetails, + /// Optional exit gateway node. If not provided, entry will be used + exit_node: Option, + + config: ProbeConfig, + + network: NymNetworkDetails, + + topology: Option, } impl Probe { + /// Create a probe with pre-queried gateway nodes pub fn new( - entrypoint: NodeIdentity, - tested_node: TestedNode, - netstack_args: NetstackArgs, - credentials_args: CredentialArgs, - socks5_args: Socks5Args, + entry_node: TestedNodeDetails, + exit_node: Option, + network: NymNetworkDetails, + config: ProbeConfig, ) -> Self { Self { - entrypoint, - tested_node, - amnezia_args: "".into(), - netstack_args, - credentials_args, - direct_gateway_node: None, - exit_gateway_node: None, - localnet_entry: None, - localnet_exit: None, - socks5_args, + entry_node, + exit_node, + network, + config, + topology: None, } } - /// Create a probe with a pre-queried gateway node (for direct IP mode) - pub fn new_with_gateway( - entrypoint: NodeIdentity, - tested_node: TestedNode, - netstack_args: NetstackArgs, - credentials_args: CredentialArgs, - gateway_node: DirectoryNode, - socks5_args: Socks5Args, - ) -> Self { - Self { - entrypoint, - tested_node, - amnezia_args: "".into(), - netstack_args, - credentials_args, - direct_gateway_node: Some(gateway_node), - exit_gateway_node: None, - localnet_entry: None, - localnet_exit: None, - socks5_args, - } - } - - /// Create a probe with both entry and exit gateways pre-queried (for LP forwarding tests) - pub fn new_with_gateways( - entrypoint: NodeIdentity, - tested_node: TestedNode, - netstack_args: NetstackArgs, - credentials_args: CredentialArgs, - entry_gateway_node: DirectoryNode, - exit_gateway_node: DirectoryNode, - socks5_args: Socks5Args, - ) -> Self { - Self { - entrypoint, - tested_node, - amnezia_args: "".into(), - netstack_args, - credentials_args, - direct_gateway_node: Some(entry_gateway_node), - exit_gateway_node: Some(exit_gateway_node), - localnet_entry: None, - localnet_exit: None, - socks5_args, - } - } - - /// Create a probe for localnet mode (no HTTP query needed) - /// Uses identity + LP address directly from CLI args - pub fn new_localnet( - entry: TestedNodeDetails, - exit: Option, - netstack_args: NetstackArgs, - credentials_args: CredentialArgs, - socks5_args: Socks5Args, - ) -> Self { - let entrypoint = entry.identity; - Self { - entrypoint, - tested_node: TestedNode::SameAsEntry, - amnezia_args: "".into(), - netstack_args, - credentials_args, - direct_gateway_node: None, - exit_gateway_node: None, - localnet_entry: Some(entry), - localnet_exit: exit, - socks5_args, - } - } - - pub fn with_amnezia(&mut self, args: &str) -> &Self { - self.amnezia_args = args.to_string(); - self - } - - #[allow(clippy::too_many_arguments)] - pub async fn probe( - self, - directory: Option, - nyxd_url: Url, - ignore_egress_epoch_role: bool, - only_wireguard: bool, - only_lp_registration: bool, - test_lp_wg: bool, - min_mixnet_performance: Option, - network_details: NymNetworkDetails, + /// Run a probe as an NS agent, i.e. a bonded node on a known network + pub async fn probe_run_agent( + mut self, + credential_args: CredentialArgs, ) -> anyhow::Result { - let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; - - let tested_entry = self.tested_node.is_same_as_entry(); - let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; - let storage = Ephemeral::default(); + let mixnet_debug_config = helpers::mixnet_debug_config( + self.config.min_gateway_mixnet_performance, + self.config.ignore_egress_epoch_role, + ); + + // If we need to run at least one mixnet client, prefetch topology + if self.config.test_mode.needs_mixnet() || self.config.test_mode.socks5_tests() { + self.topology = helpers::fetch_topology(&self.network, &mixnet_debug_config) + .await + .inspect_err(|e| warn!("Failed to fetch topology for that run, mixnet clients will have to handle themselves : {e}")).ok(); + } + // Connect to the mixnet via the entry gateway - let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone()) - .request_gateway(mixnet_entry_gateway_id.to_string()) - .network_details(network_details.clone()) - .debug_config(helpers::mixnet_debug_config( - min_mixnet_performance, - ignore_egress_epoch_role, - )) + let mut mixnet_client_builder = MixnetClientBuilder::new_with_storage(storage.clone()) + .request_gateway(self.entry_node.identity.to_string()) + .network_details(self.network.clone()) + .debug_config(mixnet_debug_config) .with_forget_me(ForgetMe::new_all()) - .credentials_mode(true) - .build()?; + .credentials_mode(true); - // in normal operation expects the ticket material to be provided as an argument - let bandwidth_import = disconnected_mixnet_client.begin_bandwidth_import(); - import_bandwidth(bandwidth_import, tickets_materials).await?; + if let Some(topology) = &self.topology { + mixnet_client_builder = mixnet_client_builder.custom_topology_provider(Box::new( + HardcodedTopologyProvider::new(topology.clone()), + )); + } - let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; + let disconnected_mixnet_client = mixnet_client_builder.build()?; - // Extract topology from the connected client (if successful) to reuse for SOCKS5 test - let topology = match &mixnet_client { - Ok(client) => client - .read_current_route_provider() - .await - .map(|rp| rp.topology.clone()), - Err(_) => None, + // Import credential + credential_args + .import_credential(&disconnected_mixnet_client) + .await?; + + let bandwidth_provider = + build_bandwidth_controller(&self.network, storage.credential_store().clone(), false)?; + + // Mixnet client start + let mixnet_client = if self.config.test_mode.needs_mixnet() { + Some(disconnected_mixnet_client.connect_to_mixnet().await) + } else { + // Make sure keys are generated, in case we don't start the mixnet client + let key_store = storage.key_store(); + let mut rng = OsRng; + if key_store.load_keys().await.is_err() { + tracing::log::debug!("Generating new client keys"); + nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; + } + None }; - // Convert legacy flags to TestMode - let has_exit = self.exit_gateway_node.is_some() || self.localnet_exit.is_some(); - let test_mode = - TestMode::from_flags(only_wireguard, only_lp_registration, test_lp_wg, has_exit); - - self.do_probe_test( - Some(mixnet_client), - storage, - mixnet_entry_gateway_id, - node_info, - directory.as_ref(), - nyxd_url, - tested_entry, - test_mode, - only_wireguard, - false, // Not using mock ecash in regular probe mode - network_details, - topology, - ) - .await + self.do_probe_test(mixnet_client, bandwidth_provider).await } - #[allow(clippy::too_many_arguments)] + /// Run a probe on unannounced gateway(s) some tests will not be available pub async fn probe_run_locally( self, config_dir: &PathBuf, - mnemonic: Option<&str>, - directory: Option, - nyxd_url: Url, - ignore_egress_epoch_role: bool, - only_wireguard: bool, - only_lp_registration: bool, - test_lp_wg: bool, - min_mixnet_performance: Option, - use_mock_ecash: bool, - network_details: NymNetworkDetails, + credential: CredentialMode, ) -> anyhow::Result { - // Localnet mode - identity + LP address from CLI, no HTTP query - // This path is used when --entry-gateway-identity is specified - if let Some(entry_info) = &self.localnet_entry { - info!("Using localnet mode with CLI-provided gateway identities"); - - // Initialize storage (needed for credentials) - if !config_dir.exists() { - std::fs::create_dir_all(config_dir)?; - } - let storage_paths = StoragePaths::new_from_dir(config_dir)?; - let storage = storage_paths - .initialise_default_persistent_storage() - .await?; - - // For localnet, use entry as the test node (or exit if provided) - let mixnet_entry_gateway_id = entry_info.identity; - let node_info = if let Some(exit_info) = &self.localnet_exit { - exit_info.clone() - } else { - entry_info.clone() - }; - - // Convert legacy flags to TestMode - let has_exit = self.localnet_exit.is_some(); - let test_mode = - TestMode::from_flags(only_wireguard, only_lp_registration, test_lp_wg, has_exit); - - return self - .do_probe_test( - None, - storage, - mixnet_entry_gateway_id, - node_info, - directory.as_ref(), - nyxd_url, - false, // tested_entry - test_mode, - only_wireguard, - use_mock_ecash, - network_details, - None, // No topology (no mixnet client in localnet mode) - ) - .await; - } - - // If both gateways are pre-queried via --gateway-ip and --exit-gateway-ip, - // skip mixnet setup entirely - we have all the data we need - if self.direct_gateway_node.is_some() && self.exit_gateway_node.is_some() { - let entry_node = if let Some(entry_node) = self.direct_gateway_node.as_ref() { - entry_node - } else { - return Err(anyhow::anyhow!("Entry gateway node is missing")); - }; - let exit_node = if let Some(exit_node) = self.exit_gateway_node.as_ref() { - exit_node - } else { - return Err(anyhow::anyhow!("Exit gateway node is missing")); - }; - - // Initialize storage (needed for credentials) - if !config_dir.exists() { - std::fs::create_dir_all(config_dir)?; - } - let storage_paths = StoragePaths::new_from_dir(config_dir)?; - let storage = storage_paths - .initialise_default_persistent_storage() - .await?; - - // Get node details from pre-queried nodes - let mixnet_entry_gateway_id = entry_node.identity(); - let node_info = exit_node.to_testable_node()?; - - // Convert legacy flags to TestMode (has_exit = true since we have exit_gateway_node) - let test_mode = - TestMode::from_flags(only_wireguard, only_lp_registration, test_lp_wg, true); - - return self - .do_probe_test( - None, - storage, - mixnet_entry_gateway_id, - node_info, - directory.as_ref(), - nyxd_url, - false, // tested_entry - test_mode, - only_wireguard, - use_mock_ecash, - network_details, - None, // No topology (no mixnet client in direct gateway mode) - ) - .await; - } - - // If only testing LP registration, use the dedicated LP-only path - // This skips mixnet setup entirely and allows testing local gateways - if only_lp_registration { - return self - .probe_lp_only(config_dir, directory, nyxd_url, use_mock_ecash) - .await; - } - - let tested_entry = self.tested_node.is_same_as_entry(); - let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; - - if config_dir.is_file() { - bail!("provided configuration directory is a file"); - } - - if !config_dir.exists() { - std::fs::create_dir_all(config_dir)?; - } - let storage_paths = StoragePaths::new_from_dir(config_dir)?; let storage = storage_paths .initialise_default_persistent_storage() .await?; - // Connect to the mixnet via the entry gateway, without forget-me flag so that gateway remembers client - // and keeps its bandwidth between probe runs + // We cannot run mixnet tests on unannounced gateway, but we still need one to import credential if not using mock ecash let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone()) - .request_gateway(mixnet_entry_gateway_id.to_string()) - .network_details(network_details.clone()) - .debug_config(helpers::mixnet_debug_config( - min_mixnet_performance, - ignore_egress_epoch_role, - )) - .credentials_mode(true) + .credentials_mode(!credential.use_mock_ecash) .build()?; + // Acquire credential if needed + credential + .acquire(&disconnected_mixnet_client, &storage) + .await?; + + let bandwidth_provider = build_bandwidth_controller( + &self.network, + storage.credential_store().clone(), + credential.use_mock_ecash, + )?; + + // Make sure keys are generated let key_store = storage.key_store(); let mut rng = OsRng; - - // WORKAROUND SINCE IT HASN'T MADE IT TO THE MONOREPO: if key_store.load_keys().await.is_err() { tracing::log::debug!("Generating new client keys"); nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; } - let ticketbook_count = storage - .credential_store() - .get_ticketbooks_info() - .await? - .len(); - - info!("Credential store contains {} ticketbooks", ticketbook_count); - - // Only acquire real bandwidth if not using mock ecash - if ticketbook_count < 1 && !use_mock_ecash { - let mnemonic = mnemonic.ok_or_else(|| { - anyhow::anyhow!("mnemonic is required when not using mock ecash (--use-mock-ecash)") - })?; - for ticketbook_type in [ - TicketType::V1MixnetEntry, - TicketType::V1WireguardEntry, - TicketType::V1WireguardExit, - ] { - acquire_bandwidth(mnemonic, &disconnected_mixnet_client, ticketbook_type).await?; - } - } else if use_mock_ecash { - info!("Using mock ecash mode - skipping bandwidth acquisition"); - } - - let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; - - // extract topology from the connected client (if any) to reuse for SOCKS5 test - let topology = match &mixnet_client { - Ok(client) => client - .read_current_route_provider() - .await - .map(|rp| rp.topology.clone()), - Err(_) => None, - }; - - // Convert legacy flags to TestMode - let has_exit = self.exit_gateway_node.is_some() || self.localnet_exit.is_some(); - let test_mode = - TestMode::from_flags(only_wireguard, only_lp_registration, test_lp_wg, has_exit); - - self.do_probe_test( - Some(mixnet_client), - storage, - mixnet_entry_gateway_id, - node_info, - directory.as_ref(), - nyxd_url, - tested_entry, - test_mode, - only_wireguard, - use_mock_ecash, - network_details, - topology, - ) - .await + self.do_probe_test(None, bandwidth_provider).await } - /// Probe LP registration only, skipping all mixnet tests - /// This is useful for testing local dev gateways that aren't registered in nym-api - pub async fn probe_lp_only( - self, + pub async fn probe_run( + mut self, config_dir: &PathBuf, - directory: Option, - nyxd_url: Url, - use_mock_ecash: bool, + credential: CredentialMode, ) -> anyhow::Result { - let tested_entry = self.tested_node.is_same_as_entry(); - let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; - - if config_dir.is_file() { - bail!("provided configuration directory is a file"); - } - - if !config_dir.exists() { - std::fs::create_dir_all(config_dir)?; - } - let storage_paths = StoragePaths::new_from_dir(config_dir)?; let storage = storage_paths .initialise_default_persistent_storage() .await?; - let key_store = storage.key_store(); - let mut rng = OsRng; + let mixnet_debug_config = helpers::mixnet_debug_config( + self.config.min_gateway_mixnet_performance, + self.config.ignore_egress_epoch_role, + ); - // Generate client keys if they don't exist - if key_store.load_keys().await.is_err() { - tracing::log::debug!("Generating new client keys"); - nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; + // If we need to run at least one mixnet client, prefetch topology + if self.config.test_mode.needs_mixnet() || self.config.test_mode.socks5_tests() { + self.topology = helpers::fetch_topology(&self.network, &mixnet_debug_config) + .await + .inspect_err(|e| warn!("Failed to fetch topology for that run, mixnet clients will have to handle themselves : {e}")).ok(); } - // Check if node has LP address - let Some(lp_data) = node_info.lp_data else { - bail!("Gateway does not have LP data configured"); + // Connect to the mixnet via the entry gateway, with forget-me flag only for stats so that gateway remembers client + // and keeps its bandwidth between probe runs + let mut mixnet_client_builder = MixnetClientBuilder::new_with_storage(storage.clone()) + .request_gateway(self.entry_node.identity.to_string()) + .network_details(self.network.clone()) + .debug_config(mixnet_debug_config) + .with_forget_me(ForgetMe::new_stats()) + .credentials_mode(!credential.use_mock_ecash); + + if let Some(topology) = &self.topology { + mixnet_client_builder = mixnet_client_builder.custom_topology_provider(Box::new( + HardcodedTopologyProvider::new(topology.clone()), + )); + } + + let disconnected_mixnet_client = mixnet_client_builder.build()?; + + // Acquire credential if needed + credential + .acquire(&disconnected_mixnet_client, &storage) + .await?; + + let bandwidth_provider = build_bandwidth_controller( + &self.network, + storage.credential_store().clone(), + credential.use_mock_ecash, + )?; + + // Mixnet client start + let mixnet_client = if self.config.test_mode.needs_mixnet() { + Some(disconnected_mixnet_client.connect_to_mixnet().await) + } else { + // Make sure keys are generated, in case we don't start the mixnet client + let key_store = storage.key_store(); + let mut rng = OsRng; + if key_store.load_keys().await.is_err() { + tracing::log::debug!("Generating new client keys"); + nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; + } + None }; - info!("Testing LP registration for gateway {}", node_info.identity); - - // Create bandwidth controller for credential preparation - let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( - &NymNetworkDetails::new_from_env(), - )?; - let client = nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; - - let bw_controller = - build_bandwidth_controller(client, storage.credential_store().clone(), use_mock_ecash); - - // Run LP registration probe - let lp_outcome = lp_registration_probe(node_info.identity, lp_data, &bw_controller) - .await - .unwrap_or_default(); - - // Return result with only LP outcome - Ok(ProbeResult { - node: node_info.identity.to_string(), - used_entry: mixnet_entry_gateway_id.to_string(), - outcome: ProbeOutcome { - as_entry: Entry::NotTested, - as_exit: if tested_entry { - None - } else { - Some(Exit::fail_to_connect()) - }, - wg: None, - socks5: None, - lp: Some(lp_outcome), - }, - }) + self.do_probe_test(mixnet_client, bandwidth_provider).await } - async fn test_socks5_if_possible( - &self, - network_details: NymNetworkDetails, - network_requester_details: &Option, - directory: &NymApiDirectory, - topology: Option, - ) -> Option { - if let Some(nr_details) = network_requester_details { - match do_socks5_connectivity_test( - &nr_details.address, - network_details, - directory, - self.socks5_args.socks5_json_rpc_url_list.clone(), - self.socks5_args.mixnet_client_timeout_sec, - self.socks5_args.test_count, - self.socks5_args.failure_count_cutoff, - topology, - ) - .await - { - Ok(results) => Some(results), - Err(e) => { - error!("SOCKS5 test failed: {}", e); + #[allow(clippy::too_many_arguments)] + pub async fn do_probe_test( + self, + mixnet_client: Option>, + bandwith_provider: Box, + ) -> anyhow::Result { + // Setup exit node + let entry_under_test = self.exit_node.is_none(); + let exit_node = self.exit_node.unwrap_or(self.entry_node.clone()); + + let mut probe_result = ProbeResult { + node: self.entry_node.identity.to_string(), + used_entry: exit_node.identity.to_string(), + outcome: ProbeOutcome { + as_entry: Entry::NotTested, + as_exit: None, + wg: None, + lp: None, + socks5: None, + }, + }; + + let mixnet_client = match mixnet_client { + Some(Ok(mixnet_client)) => { + // We can connect, we don't know about routing yet, but having `false` if we don't test it is weird + probe_result.outcome.as_entry = Entry::success(); + info!( + "Successfully connected to entry gateway: {}", + self.entry_node.identity + ); + info!("Our nym address: {}", *mixnet_client.nym_address()); + Some(mixnet_client) + } + Some(Err(err)) => { + error!("Failed to connect to mixnet: {err}"); + probe_result.outcome.as_entry = if entry_under_test { + Entry::fail_to_connect() + } else { + Entry::EntryFailure + }; + None + } + None => { + // At the moment, this is no-op. But if the initialization changes, we will have the correct value + probe_result.outcome.as_entry = Entry::NotTested; + None + } + }; + + // Mixnet ping tests + // There is some weird gymnastics with the mixnet client, but we need to give and then retrieve ownership + let mixnet_client = if self.config.test_mode.mixnet_tests() { + match mixnet_client { + Some(client) => { + let nym_address = *client.nym_address(); + let (outcome, client) = do_ping( + client, + nym_address, + exit_node.exit_router_address, + entry_under_test, + ) + .await; + match outcome { + Ok(outcome) => { + probe_result.outcome = outcome; + } + Err(e) => { + error!("Mixnet ping tests ended with an error : {e}"); + } + } + Some(client) + } + None => { + error!("Mixnet tests cannot be run without a mixnet client"); + probe_result.outcome.as_entry = if entry_under_test { + Entry::fail_to_connect() + } else { + Entry::EntryFailure + }; None } } } else { - info!("No NR available, skipping SOCKS5 tests"); - None - } - } - - pub async fn lookup_gateway( - &self, - directory: &Option, - ) -> anyhow::Result<(NodeIdentity, TestedNodeDetails)> { - // If we have a pre-queried gateway node (direct IP mode), use that - if let Some(direct_node) = &self.direct_gateway_node { - info!("Using pre-queried gateway node from direct IP query"); - let node_info = direct_node.to_testable_node()?; - info!("connecting to entry gateway: {}", direct_node.identity()); - debug!( - "authenticator version: {:?}", - node_info.authenticator_version - ); - return Ok((self.entrypoint, node_info)); - } - - // Otherwise, use the directory (original behavior) - let directory = directory - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Directory is required when not using --gateway-ip"))?; - - // Setup the entry gateways - let entry_gateway = directory.entry_gateway(&self.entrypoint)?; - - let node_info: TestedNodeDetails = match self.tested_node { - TestedNode::Custom { - identity: _, - shares_entry: true, - } => { - debug!( - "testing node {} as both entry and exit", - entry_gateway.identity() - ); - entry_gateway.to_testable_node()? - } - TestedNode::Custom { - identity, - shares_entry: false, - } => { - let node = directory.get_nym_node(identity)?; - info!( - "testing node {} (via entry {})", - node.identity(), - entry_gateway.identity() - ); - node.to_testable_node()? - } - TestedNode::SameAsEntry => entry_gateway.to_testable_node()?, + mixnet_client }; - info!("connecting to entry gateway: {}", entry_gateway.identity()); - debug!( - "authenticator version: {:?}", - node_info.authenticator_version - ); + // Wireguard with Authenticator test + if let Some(mixnet_client) = mixnet_client { + // We have a mixnet_client to disconnect at the end here + if self.config.test_mode.wireguard_tests() { + if let (Some(authenticator), Some(ip_address)) = + (exit_node.authenticator_address, exit_node.ip_address) + { + info!("Testing WireGuard via Mixnet registration"); + // Run wireguard with authenticator + let nym_address = *mixnet_client.nym_address(); + // Start the mixnet listener that the auth clients use to receive messages. + let mixnet_listener_task = + AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()) + .start(); + let mut rng = rand::thread_rng(); + let auth_client = AuthenticatorClient::new( + mixnet_listener_task.subscribe(), + mixnet_listener_task.mixnet_sender(), + nym_address, + authenticator, + exit_node.authenticator_version, + Arc::new(x25519::KeyPair::new(&mut rng)), + ip_address, + ); - Ok((self.entrypoint, node_info)) - } - - #[allow(clippy::too_many_arguments)] - pub async fn do_probe_test( - &self, - mixnet_client: Option>, - storage: T, - mixnet_entry_gateway_id: NodeIdentity, - node_info: TestedNodeDetails, - directory: Option<&NymApiDirectory>, - nyxd_url: Url, - tested_entry: bool, - test_mode: TestMode, - only_wireguard: bool, - use_mock_ecash: bool, - network_details: NymNetworkDetails, - topology: Option, - ) -> anyhow::Result - where - T: MixnetClientStorage + Clone + 'static, - ::StorageError: Send + Sync, - { - let Some(directory) = directory else { - bail!("You need to provide NYM API through environment") - }; - // test_mode replaces the old only_lp_registration and test_lp_wg flags. - // only_wireguard is kept separate as it controls ping behavior within Mixnet mode. - let mut rng = rand::thread_rng(); - let mixnet_client = match mixnet_client { - Some(Ok(mixnet_client)) => Some(mixnet_client), - Some(Err(err)) => { - error!("Failed to connect to mixnet: {err}"); - return Ok(ProbeResult { - node: node_info.identity.to_string(), - used_entry: mixnet_entry_gateway_id.to_string(), - outcome: ProbeOutcome { - as_entry: if tested_entry { - Entry::fail_to_connect() - } else { - Entry::EntryFailure - }, - as_exit: None, - socks5: None, - wg: None, - lp: None, - }, - }); - } - None => None, - }; - - // Determine if we should run ping tests: - // - Only in Mixnet mode (LP modes don't use mixnet) - // - And only if not --only-wireguard (which skips pings) - let run_ping_tests = test_mode.needs_mixnet() && !only_wireguard; - - let (outcome, mixnet_client) = if let Some(mixnet_client) = mixnet_client { - let nym_address = *mixnet_client.nym_address(); - let entry_gateway = nym_address.gateway().to_base58_string(); - - info!("Successfully connected to entry gateway: {entry_gateway}"); - info!("Our nym address: {nym_address}"); - - // Run ping tests if applicable - let (outcome, mixnet_client) = if run_ping_tests { - do_ping( - mixnet_client, - nym_address, - node_info.exit_router_address, - tested_entry, - ) - .await - } else { - ( - Ok(ProbeOutcome { - as_entry: if tested_entry { - Entry::success() - } else { - Entry::NotTested - }, - as_exit: None, - socks5: None, - wg: None, - lp: None, - }), - mixnet_client, - ) - }; - (outcome, Some(mixnet_client)) - } else if test_mode.uses_lp() && test_mode.tests_wireguard() { - // LP modes (SingleHop/TwoHop) don't need mixnet client - // Create default outcome and continue to LP-WG test below - ( - Ok(ProbeOutcome { - as_entry: Entry::NotTested, - as_exit: None, - socks5: None, - wg: None, - lp: None, - }), - None, - ) - } else { - // For Mixnet mode, missing mixnet client is a failure - ( - Ok(ProbeOutcome { - as_entry: if tested_entry { - Entry::fail_to_connect() + let (wg_ticket_type, credential_provider) = if entry_under_test { + (TicketType::V1WireguardEntry, self.entry_node.identity) } else { - Entry::EntryFailure - }, - as_exit: None, - socks5: None, - wg: None, - lp: None, - }), - None, - ) - }; + (TicketType::V1WireguardExit, exit_node.identity) + }; - let wg_outcome = if !test_mode.tests_wireguard() { - // LpOnly mode: skip WireGuard test - WgProbeResults::default() - } else if test_mode.uses_lp() { + let credential = bandwith_provider + .get_ecash_ticket(wg_ticket_type, credential_provider, 1) + .await? + .data; + + let outcome = wg_probe( + auth_client, + ip_address, + exit_node.authenticator_version, + self.config.amnezia_args.clone(), + self.config.netstack_args.clone(), + credential, + ) + .await + .unwrap_or_default(); + + // Add wg results to probe result + probe_result.outcome.wg = Some(outcome); + mixnet_listener_task.stop().await; + } else { + warn!("Not enough information to run WireGuard via mixnet registration tests"); + mixnet_client.disconnect().await; + } + } else { + // We are not running WG tests, we don't need the mixnet client anmore + mixnet_client.disconnect().await; + } + } + + // At this point, any mixnet client MUST be disconnected + + // The current probe includes registration as part of the Wireguard result, which makes that a bit awkward + // If we're supposed to run WireGuard tests and LP tests, and Mixnet registration failed, let's try to do it with lp + // This behavior should change in the future + if probe_result.outcome.wg.is_none() + && self.config.test_mode.wireguard_tests() + && self.config.test_mode.lp_tests() + { // Test WireGuard via LP registration (nested session forwarding) info!("Testing WireGuard via LP registration (no mixnet)"); - // Create bandwidth controller for LP registration - let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( - &NymNetworkDetails::new_from_env(), - )?; - let client = - nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; - - let bw_controller = build_bandwidth_controller( - client, - storage.credential_store().clone(), - use_mock_ecash, - ); - - // Determine entry and exit gateways - // Three modes for gateway resolution: - // 1. direct_gateway_node/exit_gateway_node - from --gateway-ip (HTTP API query) - // 2. localnet_entry/localnet_exit - from --entry-gateway-identity (CLI-only) - // 3. directory lookup - original behavior for production - let (entry_gateway, exit_gateway) = if let Some(exit_node) = &self.exit_gateway_node { - // Both entry and exit gateways were pre-queried (direct IP mode) - info!("Using pre-queried entry and exit gateways for LP forwarding test"); - let entry_node = self - .direct_gateway_node - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Entry gateway not available"))?; - - let entry_gateway = entry_node.to_testable_node()?; - let exit_gateway = exit_node.to_testable_node()?; - - (entry_gateway, exit_gateway) - } else if let Some(exit_localnet) = &self.localnet_exit { - // Localnet mode: use CLI-provided identities and LP addresses - info!("Using localnet entry and exit gateways for LP forwarding test"); - let entry_localnet = self.localnet_entry.as_ref().ok_or_else(|| { - anyhow::anyhow!("Entry gateway not available in localnet mode") - })?; - - (entry_localnet.clone(), exit_localnet.clone()) - } else { - // Original behavior: query from directory - // The tested node is the exit - let exit_gateway = node_info.clone(); - - let entry_gateway_node = directory.entry_gateway(&mixnet_entry_gateway_id)?; - let entry_gateway = entry_gateway_node.to_testable_node()?; - - (entry_gateway, exit_gateway) - }; - - wg_probe_lp( - &entry_gateway, - &exit_gateway, - &bw_controller, - self.amnezia_args.clone(), - self.netstack_args.clone(), - ) - .await - .unwrap_or_default() - } else if let (Some(authenticator), Some(ip_address)) = - (node_info.authenticator_address, node_info.ip_address) - { - let mixnet_client = if let Some(mixnet_client) = mixnet_client { - mixnet_client - } else { - bail!( - "Mixnet client is required for authenticator WireGuard probe, run in LP mode instead" - ); - }; - - let nym_address = *mixnet_client.nym_address(); - // Start the mixnet listener that the auth clients use to receive messages. - let mixnet_listener_task = - AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()).start(); - - let auth_client = AuthenticatorClient::new( - mixnet_listener_task.subscribe(), - mixnet_listener_task.mixnet_sender(), - nym_address, - authenticator, - node_info.authenticator_version, - Arc::new(KeyPair::new(&mut rng)), - ip_address, - ); - let config = - nym_validator_client::nyxd::Config::try_from_nym_network_details(&network_details)?; - let client = - nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; - let bw_controller = nym_bandwidth_controller::BandwidthController::new( - storage.credential_store().clone(), - client, - ); - let (wg_ticket_type, credential_provider) = if tested_entry { - ( - TicketType::V1WireguardEntry, - nym_address.gateway().to_bytes(), - ) - } else { - (TicketType::V1WireguardExit, node_info.identity.to_bytes()) - }; - - let credential = bw_controller - .prepare_ecash_ticket(wg_ticket_type, credential_provider, 1) - .await? - .data; - - let outcome = wg_probe( - auth_client, - ip_address, - node_info.authenticator_version, - self.amnezia_args.clone(), - self.netstack_args.clone(), - credential, + let outcome = wg_probe_lp( + &self.entry_node, + &exit_node, + &bandwith_provider, + self.config.amnezia_args.clone(), + self.config.netstack_args.clone(), ) .await .unwrap_or_default(); - - mixnet_listener_task.stop().await; - - outcome - } else { - WgProbeResults::default() - }; + probe_result.outcome.wg = Some(outcome); + } // Test LP registration if node has LP address - let lp_outcome = if let Some(lp_data) = node_info.lp_data { - info!("Node has LP data, testing LP registration..."); + if self.config.test_mode.lp_tests() { + if let Some(lp_data) = self.entry_node.lp_data { + info!("Node has LP data, testing LP registration..."); - // Prepare bandwidth credential for LP registration - let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( - &NymNetworkDetails::new_from_env(), - )?; - let client = - nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; - let bw_controller = build_bandwidth_controller( - client, - storage.credential_store().clone(), - use_mock_ecash, - ); + let outcome = + lp_registration_probe(self.entry_node.identity, lp_data, &bandwith_provider) + .await + .unwrap_or_default(); - let outcome = lp_registration_probe(node_info.identity, lp_data, &bw_controller) + probe_result.outcome.lp = Some(outcome); + } else { + warn!("LP test was requested, but node did not have LP data"); + + probe_result.outcome.lp = Some(LpProbeResults { + can_connect: false, + can_handshake: false, + can_register: false, + error: Some("no LP data".into()), + }) + }; + } + + // Test socks5 connectivity + if self.config.test_mode.socks5_tests() { + // test failure doesn't stop further tests + if let Some(network_requester) = exit_node.network_requester_address { + match do_socks5_connectivity_test( + &network_requester, + self.entry_node.identity, + self.network.clone(), + self.config.min_gateway_mixnet_performance, + self.config.socks5_args, + self.topology, + ) .await - .unwrap_or_default(); - - Some(outcome) - } else { - info!("Node does not have LP address, skipping LP registration test"); - None - }; - - // test failure doesn't stop further tests - let socks5_outcome = self - .test_socks5_if_possible( - network_details, - &node_info.network_requester_details, - directory, - topology, - ) - .await; - - // Disconnect the mixnet client gracefully - outcome.map(|mut outcome| { - outcome.wg = Some(wg_outcome); - outcome.lp = lp_outcome; - outcome.socks5 = socks5_outcome; - ProbeResult { - node: node_info.identity.to_string(), - used_entry: mixnet_entry_gateway_id.to_string(), - outcome, + { + Ok(results) => probe_result.outcome.socks5 = Some(results), + Err(e) => { + error!("SOCKS5 test failed: {}", e); + } + } + } else { + warn!("No NR available, skipping SOCKS5 tests"); } - }) + } + + Ok(probe_result) } } diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index b0cc2e9951..7bb668206f 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -1,20 +1,12 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use anyhow::bail; use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; -use nym_crypto::asymmetric::{ed25519, x25519}; -use nym_gateway_probe::config::Socks5Args; -use nym_gateway_probe::{ - CredentialArgs, NetstackArgs, NymApiDirectory, ProbeResult, TestMode, TestedNode, - TestedNodeDetails, TestedNodeLpDetails, query_gateway_by_ip, -}; -use nym_kkt_ciphersuite::{HashFunction, KEM}; +use nym_gateway_probe::config::{CredentialArgs, CredentialMode, ProbeConfig}; +use nym_gateway_probe::{NymApiDirectory, ProbeResult, query_gateway_by_ip}; use nym_sdk::mixnet::NodeIdentity; -use std::collections::HashMap; -use std::net::SocketAddr; use std::path::Path; use std::{path::PathBuf, sync::OnceLock}; use tracing::*; @@ -23,178 +15,84 @@ fn pretty_build_info_static() -> &'static str { static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) } - -fn validate_node_identity(s: &str) -> Result { - match s.parse() { - Ok(cg) => Ok(cg), - Err(_) => Err(format!("failed to parse country group: {s}")), - } -} +const DEFAULT_CONFIG_DIR: &str = "/tmp/nym-gateway-probe/config/"; #[derive(Parser)] #[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] struct CliArgs { #[command(subcommand)] - command: Option, + command: Commands, /// Path pointing to an env file describing the network. - #[arg(short, long, global = true)] + #[arg(short, long)] config_env_file: Option, - /// The specific gateway specified by ID. - #[arg(long, short = 'g', alias = "gateway", global = true)] - entry_gateway: Option, - - /// The address of the gateway to probe directly (bypasses directory lookup) - /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) - #[arg(long, global = true)] - gateway_ip: Option, - - // ########## - // ENTRY - // ########## - /// Ed25519 identity of the entry gateway (base58 encoded) - /// When provided, skips HTTP API query - use for localnet testing - #[arg(long, global = true)] - entry_gateway_identity: Option, - - /// x25519 key of the entry gateway used for KKT exchange (base58 encoded) - #[arg(long, global = true, requires = "entry_gateway_identity")] - entry_gateway_x25519_key: Option, - - /// expected kem type of the entry gateway used during KKT exchange - #[arg(long, global = true, requires = "entry_gateway_x25519_key")] - entry_gateway_kem_key_type: Option, - - /// expected hash function used for the entry gateway kem key digest - #[arg(long, global = true, requires = "entry_gateway_kem_key_type")] - entry_gateway_kem_key_hash_function: Option, - - /// expected entry gateway kem key digest (base58 encoded) - #[arg(long, global = true, requires = "entry_gateway_kem_key_hash_function")] - entry_gateway_kem_hey_hash_bs58: Option, - - /// LP listener address for entry gateway (e.g., "192.168.66.6:41264") - /// Used with --entry-gateway-identity for localnet mode - #[arg(long, global = true)] - entry_lp_address: Option, - - // ########## - // EXIT - // ########## - /// The address of the exit gateway for LP forwarding tests (used with --test-lp-wg) - /// When specified, --gateway-ip becomes the entry gateway and this becomes the exit gateway - /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) - #[arg(long, global = true)] - exit_gateway_ip: Option, - - /// Ed25519 identity of the exit gateway (base58 encoded) - /// When provided, skips HTTP API query - use for localnet testing - #[arg(long, global = true)] - exit_gateway_identity: Option, - - /// x25519 key of the exit gateway used for KKT exchange (base58 encoded) - #[arg(long, global = true, requires = "exit_gateway_identity")] - exit_gateway_x25519_key: Option, - - /// expected kem type of the exit gateway used during KKT exchange - #[arg(long, global = true, requires = "exit_gateway_x25519_key")] - exit_gateway_kem_key_type: Option, - - /// expected hash function used for the exit gateway kem key digest - #[arg(long, global = true, requires = "exit_gateway_kem_key_type")] - exit_gateway_kem_key_hash_function: Option, - - /// expected exit gateway kem key digest (base58 encoded) - #[arg(long, global = true, requires = "exit_gateway_kem_key_hash_function")] - exit_gateway_kem_hey_hash_bs58: Option, - - /// LP listener address for exit gateway (e.g., "172.18.0.5:41264") - /// This is the address the entry gateway uses to reach exit (for forwarding) - /// Used with --exit-gateway-identity for localnet mode - #[arg(long, global = true)] - exit_lp_address: Option, - - /// Default LP control port when deriving LP address from gateway IP - #[arg(long, global = true, default_value = "41264")] - lp_port: u16, - - /// Identity of the node to test - #[arg(long, short, value_parser = validate_node_identity, global = true)] - node: Option, - - #[arg(long, global = true)] - min_gateway_mixnet_performance: Option, - - // this was a dead field - // #[arg(long, global = true)] - // min_gateway_vpn_performance: Option, - #[arg(long, global = true)] - only_wireguard: bool, - - #[arg(long, global = true)] - only_lp_registration: bool, - - /// Test WireGuard via LP registration (no mixnet) - uses nested session forwarding - #[arg(long, global = true)] - test_lp_wg: bool, - - /// Test mode - explicitly specify which tests to run - /// - /// Modes: - /// mixnet - Traditional mixnet testing (entry/exit pings + WireGuard via authenticator) - /// single-hop - LP registration + WireGuard on single gateway (no mixnet) - /// two-hop - Entry LP + Exit LP (nested forwarding) + WireGuard tunnel - /// lp-only - LP registration only (no WireGuard) - /// - /// If not specified, mode is inferred from other flags: - /// --only-lp-registration → lp-only - /// --test-lp-wg with exit gateway → two-hop - /// --test-lp-wg without exit → single-hop - /// otherwise → mixnet - #[arg(long, global = true, value_name = "MODE")] - mode: Option, - /// Disable logging during probe - #[arg(long, global = true)] - ignore_egress_epoch_role: bool, - - #[arg(long, global = true)] + #[arg(long)] no_log: bool, - - /// Arguments to be appended to the wireguard config enabling amnezia-wg configuration - #[arg(long, short, global = true)] - amnezia_args: Option, - - /// Arguments to manage netstack downloads - #[command(flatten)] - netstack_args: NetstackArgs, - - /// Arguments to manage credentials - #[command(flatten)] - credential_args: CredentialArgs, - - /// Arguments to configure socks5 probe - #[command(flatten)] - socks5_args: Socks5Args, } -const DEFAULT_CONFIG_DIR: &str = "/tmp/nym-gateway-probe/config/"; - +#[allow(clippy::large_enum_variant)] #[derive(Subcommand, Debug)] enum Commands { - /// Run the probe locally + /// Run the probe on an unannounced gateway. IP must be provided. Bypasses directory lookup RunLocal { - /// Provide a mnemonic to get credentials (optional when using --use-mock-ecash) - #[arg(long)] - mnemonic: Option, - + /// Directory for credential and mixnet storage #[arg(long)] config_dir: Option, - /// Use mock ecash credentials for testing (requires gateway with --lp-use-mock-ecash) + /// The address of the gateway + /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) #[arg(long)] - use_mock_ecash: bool, + entry_gateway_ip: String, + + /// The address of the exit gateway. If not provided, entry acts as exit + /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) + #[arg(long)] + exit_gateway_ip: Option, + + /// Arguments to manage credentials + #[command(flatten)] + credential_mode: CredentialMode, + + #[command(flatten)] + probe_config: ProbeConfig, + }, + + /// Run the probe on a bonded gateway. Uses directory lookup + Run { + /// Directory for credential and mixnet storage + #[arg(long)] + config_dir: Option, + + /// The specific gateway specified by ID. + #[arg(long, short = 'g', alias = "gateway")] + entry_gateway: NodeIdentity, + + /// Optional identity of the exit node to test, if not provided, entry_gateway is used + #[arg(long)] + exit_gateway: Option, + + /// Arguments to manage credentials + #[command(flatten)] + credential_mode: CredentialMode, + + #[command(flatten)] + probe_config: ProbeConfig, + }, + + /// Run the probe by NS agents + RunAgent { + /// The specific gateway specified by ID. + #[arg(long, short = 'g', alias = "gateway")] + entry_gateway: NodeIdentity, + + /// Arguments to manage credentials + #[command(flatten)] + credential_args: CredentialArgs, + + #[command(flatten)] + probe_config: ProbeConfig, }, } @@ -214,266 +112,66 @@ fn setup_logging() { .init(); } -/// Resolve the test mode from explicit --mode arg or infer from legacy flags -fn resolve_test_mode( - mode_arg: Option<&str>, - only_wireguard: bool, - only_lp_registration: bool, - test_lp_wg: bool, - has_exit_gateway: bool, -) -> anyhow::Result { - if let Some(mode_str) = mode_arg { - // Explicit --mode takes priority - mode_str - .parse::() - .map_err(|e| anyhow::anyhow!("{}", e)) - } else { - // Infer from legacy flags - Ok(TestMode::from_flags( - only_wireguard, - only_lp_registration, - test_lp_wg, - has_exit_gateway, - )) - } -} - -/// Convert TestMode back to legacy boolean flags for backward compatibility -fn mode_to_flags(mode: TestMode) -> (bool, bool, bool) { - match mode { - TestMode::Mixnet => (false, false, false), // only_wireguard handled separately - TestMode::SingleHop => (false, false, true), - TestMode::TwoHop => (false, false, true), - TestMode::LpOnly => (false, true, false), - } -} - -#[allow(clippy::todo)] -#[allow(unreachable_code, unused)] -// ^^^^ // NOTE: to be changed by @SW -#[allow(clippy::unwrap_used)] pub(crate) async fn run() -> anyhow::Result { let args = CliArgs::parse(); if !args.no_log { setup_logging(); } debug!("{:?}", nym_bin_common::bin_info_local_vergen!()); - setup_env(args.config_env_file.as_ref()); + setup_env(args.config_env_file.as_ref()); let network = nym_sdk::NymNetworkDetails::new_from_env(); - let nyxd_url = network - .endpoints - .first() - .map(|ep| ep.nyxd_url()) - .ok_or(anyhow::anyhow!("missing nyxd url"))?; + match args.command { + Commands::RunLocal { + config_dir, + entry_gateway_ip, + exit_gateway_ip, + credential_mode, + probe_config, + } => { + info!("Using direct IP query mode for gateway: {entry_gateway_ip}"); + let entry_details = query_gateway_by_ip(entry_gateway_ip) + .await? + .to_testable_node()?; - args.socks5_args.validate_socks5_endpoints().await?; - - // Three resolution modes in priority order: - // 1. Localnet mode: --entry-gateway-identity provided (no HTTP query) - // 2. Direct IP mode: --gateway-ip provided (queries HTTP API) - // 3. Directory mode: uses nym-api directory service - - // Localnet mode: identity provided via CLI, skip HTTP queries entirely - if let Some(kem_key_digest) = &args.entry_gateway_kem_hey_hash_bs58 { - info!("Using localnet mode with CLI-provided gateway identity"); - - // SAFETY: if kem key digest is provided, all other LP data must also be present - // (enforced by clap) - let hash_fn: HashFunction = args - .entry_gateway_kem_key_hash_function - .as_ref() - .unwrap() - .parse()?; - let kem_type: KEM = args.entry_gateway_kem_key_type.as_ref().unwrap().parse()?; - let x25519_key: x25519::PublicKey = - args.entry_gateway_x25519_key.as_ref().unwrap().parse()?; - let identity: ed25519::PublicKey = args.entry_gateway_identity.as_ref().unwrap().parse()?; - let digest = bs58::decode(&kem_key_digest).into_vec()?; - - let mut expected_kem_key_hashes = HashMap::new(); - let mut digests = HashMap::new(); - digests.insert(hash_fn, digest); - expected_kem_key_hashes.insert(kem_type, digests); - - // Entry LP address: explicit or derived from gateway_ip + lp_port - let entry_lp_addr: SocketAddr = if let Some(lp_addr) = args.entry_lp_address { - lp_addr - } else if let Some(gw_ip) = &args.gateway_ip { - // Derive LP address from gateway IP - let ip: std::net::IpAddr = gw_ip - .parse() - .map_err(|e| anyhow::anyhow!("Invalid gateway-ip '{gw_ip}': {e}"))?; - SocketAddr::new(ip, args.lp_port) - } else { - anyhow::bail!( - "--entry-lp-address or --gateway-ip required with --entry-gateway-identity" - ); - }; - - let entry_lp_node = TestedNodeLpDetails { - address: entry_lp_addr, - expected_kem_key_hashes, - expected_signing_key_hashes: todo!(), - x25519: x25519_key, - lp_version: todo!(), - }; - let entry_details = TestedNodeDetails::from_cli(identity, entry_lp_node); - - // Parse exit gateway if provided - let exit_details = if let Some(kem_key_digest) = &args.exit_gateway_kem_hey_hash_bs58 { - let exit_lp_addr = *args.exit_lp_address.as_ref().ok_or_else(|| { - anyhow::anyhow!("--exit-lp-address required with --exit-gateway-identity") - })?; - - // SAFETY: if kem key digest is provided, all other LP data must also be present - // (enforced by clap) - let hash_fn: HashFunction = args - .exit_gateway_kem_key_hash_function - .as_ref() - .unwrap() - .parse()?; - let kem_type: KEM = args.exit_gateway_kem_key_type.as_ref().unwrap().parse()?; - let x25519_key: x25519::PublicKey = - args.exit_gateway_x25519_key.as_ref().unwrap().parse()?; - let identity: ed25519::PublicKey = - args.exit_gateway_identity.as_ref().unwrap().parse()?; - let digest = bs58::decode(&kem_key_digest).into_vec()?; - - let mut expected_kem_key_hashes = HashMap::new(); - let mut digests = HashMap::new(); - digests.insert(hash_fn, digest); - expected_kem_key_hashes.insert(kem_type, digests); - - let exit_lp_node = TestedNodeLpDetails { - address: exit_lp_addr, - expected_kem_key_hashes, - expected_signing_key_hashes: todo!(), - x25519: x25519_key, - lp_version: todo!(), - }; - - Some(TestedNodeDetails::from_cli(identity, exit_lp_node)) - } else { - None - }; - - // Resolve test mode from --mode arg or infer from flags - let has_exit = exit_details.is_some(); - let test_mode = resolve_test_mode( - args.mode.as_deref(), - args.only_wireguard, - args.only_lp_registration, - args.test_lp_wg, - has_exit, - )?; - - // Validate that two-hop mode has required exit gateway - if test_mode.needs_exit_gateway() && !has_exit { - bail!( - "--mode two-hop requires exit gateway \ - (use --exit-gateway-identity and --exit-lp-address)" - ); - } - - info!("Test mode: {}", test_mode); - - // Convert back to flags for backward compatibility with existing probe methods - // only_wireguard is preserved from args since it's orthogonal to mode - // (it means "skip ping tests" in mixnet mode, irrelevant for LP modes) - let (_, only_lp_registration, test_lp_wg) = mode_to_flags(test_mode); - let only_wireguard = args.only_wireguard; - - let mut trial = nym_gateway_probe::Probe::new_localnet( - entry_details, - exit_details, - args.netstack_args, - args.credential_args, - args.socks5_args, - ); - - if let Some(awg_args) = args.amnezia_args { - trial.with_amnezia(&awg_args); - } - - // Localnet mode doesn't need directory, but nyxd_url is still used for credentials - return match &args.command { - Some(Commands::RunLocal { - mnemonic, - config_dir, - use_mock_ecash, - }) => { - let config_dir = config_dir - .clone() - .unwrap_or_else(|| Path::new(DEFAULT_CONFIG_DIR).join(&network.network_name)); - - info!( - "using the following directory for the probe config: {}", - config_dir.display() - ); - - Box::pin(trial.probe_run_locally( - &config_dir, - mnemonic.as_deref(), - None, // No directory in localnet mode - nyxd_url, - args.ignore_egress_epoch_role, - only_wireguard, - only_lp_registration, - test_lp_wg, - args.min_gateway_mixnet_performance, - *use_mock_ecash, - network, - )) - .await - } - None => { - Box::pin(trial.probe( - None, // No directory in localnet mode - nyxd_url, - args.ignore_egress_epoch_role, - only_wireguard, - only_lp_registration, - test_lp_wg, - args.min_gateway_mixnet_performance, - network, - )) - .await - } - }; - } - - // If gateway IP is provided, query it directly without using the directory - let (entry, directory, gateway_node, exit_gateway_node) = - if let Some(gateway_ip) = args.gateway_ip.clone() { - info!("Using direct IP query mode for gateway: {}", gateway_ip); - let gateway_node = query_gateway_by_ip(gateway_ip).await?; - let identity = gateway_node.identity(); - - // Query exit gateway if provided (for LP forwarding tests) - let exit_node = if let Some(exit_gateway_ip) = args.exit_gateway_ip { - info!( - "Using direct IP query mode for exit gateway: {}", - exit_gateway_ip - ); - Some(query_gateway_by_ip(exit_gateway_ip).await?) + // Parse exit gateway if provided + let exit_details = if let Some(ip_address) = exit_gateway_ip { + info!("Using direct IP query mode for exit gateway: {ip_address}"); + Some(query_gateway_by_ip(ip_address).await?.to_testable_node()?) } else { None }; - // Still create the directory for potential secondary lookups, - // but only if API URL is available - let directory = - if let Some(api_url) = network.endpoints.first().and_then(|ep| ep.api_url()) { - Some(NymApiDirectory::new(api_url).await?) - } else { - None - }; + let config_dir = config_dir + .clone() + .unwrap_or_else(|| Path::new(DEFAULT_CONFIG_DIR).join(&network.network_name)); - (identity, directory, Some(gateway_node), exit_node) - } else { - // Original behavior: use directory service + if config_dir.is_file() { + anyhow::bail!("provided configuration directory is a file"); + } + + if !config_dir.exists() { + std::fs::create_dir_all(config_dir.clone())?; + } + + info!( + "using the following directory for the probe config: {}", + config_dir.display() + ); + + let trial = + nym_gateway_probe::Probe::new(entry_details, exit_details, network, probe_config); + + Box::pin(trial.probe_run_locally(&config_dir, credential_mode)).await + } + Commands::Run { + entry_gateway, + exit_gateway, + config_dir, + credential_mode, + probe_config, + } => { let api_url = network .endpoints .first() @@ -481,121 +179,57 @@ pub(crate) async fn run() -> anyhow::Result { .ok_or(anyhow::anyhow!("missing api url"))?; let directory = NymApiDirectory::new(api_url).await?; + let entry_details = directory + .entry_gateway(&entry_gateway)? + .to_testable_node()?; + let exit_details = exit_gateway + .map(|id_key| directory.exit_gateway(&id_key)) + .transpose()? + .map(|node| node.to_testable_node()) + .transpose()?; - let entry = if let Some(gateway) = &args.entry_gateway { - NodeIdentity::from_base58_string(gateway)? - } else { - directory.random_exit_with_ipr()? - }; - - (entry, Some(directory), None, None) - }; - - let test_point = if let Some(node) = args.node { - TestedNode::Custom { - identity: node, - shares_entry: false, - } - } else { - TestedNode::SameAsEntry - }; - - // Resolve test mode from --mode arg or infer from flags - let has_exit = exit_gateway_node.is_some(); - let test_mode = resolve_test_mode( - args.mode.as_deref(), - args.only_wireguard, - args.only_lp_registration, - args.test_lp_wg, - has_exit, - )?; - info!("Test mode: {}", test_mode); - - // Convert back to flags for backward compatibility with existing probe methods - // only_wireguard is preserved from args since it's orthogonal to mode - let (_, only_lp_registration, test_lp_wg) = mode_to_flags(test_mode); - let only_wireguard = args.only_wireguard; - - let mut trial = if let (Some(entry_node), Some(exit_node)) = (&gateway_node, &exit_gateway_node) - { - // Both entry and exit gateways provided (for LP telescoping tests) - info!("Using both entry and exit gateways for LP forwarding test"); - nym_gateway_probe::Probe::new_with_gateways( - entry, - test_point, - args.netstack_args, - args.credential_args, - entry_node.clone(), - exit_node.clone(), - args.socks5_args, - ) - } else if let Some(gw_node) = gateway_node { - // Only entry gateway provided - nym_gateway_probe::Probe::new_with_gateway( - entry, - test_point, - args.netstack_args, - args.credential_args, - gw_node, - args.socks5_args, - ) - } else { - // No direct gateways, use directory lookup - nym_gateway_probe::Probe::new( - entry, - test_point, - args.netstack_args, - args.credential_args, - args.socks5_args, - ) - }; - - if let Some(awg_args) = args.amnezia_args { - trial.with_amnezia(&awg_args); - } - - match args.command { - Some(Commands::RunLocal { - mnemonic, - config_dir, - use_mock_ecash, - }) => { let config_dir = config_dir .clone() .unwrap_or_else(|| Path::new(DEFAULT_CONFIG_DIR).join(&network.network_name)); + if config_dir.is_file() { + anyhow::bail!("provided configuration directory is a file"); + } + + if !config_dir.exists() { + std::fs::create_dir_all(config_dir.clone())?; + } + info!( "using the following directory for the probe config: {}", config_dir.display() ); - Box::pin(trial.probe_run_locally( - &config_dir, - mnemonic.as_deref(), - directory, - nyxd_url, - args.ignore_egress_epoch_role, - only_wireguard, - only_lp_registration, - test_lp_wg, - args.min_gateway_mixnet_performance, - use_mock_ecash, - network, - )) - .await + let trial = + nym_gateway_probe::Probe::new(entry_details, exit_details, network, probe_config); + Box::pin(trial.probe_run(&config_dir, credential_mode)).await } - None => { - Box::pin(trial.probe( - directory, - nyxd_url, - args.ignore_egress_epoch_role, - only_wireguard, - only_lp_registration, - test_lp_wg, - args.min_gateway_mixnet_performance, - network, - )) - .await + Commands::RunAgent { + entry_gateway, + credential_args, + mut probe_config, + } => { + let api_url = network + .endpoints + .first() + .and_then(|ep| ep.api_url()) + .ok_or(anyhow::anyhow!("missing api url"))?; + + let directory = NymApiDirectory::new(api_url).await?; + let entry_details = directory + .entry_gateway(&entry_gateway)? + .to_testable_node()?; + + // Agents run everything + probe_config.test_mode = nym_gateway_probe::config::TestMode::All; + + let trial = nym_gateway_probe::Probe::new(entry_details, None, network, probe_config); + Box::pin(trial.probe_run_agent(credential_args)).await } } } diff --git a/nym-node-status-api/nym-node-status-agent/Cargo.toml b/nym-node-status-api/nym-node-status-agent/Cargo.toml index 9c8daf7007..cfb9cea2c3 100644 --- a/nym-node-status-api/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-api/nym-node-status-agent/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-agent" -version = "1.1.1" +version = "1.1.2" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs index 8f82428e75..820136b3f0 100644 --- a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs +++ b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs @@ -52,7 +52,7 @@ pub(crate) async fn run_probe( // Run the probe let log = probe.run_and_get_log( - &Some(gateway_identity_key.clone()), + gateway_identity_key.clone(), probe_extra_args, testrun.ticket_materials, ); diff --git a/nym-node-status-api/nym-node-status-agent/src/probe.rs b/nym-node-status-api/nym-node-status-agent/src/probe.rs index 3ee47646b9..610c07c4bd 100644 --- a/nym-node-status-api/nym-node-status-agent/src/probe.rs +++ b/nym-node-status-api/nym-node-status-agent/src/probe.rs @@ -73,16 +73,15 @@ impl GwProbe { pub(crate) fn run_and_get_log( &self, - gateway_key: &Option, + gateway_key: String, probe_extra_args: &Vec, ticket_materials: AttachedTicketMaterials, ) -> String { let mut command = std::process::Command::new(&self.path); command.stdout(std::process::Stdio::piped()); - if let Some(gateway_id) = gateway_key { - command.arg("--gateway").arg(gateway_id); - } + command.arg("run-agent"); + command.arg("--gateway").arg(gateway_key); tracing::info!("Extra args for the probe:"); for arg in probe_extra_args {