[LP Gateway Probe] CLI and behavior improvements (#6400)

* attempt to de-spaghettificationize the gateway probe

* applying suggestions
This commit is contained in:
Simon Wicky
2026-01-30 16:55:29 +01:00
committed by GitHub
parent 8916b021a9
commit cd0881462b
19 changed files with 900 additions and 1756 deletions
+13 -24
View File
@@ -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"]
@@ -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<S>(
rpc_client: QueryHttpRpcNyxdClient,
on_disk_storage: S,
network: &NymNetworkDetails,
storage: S,
use_mock_ecash: bool,
) -> Box<dyn BandwidthTicketProvider>
) -> anyhow::Result<Box<dyn BandwidthTicketProvider>>
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()))
}
}
+48 -1
View File
@@ -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<NymTopology, String> {
// get Nym API URLs from network_details
let nym_api_urls: Vec<Url> = 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")),
}
}
+1 -2
View File
@@ -1,6 +1,7 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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,
+50 -68
View File
@@ -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<DirectoryNod
let build_info_result = client.get_build_information().await;
let aux_details_result = client.get_auxiliary_details().await;
let websockets_result = client.get_mixnet_websockets().await;
let lp_result = client.get_lewes_protocol().await;
// These are optional, so we use ok() to ignore errors
let ipr_result = client.get_ip_packet_router().await.ok();
let authenticator_result = client.get_authenticator().await.ok();
let wireguard_result = client.get_wireguard().await.ok();
let ipr_result = client
.get_ip_packet_router()
.await
.inspect_err(|e| error!("Failed to get ipr information : {e}"))
.ok();
let nr_result = client
.get_network_requester()
.await
.inspect_err(|e| error!("Failed to get nr information : {e}"))
.ok();
let authenticator_result = client
.get_authenticator()
.await
.inspect_err(|e| error!("Failed to get authenticator information : {e}"))
.ok();
let wireguard_result = client
.get_wireguard()
.await
.inspect_err(|e| error!("Failed to get wireguard information : {e}"))
.ok();
let lp_result = client
.get_lewes_protocol()
.await
.inspect_err(|e| error!("Failed to get LP information : {e}"))
.ok();
// Check required fields
let host_info = host_info_result.context("Failed to get host information")?;
@@ -242,7 +266,11 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result<DirectoryNod
}
// Convert to our internal types
let network_requester: Option<NetworkRequesterDetailsV2> = None; // Not needed for LP testing
let network_requester: Option<NetworkRequesterDetailsV2> =
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<IpPacketRouterDetailsV2> =
ipr_result.map(|ipr| IpPacketRouterDetailsV2 {
address: ipr.address,
@@ -260,8 +288,6 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result<DirectoryNod
public_key: wg.public_key,
});
let lp: Option<LewesProtocolDetailsV1> = 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<DirectoryNod
ip_packet_router,
authenticator,
wireguard,
lewes_protocol: lp,
lewes_protocol: lp_result.map(Into::into),
mixnet_websockets: WebSocketsV2 {
ws_port: websockets.ws_port,
wss_port: websockets.wss_port,
@@ -431,45 +457,27 @@ impl NymApiDirectory {
Ok(maybe_entry)
}
pub fn exit_gateway_nr(&self, identity: &NodeIdentity) -> anyhow::Result<DirectoryNode> {
pub fn exit_gateway(&self, identity: &NodeIdentity) -> anyhow::Result<DirectoryNode> {
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<Recipient>,
pub network_requester_details: Option<NetworkRequesterDetailsV1>,
pub network_requester_address: Option<Recipient>,
pub authenticator_address: Option<Recipient>,
pub authenticator_version: AuthenticatorVersion,
pub ip_address: Option<IpAddr>,
@@ -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()
}
}
+41 -125
View File
@@ -1,7 +1,6 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<String>,
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<String>,
netstack_args: NetstackArgs,
) -> anyhow::Result<WgProbeResults> {
// 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<String>,
mixnet_client_timeout: u64,
test_run_count: u64,
failure_count_cutoff: usize,
topology: Option<NymTopology>,
min_gw_performance: Option<u8>,
socks5_args: Socks5Args,
maybe_topology: Option<NymTopology>,
) -> anyhow::Result<Socks5ProbeResults> {
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::<Recipient>() {
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<HardcodedTopologyProvider> = 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<Box<HardcodedTopologyProvider>, String> {
// get Nym API URLs from network_details
let nym_api_urls: Vec<Url> = 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")),
}
}
+1 -1
View File
@@ -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 {
+79 -14
View File
@@ -1,29 +1,94 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<String>,
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<AttachedTicketMaterials> {
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<Ephemeral>,
) -> 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<String>,
}
impl CredentialMode {
pub async fn acquire(
&self,
disconnected_mixnet_client: &DisconnectedMixnetClient<OnDiskPersistent>,
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(())
}
}
+38 -1
View File
@@ -1,12 +1,49 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<u8>,
/// 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<String>,
/// Arguments to manage netstack downloads
#[command(flatten)]
pub netstack_args: NetstackArgs,
/// Arguments to configure socks5 probe
#[command(flatten)]
pub socks5_args: Socks5Args,
}
+12 -12
View File
@@ -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<String>,
#[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<String>,
#[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<String>,
#[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<String>,
}
+7 -5
View File
@@ -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<String>,
#[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,
}
+95 -178
View File
@@ -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<Self, Self::Err> {
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::<TestMode>().unwrap(), TestMode::Mixnet);
assert_eq!(
"single-hop".parse::<TestMode>().unwrap(),
TestMode::SingleHop
);
assert_eq!("two-hop".parse::<TestMode>().unwrap(), TestMode::TwoHop);
assert_eq!("core".parse::<TestMode>().unwrap(), TestMode::Core);
assert_eq!("wg-mix".parse::<TestMode>().unwrap(), TestMode::WgMix);
assert_eq!("wg-lp".parse::<TestMode>().unwrap(), TestMode::WgLp);
assert_eq!("lp-only".parse::<TestMode>().unwrap(), TestMode::LpOnly);
assert_eq!(
"socks5-only".parse::<TestMode>().unwrap(),
TestMode::Socks5Only
);
assert_eq!("all".parse::<TestMode>().unwrap(), TestMode::All);
}
#[test]
fn test_from_str_alternate_formats() {
// Default aliases
assert_eq!("mixnet".parse::<TestMode>().unwrap(), TestMode::Core);
// snake_case
assert_eq!(
"single_hop".parse::<TestMode>().unwrap(),
TestMode::SingleHop
);
assert_eq!("two_hop".parse::<TestMode>().unwrap(), TestMode::TwoHop);
assert_eq!("wg_mix".parse::<TestMode>().unwrap(), TestMode::WgMix);
assert_eq!("wg_lp".parse::<TestMode>().unwrap(), TestMode::WgLp);
assert_eq!("lp_only".parse::<TestMode>().unwrap(), TestMode::LpOnly);
assert_eq!(
"socks5_only".parse::<TestMode>().unwrap(),
TestMode::Socks5Only
);
// no separator
assert_eq!(
"singlehop".parse::<TestMode>().unwrap(),
TestMode::SingleHop
);
assert_eq!("twohop".parse::<TestMode>().unwrap(), TestMode::TwoHop);
assert_eq!("wgmix".parse::<TestMode>().unwrap(), TestMode::WgMix);
assert_eq!("wglp".parse::<TestMode>().unwrap(), TestMode::WgLp);
assert_eq!("lponly".parse::<TestMode>().unwrap(), TestMode::LpOnly);
assert_eq!(
"socks5only".parse::<TestMode>().unwrap(),
TestMode::Socks5Only
);
}
#[test]
fn test_from_str_case_insensitive() {
assert_eq!("MIXNET".parse::<TestMode>().unwrap(), TestMode::Mixnet);
assert_eq!(
"Single-Hop".parse::<TestMode>().unwrap(),
TestMode::SingleHop
);
assert_eq!("TWO_HOP".parse::<TestMode>().unwrap(), TestMode::TwoHop);
assert_eq!("cOrE".parse::<TestMode>().unwrap(), TestMode::Core);
assert_eq!("WG-MIX".parse::<TestMode>().unwrap(), TestMode::WgMix);
assert_eq!("Wg_Lp".parse::<TestMode>().unwrap(), TestMode::WgLp);
assert_eq!("LpOnly".parse::<TestMode>().unwrap(), TestMode::LpOnly);
assert_eq!(
"soCkS5-oNlY".parse::<TestMode>().unwrap(),
TestMode::Socks5Only
);
assert_eq!("ALL".parse::<TestMode>().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();
File diff suppressed because it is too large Load Diff
+145 -511
View File
@@ -1,20 +1,12 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<String> = OnceLock::new();
PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print())
}
fn validate_node_identity(s: &str) -> Result<NodeIdentity, String> {
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<Commands>,
command: Commands,
/// Path pointing to an env file describing the network.
#[arg(short, long, global = true)]
#[arg(short, long)]
config_env_file: Option<PathBuf>,
/// The specific gateway specified by ID.
#[arg(long, short = 'g', alias = "gateway", global = true)]
entry_gateway: Option<String>,
/// 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<String>,
// ##########
// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<SocketAddr>,
// ##########
// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<SocketAddr>,
/// 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<NodeIdentity>,
#[arg(long, global = true)]
min_gateway_mixnet_performance: Option<u8>,
// this was a dead field
// #[arg(long, global = true)]
// min_gateway_vpn_performance: Option<u8>,
#[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<String>,
/// 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<String>,
/// 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<String>,
/// Directory for credential and mixnet storage
#[arg(long)]
config_dir: Option<PathBuf>,
/// 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<String>,
/// 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<PathBuf>,
/// 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<NodeIdentity>,
/// 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<TestMode> {
if let Some(mode_str) = mode_arg {
// Explicit --mode takes priority
mode_str
.parse::<TestMode>()
.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<ProbeResult> {
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<ProbeResult> {
.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
}
}
}