diff --git a/nym-gateway-probe/src/bandwidth_helpers.rs b/nym-gateway-probe/src/common/bandwidth_helpers.rs similarity index 100% rename from nym-gateway-probe/src/bandwidth_helpers.rs rename to nym-gateway-probe/src/common/bandwidth_helpers.rs diff --git a/nym-gateway-probe/src/common/helpers.rs b/nym-gateway-probe/src/common/helpers.rs new file mode 100644 index 0000000000..e80912abc5 --- /dev/null +++ b/nym-gateway-probe/src/common/helpers.rs @@ -0,0 +1,64 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::common::nodes::TestedNodeLpDetails; +use nym_crypto::asymmetric::ed25519; +use nym_ip_packet_requests::v8::response::{ + ControlResponse, DataResponse, InfoLevel, IpPacketResponse, IpPacketResponseData, +}; +use nym_lp::peer::LpRemotePeer; +use nym_sdk::mixnet::ReconstructedMessage; +use tracing::*; + +pub fn to_lp_remote_peer(identity: ed25519::PublicKey, data: TestedNodeLpDetails) -> LpRemotePeer { + LpRemotePeer::new(identity, data.x25519).with_key_digests( + data.expected_kem_key_hashes, + data.expected_signing_key_hashes, + ) +} + +pub fn mixnet_debug_config( + min_gateway_performance: Option, + ignore_egress_epoch_role: bool, +) -> nym_client_core::config::DebugConfig { + let mut debug_config = nym_client_core::config::DebugConfig::default(); + debug_config + .traffic + .disable_main_poisson_packet_distribution = true; + debug_config.cover_traffic.disable_loop_cover_traffic_stream = true; + if let Some(minimum_gateway_performance) = min_gateway_performance { + debug_config.topology.minimum_gateway_performance = minimum_gateway_performance; + } + if ignore_egress_epoch_role { + debug_config.topology.ignore_egress_epoch_role = ignore_egress_epoch_role; + } + + debug_config +} + +pub fn unpack_data_response(reconstructed_message: &ReconstructedMessage) -> Option { + match IpPacketResponse::from_reconstructed_message(reconstructed_message) { + Ok(response) => match response.data { + IpPacketResponseData::Data(data_response) => Some(data_response), + IpPacketResponseData::Control(control) => match *control { + ControlResponse::Info(info) => { + let msg = format!("Received info response from the mixnet: {}", info.reply); + match info.level { + InfoLevel::Info => info!("{msg}"), + InfoLevel::Warn => warn!("{msg}"), + InfoLevel::Error => error!("{msg}"), + } + None + } + _ => { + info!("Ignoring: {:?}", control); + None + } + }, + }, + Err(err) => { + warn!("Failed to parse mixnet message: {err}"); + None + } + } +} diff --git a/nym-gateway-probe/src/icmp.rs b/nym-gateway-probe/src/common/icmp.rs similarity index 100% rename from nym-gateway-probe/src/icmp.rs rename to nym-gateway-probe/src/common/icmp.rs diff --git a/nym-gateway-probe/src/common/mod.rs b/nym-gateway-probe/src/common/mod.rs index 930623615d..3f2cda9f35 100644 --- a/nym-gateway-probe/src/common/mod.rs +++ b/nym-gateway-probe/src/common/mod.rs @@ -6,8 +6,11 @@ //! This module contains shared functionality used by multiple test modes: //! - WireGuard tunnel testing via netstack -pub mod wireguard; - -pub use wireguard::{ - TwoHopWgTunnelConfig, WgTunnelConfig, run_tunnel_tests, run_two_hop_tunnel_tests, -}; +pub(crate) mod bandwidth_helpers; +pub(crate) mod helpers; +pub(crate) mod icmp; +pub(crate) mod netstack; +pub(crate) mod nodes; +pub(crate) mod probe_tests; +pub(crate) mod types; +pub(crate) mod wireguard; diff --git a/nym-gateway-probe/src/netstack.rs b/nym-gateway-probe/src/common/netstack.rs similarity index 99% rename from nym-gateway-probe/src/netstack.rs rename to nym-gateway-probe/src/common/netstack.rs index 3c7bd0772f..9b033f5898 100644 --- a/nym-gateway-probe/src/netstack.rs +++ b/nym-gateway-probe/src/common/netstack.rs @@ -15,7 +15,7 @@ mod sys { } } -use crate::NetstackArgs; +use crate::config::NetstackArgs; #[derive(serde::Serialize)] pub struct NetstackRequest { diff --git a/nym-gateway-probe/src/nodes.rs b/nym-gateway-probe/src/common/nodes.rs similarity index 89% rename from nym-gateway-probe/src/nodes.rs rename to nym-gateway-probe/src/common/nodes.rs index 202e00cd15..6485920574 100644 --- a/nym-gateway-probe/src/nodes.rs +++ b/nym-gateway-probe/src/common/nodes.rs @@ -1,7 +1,6 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::{TestedNodeDetails, TestedNodeLpDetails}; use anyhow::{Context, anyhow, bail}; use nym_api_requests::models::{ AuthenticatorDetailsV2, DeclaredRolesV2, DescribedNodeTypeV2, HostInformationV2, @@ -10,16 +9,20 @@ use nym_api_requests::models::{ }; use nym_authenticator_requests::AuthenticatorVersion; use nym_bin_common::build_information::BinaryBuildInformationOwned; +use nym_crypto::asymmetric::x25519; use nym_http_api_client::UserAgent; +use nym_kkt_ciphersuite::SignatureScheme; +use nym_kkt_ciphersuite::{KEM, KEMKeyDigests}; use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; use nym_node_requests::api::client::NymNodeApiClientExt; use nym_node_requests::api::v1::node::models::AuxiliaryDetails as NodeAuxiliaryDetails; use nym_sdk::mixnet::NodeIdentity; +use nym_sdk::mixnet::Recipient; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::NymNodeDescriptionV2; use rand::prelude::IteratorRandom; use std::collections::HashMap; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::time::Duration; use time::OffsetDateTime; use tracing::{debug, info, warn}; @@ -437,3 +440,69 @@ impl NymApiDirectory { 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 authenticator_address: Option, + pub authenticator_version: AuthenticatorVersion, + pub ip_address: Option, + pub lp_data: Option, +} + +#[derive(Debug, Clone)] +pub struct TestedNodeLpDetails { + pub address: SocketAddr, + pub expected_kem_key_hashes: HashMap, + pub expected_signing_key_hashes: HashMap, + pub x25519: x25519::PublicKey, +} + +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), + // 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 new file mode 100644 index 0000000000..ef039e6ab4 --- /dev/null +++ b/nym-gateway-probe/src/common/probe_tests.rs @@ -0,0 +1,697 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::common::nodes::{TestedNodeDetails, TestedNodeLpDetails}; +use crate::common::types::{ + Entry, Exit, IpPingReplies, LpProbeResults, ProbeOutcome, WgProbeResults, +}; +use crate::common::wireguard::{ + TwoHopWgTunnelConfig, WgTunnelConfig, run_tunnel_tests, run_two_hop_tunnel_tests, +}; +use crate::common::{bandwidth_helpers, helpers, icmp}; +use crate::config::NetstackArgs; +use anyhow::bail; +use base64::{Engine, engine::general_purpose}; +use bytes::BytesMut; +use futures::StreamExt; +use nym_authenticator_client::AuthenticatorClient; +use nym_authenticator_requests::{ + AuthenticatorVersion, client_message::ClientMessage, response::AuthenticatorResponse, v2, v3, + v4, v5, v6, +}; +use nym_config::defaults::mixnet_vpn::{NYM_TUN_DEVICE_ADDRESS_V4, NYM_TUN_DEVICE_ADDRESS_V6}; +use nym_connection_monitor::self_ping_and_wait; +use nym_credentials_interface::{CredentialSpendingData, TicketType}; +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::mixnet::{MixnetClient, NodeIdentity, Recipient}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::Duration, +}; +use tokio::net::TcpStream; +use tokio_util::{codec::Decoder, sync::CancellationToken}; +use tracing::*; + +pub async fn wg_probe( + mut auth_client: AuthenticatorClient, + gateway_ip: IpAddr, + auth_version: AuthenticatorVersion, + awg_args: String, + netstack_args: NetstackArgs, + // TODO: update type + credential: CredentialSpendingData, +) -> anyhow::Result { + info!("attempting to use authenticator version {auth_version:?}"); + + let mut rng = rand::thread_rng(); + + // that's a long conversion chain + // (it should be simplified later...) + // nym x25519 -> dalek x25519 -> wireguard wrapper x25519 + let private_key = nym_crypto::asymmetric::encryption::PrivateKey::new(&mut rng); + let public_key = private_key.public_key(); + + let authenticator_pub_key = public_key.inner().into(); + let init_message = match auth_version { + AuthenticatorVersion::V2 => ClientMessage::Initial(Box::new( + v2::registration::InitMessage::new(authenticator_pub_key), + )), + AuthenticatorVersion::V3 => ClientMessage::Initial(Box::new( + v3::registration::InitMessage::new(authenticator_pub_key), + )), + AuthenticatorVersion::V4 => ClientMessage::Initial(Box::new( + v4::registration::InitMessage::new(authenticator_pub_key), + )), + AuthenticatorVersion::V5 => ClientMessage::Initial(Box::new( + v5::registration::InitMessage::new(authenticator_pub_key), + )), + AuthenticatorVersion::V6 => ClientMessage::Initial(Box::new( + v6::registration::InitMessage::new(authenticator_pub_key), + )), + AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => bail!("unknown version number"), + }; + + let mut wg_outcome = WgProbeResults::default(); + + info!( + "connecting to authenticator: {}...", + auth_client.auth_recipient + ); + let response = auth_client + .send_and_wait_for_response(&init_message) + .await?; + + let registered_data = match response { + AuthenticatorResponse::PendingRegistration(pending_registration_response) => { + // Unwrap since we have already checked that we have the keypair. + debug!("Verifying data"); + pending_registration_response.verify(&private_key)?; + + let credential = credential + .try_into() + .inspect_err(|err| error!("invalid zk-nym data: {err}")) + .ok(); + + let finalized_message = + pending_registration_response.finalise_registration(&private_key, credential); + let client_message = ClientMessage::Final(finalized_message); + + let response = auth_client + .send_and_wait_for_response(&client_message) + .await?; + let AuthenticatorResponse::Registered(registered_response) = response else { + bail!("Unexpected response"); + }; + registered_response + } + AuthenticatorResponse::Registered(registered_response) => registered_response, + _ => bail!("Unexpected response"), + }; + + 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 public_key_hex = hex::encode(peer_public.as_bytes()); + + info!("WG connection details"); + info!("Peer public key: {}", public_key_bs64); + info!( + "ips {}(v4) {}(v6), port {}", + registered_data.private_ips().ipv4, + registered_data.private_ips().ipv6, + registered_data.wg_port(), + ); + + let wg_endpoint = format!("{gateway_ip}:{}", registered_data.wg_port()); + + info!("Successfully registered with the gateway"); + + wg_outcome.can_register = true; + + // Run tunnel connectivity tests using shared helper + let tunnel_config = WgTunnelConfig::new( + registered_data.private_ips().ipv4.to_string(), + registered_data.private_ips().ipv6.to_string(), + private_key_hex, + public_key_hex, + wg_endpoint, + ); + + run_tunnel_tests(&tunnel_config, &netstack_args, &awg_args, &mut wg_outcome); + + Ok(wg_outcome) +} + +pub async fn lp_registration_probe( + gateway_identity: NodeIdentity, + gateway_lp_data: TestedNodeLpDetails, + bandwidth_controller: &nym_bandwidth_controller::BandwidthController< + nym_validator_client::nyxd::NyxdClient, + St, + >, + use_mock_ecash: bool, +) -> anyhow::Result +where + St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, + ::StorageError: Send + Sync, +{ + use nym_crypto::asymmetric::ed25519; + use nym_registration_client::LpRegistrationClient; + + let lp_address = gateway_lp_data.address; + let peer = helpers::to_lp_remote_peer(gateway_identity, gateway_lp_data); + + info!("Starting LP registration probe for gateway at {lp_address}",); + + let mut lp_outcome = LpProbeResults::default(); + + // Generate Ed25519 keypair for this connection (X25519 will be derived internally by LP) + let mut rng = rand::thread_rng(); + let client_ed25519_keypair = std::sync::Arc::new(ed25519::KeyPair::new(&mut rng)); + + // Step 0: Derive X25519 keys from Ed25519 for the gateways + + // Create LP registration client (uses Ed25519 keys directly, derives X25519 internally) + let mut client = LpRegistrationClient::::new_with_default_config( + client_ed25519_keypair, + peer, + lp_address, + ); + + // Step 1: Perform handshake (connection is implicit in packet-per-connection model) + // LpRegistrationClient uses packet-per-connection model - connect() is gone, + // connection is established during handshake and registration automatically. + info!("Performing LP handshake at {lp_address}..."); + match client.perform_handshake().await { + Ok(_) => { + info!("LP handshake completed successfully"); + lp_outcome.can_connect = true; // Connection succeeded if handshake succeeded + lp_outcome.can_handshake = true; + } + Err(e) => { + let error_msg = format!("LP handshake failed: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + + // Step 2: Register with gateway (send request + receive response in one call) + info!("Sending LP registration request..."); + + // Generate WireGuard keypair for dVPN registration + let mut rng = rand::thread_rng(); + let wg_keypair = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng); + + // Convert gateway identity to ed25519 public key + let gateway_ed25519_pubkey = match nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &gateway_identity.to_bytes(), + ) { + Ok(key) => key, + Err(e) => { + let error_msg = format!("Failed to convert gateway identity: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + }; + + // Register using the new packet-per-connection API (returns GatewayData directly) + let ticket_type = TicketType::V1WireguardEntry; + let gateway_data = if use_mock_ecash { + info!("Using mock ecash credential for LP registration"); + let credential = bandwidth_helpers::create_dummy_credential( + &gateway_ed25519_pubkey.to_bytes(), + ticket_type, + ); + + match client + .register_with_credential(&mut rng, &wg_keypair, credential, ticket_type) + .await + { + Ok(data) => data, + Err(e) => { + let error_msg = format!("LP registration failed (mock ecash): {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + } else { + info!("Using real bandwidth controller for LP registration"); + match client + .register( + &mut rng, + &wg_keypair, + &gateway_ed25519_pubkey, + bandwidth_controller, + ticket_type, + ) + .await + { + Ok(data) => data, + Err(e) => { + let error_msg = format!("LP registration failed: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + }; + + info!("LP registration successful! Received gateway data:"); + info!(" - Gateway public key: {:?}", gateway_data.public_key); + info!(" - Private IPv4: {}", gateway_data.private_ipv4); + info!(" - Private IPv6: {}", gateway_data.private_ipv6); + info!(" - Endpoint: {}", gateway_data.endpoint); + lp_outcome.can_register = true; + + Ok(lp_outcome) +} + +/// LP-based WireGuard probe: Tests LP nested session registration + WireGuard tunnel connectivity +/// +/// This function tests the full VPN flow using LP registration instead of mixnet+authenticator: +/// 1. Connects to entry gateway (outer LP session) +/// 2. Registers with exit gateway via entry forwarding (nested LP session) +/// 3. Receives WireGuard configuration from both gateways +/// 4. Tests WireGuard tunnel connectivity (IPv4/IPv6) +/// +/// This validates that IP hiding works (exit sees entry IP, not client IP) and that the +/// full VPN tunnel operates correctly after LP registration. +/// +// Known issue in localnet mode - After this probe runs, container networking +// to the external internet becomes unstable while internal container-to-container traffic +// continues to work. The two-hop WireGuard tunnel itself succeeds (handshake completes), +// but subsequent DNS/ping tests may timeout. This appears to be related to Apple Container +// Runtime networking quirks combined with our NAT/iptables configuration. Tracked in +// beads issue nym-vbdo. Workaround: restart the localnet containers between probe runs. +pub async fn wg_probe_lp( + entry_gateway: &TestedNodeDetails, + exit_gateway: &TestedNodeDetails, + bandwidth_controller: &nym_bandwidth_controller::BandwidthController< + nym_validator_client::nyxd::NyxdClient, + St, + >, + use_mock_ecash: bool, + awg_args: String, + netstack_args: NetstackArgs, +) -> anyhow::Result +where + St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, + ::StorageError: Send + Sync, +{ + // Validate that both gateways have required information + let entry_lp_data = entry_gateway + .lp_data + .clone() + .ok_or_else(|| anyhow::anyhow!("Entry gateway missing LP data"))?; + + let exit_lp_data = exit_gateway + .lp_data + .clone() + .ok_or_else(|| anyhow::anyhow!("Exit gateway missing LP data"))?; + + let entry_address = entry_lp_data.address; + let exit_address = exit_lp_data.address; + + let entry_ip = entry_address.ip(); + let exit_ip = exit_address.ip(); + + info!("Starting LP-based WireGuard probe (entry→exit via forwarding)"); + + let mut wg_outcome = WgProbeResults::default(); + + // Generate Ed25519 keypairs for LP protocol + let mut rng = rand::thread_rng(); + let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + + // Generate WireGuard keypairs for VPN registration + let entry_wg_keypair = x25519::KeyPair::new(&mut rng); + let exit_wg_keypair = x25519::KeyPair::new(&mut rng); + + let entry_peer = helpers::to_lp_remote_peer(entry_gateway.identity, entry_lp_data); + let exit_peer = helpers::to_lp_remote_peer(exit_gateway.identity, exit_lp_data); + + // STEP 1: Establish outer LP session with entry gateway + // LpRegistrationClient uses packet-per-connection model - connect() is gone, + // connection is established automatically during handshake. + info!("Establishing outer LP session with entry gateway..."); + let mut entry_client = LpRegistrationClient::::new_with_default_config( + entry_lp_keypair, + entry_peer, + entry_address, + ); + + // Perform handshake with entry gateway (connection is implicit) + if let Err(e) = entry_client.perform_handshake().await { + error!("Failed to handshake with entry gateway: {}", e); + return Ok(wg_outcome); + } + info!("Outer LP session with entry gateway established"); + + // STEP 2: Use nested session to register with exit gateway via forwarding + info!("Registering with exit gateway via entry forwarding..."); + let mut nested_session = + NestedLpSession::new(exit_address.to_string(), exit_lp_keypair, exit_peer); + + // 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))?; + + // Perform handshake and registration with exit gateway via forwarding + let exit_gateway_data = if use_mock_ecash { + info!("Using mock ecash credential for exit gateway registration"); + let credential = bandwidth_helpers::create_dummy_credential( + &exit_gateway_pubkey.to_bytes(), + TicketType::V1WireguardExit, + ); + match nested_session + .handshake_and_register_with_credential( + &mut entry_client, + &mut rng, + &exit_wg_keypair, + credential, + TicketType::V1WireguardExit, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with exit gateway (mock ecash): {}", e); + return Ok(wg_outcome); + } + } + } else { + match nested_session + .handshake_and_register( + &mut entry_client, + &mut rng, + &exit_wg_keypair, + &exit_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardExit, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with exit gateway: {}", e); + return Ok(wg_outcome); + } + } + }; + info!("Exit gateway registration successful via forwarding"); + + // STEP 3: Register with entry gateway + info!("Registering with entry gateway..."); + let entry_gateway_pubkey = + ed25519::PublicKey::from_bytes(&entry_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid entry gateway identity: {}", e))?; + + // Use packet-per-connection register() which returns GatewayData directly + let entry_gateway_data = if use_mock_ecash { + info!("Using mock ecash credential for entry gateway registration"); + let credential = bandwidth_helpers::create_dummy_credential( + &entry_gateway_pubkey.to_bytes(), + TicketType::V1WireguardEntry, + ); + match entry_client + .register_with_credential( + &mut rng, + &entry_wg_keypair, + credential, + TicketType::V1WireguardEntry, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with entry gateway (mock ecash): {}", e); + return Ok(wg_outcome); + } + } + } else { + match entry_client + .register( + &mut rng, + &entry_wg_keypair, + &entry_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardEntry, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with entry gateway: {}", e); + return Ok(wg_outcome); + } + } + }; + info!("Entry gateway registration successful"); + + info!("LP registration successful for both gateways!"); + wg_outcome.can_register = true; + + // STEP 4: Test WireGuard tunnels using two-hop configuration + // Traffic flows: Exit tunnel -> UDP Forwarder -> Entry tunnel -> Exit Gateway -> Internet + // The exit gateway endpoint is not directly reachable from the host in localnet. + // We must tunnel through the entry gateway using the UDP forwarder pattern. + + // Convert keys to hex for netstack + let entry_private_key_hex = hex::encode(entry_wg_keypair.private_key().to_bytes()); + let entry_public_key_hex = hex::encode(entry_gateway_data.public_key.to_bytes()); + let exit_private_key_hex = hex::encode(exit_wg_keypair.private_key().to_bytes()); + let exit_public_key_hex = hex::encode(exit_gateway_data.public_key.to_bytes()); + + // Build WireGuard endpoint addresses + // Entry endpoint uses entry_ip (host-reachable) + port from registration + let entry_wg_endpoint = format!("{}:{}", entry_ip, entry_gateway_data.endpoint.port()); + // Exit endpoint uses exit_ip + port from registration (forwarded via entry) + let exit_wg_endpoint = format!("{}:{}", exit_ip, exit_gateway_data.endpoint.port()); + + info!("Two-hop WireGuard configuration:"); + info!(" Entry gateway:"); + info!(" Private IPv4: {}", entry_gateway_data.private_ipv4); + info!(" Endpoint: {}", entry_wg_endpoint); + info!(" Exit gateway:"); + info!(" Private IPv4: {}", exit_gateway_data.private_ipv4); + info!(" Endpoint (via forwarder): {}", exit_wg_endpoint); + + // Build two-hop tunnel configuration + let two_hop_config = TwoHopWgTunnelConfig::new( + entry_gateway_data.private_ipv4.to_string(), + entry_private_key_hex, + entry_public_key_hex, + entry_wg_endpoint, + awg_args.clone(), // 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 + ); + + // Run two-hop tunnel connectivity tests + run_two_hop_tunnel_tests(&two_hop_config, &netstack_args, &mut wg_outcome); + + info!("LP-based two-hop WireGuard probe completed"); + Ok(wg_outcome) +} + +pub async fn do_ping( + mut mixnet_client: MixnetClient, + our_address: Recipient, + exit_router_address: Option, + tested_entry: bool, +) -> (anyhow::Result, MixnetClient) { + let entry = do_ping_entry(&mut mixnet_client, our_address, tested_entry).await; + + let (exit_result, mixnet_client) = if let Some(exit_router_address) = exit_router_address { + let (maybe_ip_pair, mut mixnet_client) = + connect_exit(mixnet_client, exit_router_address).await; + match maybe_ip_pair { + Some(ip_pair) => ( + do_ping_exit(&mut mixnet_client, ip_pair, exit_router_address).await, + mixnet_client, + ), + None => (Ok(Some(Exit::fail_to_connect())), mixnet_client), + } + } else { + (Ok(None), mixnet_client) + }; + + ( + exit_result.map(|exit| ProbeOutcome { + as_entry: entry, + as_exit: exit, + wg: None, + lp: None, + }), + mixnet_client, + ) +} + +async fn do_ping_entry( + mixnet_client: &mut MixnetClient, + our_address: Recipient, + tested_entry: bool, +) -> Entry { + // Step 1: confirm that the entry gateway is routing our mixnet traffic + info!("Sending mixnet ping to ourselves to verify mixnet connection"); + + if self_ping_and_wait(our_address, mixnet_client) + .await + .is_err() + { + return if tested_entry { + Entry::fail_to_connect() + } else { + Entry::EntryFailure + }; + } + info!("Successfully mixnet pinged ourselves"); + + Entry::success() +} + +async fn connect_exit( + mixnet_client: MixnetClient, + exit_router_address: Recipient, +) -> (Option, MixnetClient) { + // Step 2: connect to the exit gateway + info!( + "Connecting to exit gateway: {}", + exit_router_address.gateway().to_base58_string() + ); + // The IPR supports cancellation, but it's unused in the gateway probe + let cancel_token = CancellationToken::new(); + let mut ipr_client = IprClientConnect::new(mixnet_client, cancel_token); + + let maybe_ip_pair = ipr_client.connect(exit_router_address).await; + let mixnet_client = ipr_client.into_mixnet_client(); + + if let Ok(our_ips) = maybe_ip_pair { + info!("Successfully connected to exit gateway"); + info!("Using mixnet VPN IP addresses: {our_ips}"); + (Some(our_ips), mixnet_client) + } else { + (None, mixnet_client) + } +} + +pub async fn do_ping_exit( + mixnet_client: &mut MixnetClient, + our_ips: IpPair, + exit_router_address: Recipient, +) -> anyhow::Result> { + // Step 3: perform ICMP connectivity checks for the exit gateway + send_icmp_pings(mixnet_client, our_ips, exit_router_address).await?; + listen_for_icmp_ping_replies(mixnet_client, our_ips).await +} + +async fn send_icmp_pings( + mixnet_client: &MixnetClient, + our_ips: IpPair, + exit_router_address: Recipient, +) -> anyhow::Result<()> { + // ipv4 addresses for testing + let ipr_tun_ip_v4 = NYM_TUN_DEVICE_ADDRESS_V4; + let external_ip_v4 = Ipv4Addr::new(8, 8, 8, 8); + + // ipv6 addresses for testing + let ipr_tun_ip_v6 = NYM_TUN_DEVICE_ADDRESS_V6; + let external_ip_v6 = Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888); + + info!( + "Sending ICMP echo requests to: {ipr_tun_ip_v4}, {ipr_tun_ip_v6}, {external_ip_v4}, {external_ip_v6}" + ); + + // send ipv4 pings + for ii in 0..10 { + icmp::send_ping_v4( + mixnet_client, + our_ips, + ii, + ipr_tun_ip_v4, + exit_router_address, + ) + .await?; + icmp::send_ping_v4( + mixnet_client, + our_ips, + ii, + external_ip_v4, + exit_router_address, + ) + .await?; + } + + // send ipv6 pings + for ii in 0..10 { + icmp::send_ping_v6( + mixnet_client, + our_ips, + ii, + ipr_tun_ip_v6, + exit_router_address, + ) + .await?; + icmp::send_ping_v6( + mixnet_client, + our_ips, + ii, + external_ip_v6, + exit_router_address, + ) + .await?; + } + Ok(()) +} + +pub async fn listen_for_icmp_ping_replies( + mixnet_client: &mut MixnetClient, + our_ips: IpPair, +) -> anyhow::Result> { + let mut multi_ip_packet_decoder = MultiIpPacketCodec::new(); + let mut registered_replies = IpPingReplies::new(); + + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(2)) => { + info!("Finished waiting for ICMP echo reply from exit gateway"); + break; + } + Some(reconstructed_message) = mixnet_client.next() => { + let Some(data_response) = helpers::unpack_data_response(&reconstructed_message) else { + continue; + }; + + // IP packets are bundled together in a mixnet message + let mut bytes = BytesMut::from(&*data_response.ip_packet); + while let Ok(Some(packet)) = multi_ip_packet_decoder.decode(&mut bytes) { + if let Some(event) = icmp::check_for_icmp_beacon_reply(&packet.into_bytes(), icmp::icmp_identifier(), our_ips) { + info!("Received ICMP echo reply from exit gateway"); + info!("Connection event: {event:?}"); + registered_replies.register_event(&event); + } + } + } + } + } + + Ok(Some(Exit { + can_connect: true, + can_route_ip_v4: registered_replies.ipr_tun_ip_v4, + can_route_ip_external_v4: registered_replies.external_ip_v4, + can_route_ip_v6: registered_replies.ipr_tun_ip_v6, + can_route_ip_external_v6: registered_replies.external_ip_v6, + })) +} diff --git a/nym-gateway-probe/src/types.rs b/nym-gateway-probe/src/common/types.rs similarity index 100% rename from nym-gateway-probe/src/types.rs rename to nym-gateway-probe/src/common/types.rs diff --git a/nym-gateway-probe/src/common/wireguard.rs b/nym-gateway-probe/src/common/wireguard.rs index 3d58d68a53..2a9e2ba5b4 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::netstack::{ +use crate::common::netstack::{ NetstackRequest, NetstackRequestGo, NetstackResult, TwoHopNetstackRequestGo, }; -use crate::types::WgProbeResults; +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 { @@ -99,7 +99,7 @@ pub fn run_tunnel_tests( info!("Testing IPv4 tunnel connectivity..."); let ipv4_request = NetstackRequestGo::from_rust_v4(&netstack_request); - match crate::netstack::ping(&ipv4_request) { + match crate::common::netstack::ping(&ipv4_request) { Ok(NetstackResult::Response(netstack_response_v4)) => { info!( "WireGuard probe response for IPv4: {:#?}", @@ -137,7 +137,7 @@ pub fn run_tunnel_tests( info!("Testing IPv6 tunnel connectivity..."); let ipv6_request = NetstackRequestGo::from_rust_v6(&netstack_request); - match crate::netstack::ping(&ipv6_request) { + match crate::common::netstack::ping(&ipv6_request) { Ok(NetstackResult::Response(netstack_response_v6)) => { info!( "WireGuard probe response for IPv6: {:#?}", @@ -281,7 +281,7 @@ pub fn run_two_hop_tunnel_tests( info!(" Entry endpoint: {}", config.entry_endpoint); info!(" Exit endpoint (via forwarder): {}", config.exit_endpoint); - match crate::netstack::ping_two_hop(&request) { + match crate::common::netstack::ping_two_hop(&request) { Ok(NetstackResult::Response(response)) => { info!("Two-hop WireGuard probe response (IPv4): {:#?}", response); wg_outcome.can_handshake_v4 = response.can_handshake; diff --git a/nym-gateway-probe/src/config/credentials.rs b/nym-gateway-probe/src/config/credentials.rs new file mode 100644 index 0000000000..50cf308b4b --- /dev/null +++ b/nym-gateway-probe/src/config/credentials.rs @@ -0,0 +1,29 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use clap::Args; +use nym_node_status_client::models::AttachedTicketMaterials; + +#[derive(Args)] +pub struct CredentialArgs { + #[arg(long)] + ticket_materials: Option, + + #[arg(long, default_value_t = 1)] + 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, + self.ticket_materials_revision, + )?) + } +} diff --git a/nym-gateway-probe/src/config/mod.rs b/nym-gateway-probe/src/config/mod.rs new file mode 100644 index 0000000000..3601045253 --- /dev/null +++ b/nym-gateway-probe/src/config/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +mod credentials; +mod netstack; +mod test_mode; + +pub use credentials::CredentialArgs; +pub use netstack::NetstackArgs; +pub use test_mode::TestMode; diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs new file mode 100644 index 0000000000..de6dda47d7 --- /dev/null +++ b/nym-gateway-probe/src/config/netstack.rs @@ -0,0 +1,209 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use clap::Args; + +#[derive(Args, Clone)] +pub struct NetstackArgs { + #[arg(long, default_value_t = 180)] + pub netstack_download_timeout_sec: u64, + + #[arg(long, default_value_t = 30)] + pub metadata_timeout_sec: u64, + + #[arg(long, default_value = "1.1.1.1")] + pub netstack_v4_dns: String, + + #[arg(long, default_value = "2606:4700:4700::1111")] + pub netstack_v6_dns: String, + + #[arg(long, default_value_t = 5)] + pub netstack_num_ping: u8, + + #[arg(long, default_value_t = 3)] + pub netstack_send_timeout_sec: u64, + + #[arg(long, default_value_t = 3)] + pub netstack_recv_timeout_sec: u64, + + #[arg(long, 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()])] + pub netstack_ping_ips_v4: Vec, + + #[arg(long, 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()])] + pub netstack_ping_ips_v6: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_netstack_args_default_values() { + // Test that the default values are correctly set in the struct definition + // This validates that our changes to the default values are correct + + // Create a default instance to test the values + let args = NetstackArgs { + netstack_download_timeout_sec: 180, + metadata_timeout_sec: 30, + netstack_v4_dns: "1.1.1.1".to_string(), + netstack_v6_dns: "2606:4700:4700::1111".to_string(), + netstack_num_ping: 5, + netstack_send_timeout_sec: 3, + netstack_recv_timeout_sec: 3, + netstack_ping_hosts_v4: vec!["nym.com".to_string()], + netstack_ping_ips_v4: vec!["1.1.1.1".to_string()], + netstack_ping_hosts_v6: vec!["cloudflare.com".to_string()], + netstack_ping_ips_v6: vec![ + "2001:4860:4860::8888".to_string(), + "2606:4700:4700::1111".to_string(), + "2620:fe::fe".to_string(), + ], + }; + + // Test IPv4 defaults + assert_eq!(args.netstack_ping_hosts_v4, vec!["nym.com"]); + assert_eq!(args.netstack_ping_ips_v4, vec!["1.1.1.1"]); + assert_eq!(args.netstack_v4_dns, "1.1.1.1"); + + // Test IPv6 defaults + assert_eq!(args.netstack_ping_hosts_v6, vec!["cloudflare.com"]); + assert_eq!( + args.netstack_ping_ips_v6, + vec![ + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "2620:fe::fe" + ] + ); + assert_eq!(args.netstack_v6_dns, "2606:4700:4700::1111"); + + // Test other defaults + assert_eq!(args.netstack_download_timeout_sec, 180); + assert_eq!(args.netstack_num_ping, 5); + assert_eq!(args.netstack_send_timeout_sec, 3); + assert_eq!(args.netstack_recv_timeout_sec, 3); + } + + #[test] + fn test_netstack_args_custom_construction() { + // Test that we can create instances with custom values + let args = NetstackArgs { + netstack_download_timeout_sec: 300, + metadata_timeout_sec: 30, + netstack_v4_dns: "8.8.8.8".to_string(), + netstack_v6_dns: "2001:4860:4860::8888".to_string(), + netstack_num_ping: 10, + netstack_send_timeout_sec: 5, + netstack_recv_timeout_sec: 5, + netstack_ping_hosts_v4: vec!["example.com".to_string()], + netstack_ping_ips_v4: vec!["8.8.8.8".to_string()], + netstack_ping_hosts_v6: vec!["ipv6.example.com".to_string()], + netstack_ping_ips_v6: vec!["2001:4860:4860::8888".to_string()], + }; + + assert_eq!(args.netstack_ping_hosts_v4, vec!["example.com"]); + assert_eq!(args.netstack_ping_hosts_v6, vec!["ipv6.example.com"]); + assert_eq!(args.netstack_ping_ips_v4, vec!["8.8.8.8"]); + assert_eq!(args.netstack_ping_ips_v6, vec!["2001:4860:4860::8888"]); + assert_eq!(args.netstack_v4_dns, "8.8.8.8"); + assert_eq!(args.netstack_v6_dns, "2001:4860:4860::8888"); + assert_eq!(args.netstack_download_timeout_sec, 300); + assert_eq!(args.netstack_num_ping, 10); + assert_eq!(args.netstack_send_timeout_sec, 5); + assert_eq!(args.netstack_recv_timeout_sec, 5); + } + + #[test] + fn test_netstack_args_multiple_values() { + // Test that multiple hosts and IPs can be stored + let args = NetstackArgs { + netstack_download_timeout_sec: 180, + metadata_timeout_sec: 30, + netstack_v4_dns: "1.1.1.1".to_string(), + netstack_v6_dns: "2606:4700:4700::1111".to_string(), + netstack_num_ping: 5, + netstack_send_timeout_sec: 3, + netstack_recv_timeout_sec: 3, + netstack_ping_hosts_v4: vec!["nym.com".to_string(), "example.com".to_string()], + netstack_ping_ips_v4: vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()], + netstack_ping_hosts_v6: vec![ + "cloudflare.com".to_string(), + "ipv6.example.com".to_string(), + ], + netstack_ping_ips_v6: vec![ + "2001:4860:4860::8888".to_string(), + "2606:4700:4700::1111".to_string(), + ], + }; + + assert_eq!(args.netstack_ping_hosts_v4, vec!["nym.com", "example.com"]); + assert_eq!( + args.netstack_ping_hosts_v6, + vec!["cloudflare.com", "ipv6.example.com"] + ); + assert_eq!(args.netstack_ping_ips_v4, vec!["1.1.1.1", "8.8.8.8"]); + assert_eq!( + args.netstack_ping_ips_v6, + vec!["2001:4860:4860::8888", "2606:4700:4700::1111"] + ); + } + + #[test] + fn test_netstack_args_edge_cases() { + // Test edge cases like zero values and empty vectors + let args = NetstackArgs { + netstack_download_timeout_sec: 0, + metadata_timeout_sec: 30, + netstack_v4_dns: "1.1.1.1".to_string(), + netstack_v6_dns: "2606:4700:4700::1111".to_string(), + netstack_num_ping: 0, + netstack_send_timeout_sec: 0, + netstack_recv_timeout_sec: 0, + netstack_ping_hosts_v4: vec![], + netstack_ping_ips_v4: vec![], + netstack_ping_hosts_v6: vec![], + netstack_ping_ips_v6: vec![], + }; + + assert_eq!(args.netstack_num_ping, 0); + assert_eq!(args.netstack_send_timeout_sec, 0); + assert_eq!(args.netstack_recv_timeout_sec, 0); + assert_eq!(args.netstack_download_timeout_sec, 0); + assert!(args.netstack_ping_hosts_v4.is_empty()); + assert!(args.netstack_ping_ips_v4.is_empty()); + assert!(args.netstack_ping_hosts_v6.is_empty()); + assert!(args.netstack_ping_ips_v6.is_empty()); + } + + #[test] + fn test_netstack_args_domain_validation() { + // Test that our domain choices are reasonable + let args = NetstackArgs { + netstack_download_timeout_sec: 180, + metadata_timeout_sec: 30, + netstack_v4_dns: "1.1.1.1".to_string(), + netstack_v6_dns: "2606:4700:4700::1111".to_string(), + netstack_num_ping: 5, + netstack_send_timeout_sec: 3, + netstack_recv_timeout_sec: 3, + netstack_ping_hosts_v4: vec!["nym.com".to_string()], + netstack_ping_ips_v4: vec!["1.1.1.1".to_string()], + netstack_ping_hosts_v6: vec!["cloudflare.com".to_string()], + netstack_ping_ips_v6: vec!["2001:4860:4860::8888".to_string()], + }; + + assert!(args.netstack_ping_hosts_v4[0].contains("nym")); + + assert!(args.netstack_ping_hosts_v6[0].contains("cloudflare")); + + assert_eq!(args.netstack_v4_dns, "1.1.1.1"); + assert_eq!(args.netstack_v6_dns, "2606:4700:4700::1111"); + } +} diff --git a/nym-gateway-probe/src/mode/mod.rs b/nym-gateway-probe/src/config/test_mode.rs similarity index 100% rename from nym-gateway-probe/src/mode/mod.rs rename to nym-gateway-probe/src/config/test_mode.rs diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 925c9c030c..b74cf2df7d 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -1,200 +1,36 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::types::Entry; +use crate::common::helpers; +use crate::common::probe_tests::{do_ping, lp_registration_probe, wg_probe, wg_probe_lp}; +use crate::common::types::{Entry, Exit, WgProbeResults}; use anyhow::bail; -use base64::{Engine as _, engine::general_purpose}; -use bytes::BytesMut; -use clap::Args; -use futures::StreamExt; use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; -use nym_authenticator_requests::{ - AuthenticatorVersion, client_message::ClientMessage, response::AuthenticatorResponse, v2, v3, - v4, v5, v6, -}; use nym_client_core::config::ForgetMe; -use nym_config::defaults::{ - NymNetworkDetails, - mixnet_vpn::{NYM_TUN_DEVICE_ADDRESS_V4, NYM_TUN_DEVICE_ADDRESS_V6}, -}; -use nym_connection_monitor::self_ping_and_wait; -use nym_credentials_interface::{CredentialSpendingData, TicketType}; +use nym_config::defaults::NymNetworkDetails; +use nym_credentials_interface::TicketType; use nym_crypto::asymmetric::x25519::KeyPair; -use nym_ip_packet_client::IprClientConnect; -use nym_ip_packet_requests::{ - IpPair, - codec::MultiIpPacketCodec, - v8::response::{ - ControlResponse, DataResponse, InfoLevel, IpPacketResponse, IpPacketResponseData, - }, -}; use nym_sdk::mixnet::{ CredentialStorage, Ephemeral, KeyStore, MixnetClient, MixnetClientBuilder, MixnetClientStorage, - NodeIdentity, Recipient, ReconstructedMessage, StoragePaths, + NodeIdentity, StoragePaths, }; use rand::rngs::OsRng; -use std::collections::HashMap; -use std::net::SocketAddr; use std::path::PathBuf; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::Arc, - time::Duration, -}; -use tokio::net::TcpStream; -use tokio_util::{codec::Decoder, sync::CancellationToken}; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; use tracing::*; -use types::WgProbeResults; use url::Url; -use crate::{ - icmp::{check_for_icmp_beacon_reply, icmp_identifier, send_ping_v4, send_ping_v6}, - types::Exit, -}; - -mod bandwidth_helpers; mod common; -mod icmp; -pub mod mode; -mod netstack; -pub mod nodes; -mod types; +pub mod config; -use crate::bandwidth_helpers::{acquire_bandwidth, import_bandwidth}; -use crate::nodes::{DirectoryNode, NymApiDirectory}; -pub use mode::TestMode; -use nym_crypto::asymmetric::{ed25519, x25519}; -use nym_kkt_ciphersuite::{KEM, KEMKeyDigests, SignatureScheme}; -use nym_lp::peer::LpRemotePeer; -use nym_node_status_client::models::AttachedTicketMaterials; -use nym_registration_client::{LpRegistrationClient, NestedLpSession}; -pub use types::{IpPingReplies, ProbeOutcome, ProbeResult}; - -#[derive(Args, Clone)] -pub struct NetstackArgs { - #[arg(long, default_value_t = 180)] - netstack_download_timeout_sec: u64, - - #[arg(long, default_value_t = 30)] - metadata_timeout_sec: u64, - - #[arg(long, default_value = "1.1.1.1")] - netstack_v4_dns: String, - - #[arg(long, default_value = "2606:4700:4700::1111")] - netstack_v6_dns: String, - - #[arg(long, default_value_t = 5)] - netstack_num_ping: u8, - - #[arg(long, default_value_t = 3)] - netstack_send_timeout_sec: u64, - - #[arg(long, default_value_t = 3)] - netstack_recv_timeout_sec: u64, - - #[arg(long, default_values_t = vec!["nym.com".to_string()])] - netstack_ping_hosts_v4: Vec, - - #[arg(long, default_values_t = vec!["1.1.1.1".to_string()])] - netstack_ping_ips_v4: Vec, - - #[arg(long, default_values_t = vec!["cloudflare.com".to_string()])] - 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()])] - netstack_ping_ips_v6: Vec, -} - -#[derive(Args)] -pub struct CredentialArgs { - #[arg(long)] - ticket_materials: Option, - - #[arg(long, default_value_t = 1)] - ticket_materials_revision: u8, -} - -impl CredentialArgs { - 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, - self.ticket_materials_revision, - )?) - } -} - -#[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 { - identity: NodeIdentity, - exit_router_address: Option, - authenticator_address: Option, - authenticator_version: AuthenticatorVersion, - ip_address: Option, - lp_data: Option, -} - -#[derive(Debug, Clone)] -pub struct TestedNodeLpDetails { - pub address: SocketAddr, - pub expected_kem_key_hashes: HashMap, - pub expected_signing_key_hashes: HashMap, - pub x25519: x25519::PublicKey, -} - -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), - // 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() - } -} +use crate::common::bandwidth_helpers::{acquire_bandwidth, 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, @@ -324,7 +160,7 @@ impl Probe { let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone()) .request_gateway(mixnet_entry_gateway_id.to_string()) .network_details(NymNetworkDetails::new_from_env()) - .debug_config(mixnet_debug_config( + .debug_config(helpers::mixnet_debug_config( min_mixnet_performance, ignore_egress_epoch_role, )) @@ -491,7 +327,7 @@ impl Probe { let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone()) .request_gateway(mixnet_entry_gateway_id.to_string()) .network_details(NymNetworkDetails::new_from_env()) - .debug_config(mixnet_debug_config( + .debug_config(helpers::mixnet_debug_config( min_mixnet_performance, ignore_egress_epoch_role, )) @@ -614,12 +450,12 @@ impl Probe { Ok(ProbeResult { node: node_info.identity.to_string(), used_entry: mixnet_entry_gateway_id.to_string(), - outcome: types::ProbeOutcome { - as_entry: types::Entry::NotTested, + outcome: ProbeOutcome { + as_entry: Entry::NotTested, as_exit: if tested_entry { None } else { - Some(types::Exit::fail_to_connect()) + Some(Exit::fail_to_connect()) }, wg: None, lp: Some(lp_outcome), @@ -965,885 +801,3 @@ impl Probe { }) } } - -async fn wg_probe( - mut auth_client: AuthenticatorClient, - gateway_ip: IpAddr, - auth_version: AuthenticatorVersion, - awg_args: String, - netstack_args: NetstackArgs, - // TODO: update type - credential: CredentialSpendingData, -) -> anyhow::Result { - info!("attempting to use authenticator version {auth_version:?}"); - - let mut rng = rand::thread_rng(); - - // that's a long conversion chain - // (it should be simplified later...) - // nym x25519 -> dalek x25519 -> wireguard wrapper x25519 - let private_key = nym_crypto::asymmetric::encryption::PrivateKey::new(&mut rng); - let public_key = private_key.public_key(); - - let authenticator_pub_key = public_key.inner().into(); - let init_message = match auth_version { - AuthenticatorVersion::V2 => ClientMessage::Initial(Box::new( - v2::registration::InitMessage::new(authenticator_pub_key), - )), - AuthenticatorVersion::V3 => ClientMessage::Initial(Box::new( - v3::registration::InitMessage::new(authenticator_pub_key), - )), - AuthenticatorVersion::V4 => ClientMessage::Initial(Box::new( - v4::registration::InitMessage::new(authenticator_pub_key), - )), - AuthenticatorVersion::V5 => ClientMessage::Initial(Box::new( - v5::registration::InitMessage::new(authenticator_pub_key), - )), - AuthenticatorVersion::V6 => ClientMessage::Initial(Box::new( - v6::registration::InitMessage::new(authenticator_pub_key), - )), - AuthenticatorVersion::V1 | AuthenticatorVersion::UNKNOWN => bail!("unknown version number"), - }; - - let mut wg_outcome = WgProbeResults::default(); - - info!( - "connecting to authenticator: {}...", - auth_client.auth_recipient - ); - let response = auth_client - .send_and_wait_for_response(&init_message) - .await?; - - let registered_data = match response { - AuthenticatorResponse::PendingRegistration(pending_registration_response) => { - // Unwrap since we have already checked that we have the keypair. - debug!("Verifying data"); - pending_registration_response.verify(&private_key)?; - - let credential = credential - .try_into() - .inspect_err(|err| error!("invalid zk-nym data: {err}")) - .ok(); - - let finalized_message = - pending_registration_response.finalise_registration(&private_key, credential); - let client_message = ClientMessage::Final(finalized_message); - - let response = auth_client - .send_and_wait_for_response(&client_message) - .await?; - let AuthenticatorResponse::Registered(registered_response) = response else { - bail!("Unexpected response"); - }; - registered_response - } - AuthenticatorResponse::Registered(registered_response) => registered_response, - _ => bail!("Unexpected response"), - }; - - 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 public_key_hex = hex::encode(peer_public.as_bytes()); - - info!("WG connection details"); - info!("Peer public key: {}", public_key_bs64); - info!( - "ips {}(v4) {}(v6), port {}", - registered_data.private_ips().ipv4, - registered_data.private_ips().ipv6, - registered_data.wg_port(), - ); - - let wg_endpoint = format!("{gateway_ip}:{}", registered_data.wg_port()); - - info!("Successfully registered with the gateway"); - - wg_outcome.can_register = true; - - // Run tunnel connectivity tests using shared helper - let tunnel_config = common::WgTunnelConfig::new( - registered_data.private_ips().ipv4.to_string(), - registered_data.private_ips().ipv6.to_string(), - private_key_hex, - public_key_hex, - wg_endpoint, - ); - - common::run_tunnel_tests(&tunnel_config, &netstack_args, &awg_args, &mut wg_outcome); - - Ok(wg_outcome) -} - -fn to_lp_remote_peer(identity: ed25519::PublicKey, data: TestedNodeLpDetails) -> LpRemotePeer { - LpRemotePeer::new(identity, data.x25519).with_key_digests( - data.expected_kem_key_hashes, - data.expected_signing_key_hashes, - ) -} - -async fn lp_registration_probe( - gateway_identity: NodeIdentity, - gateway_lp_data: TestedNodeLpDetails, - bandwidth_controller: &nym_bandwidth_controller::BandwidthController< - nym_validator_client::nyxd::NyxdClient, - St, - >, - use_mock_ecash: bool, -) -> anyhow::Result -where - St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, - ::StorageError: Send + Sync, -{ - use nym_crypto::asymmetric::ed25519; - use nym_registration_client::LpRegistrationClient; - - let lp_address = gateway_lp_data.address; - let peer = to_lp_remote_peer(gateway_identity, gateway_lp_data); - - info!("Starting LP registration probe for gateway at {lp_address}",); - - let mut lp_outcome = types::LpProbeResults::default(); - - // Generate Ed25519 keypair for this connection (X25519 will be derived internally by LP) - let mut rng = rand::thread_rng(); - let client_ed25519_keypair = std::sync::Arc::new(ed25519::KeyPair::new(&mut rng)); - - // Step 0: Derive X25519 keys from Ed25519 for the gateways - - // Create LP registration client (uses Ed25519 keys directly, derives X25519 internally) - let mut client = LpRegistrationClient::::new_with_default_config( - client_ed25519_keypair, - peer, - lp_address, - ); - - // Step 1: Perform handshake (connection is implicit in packet-per-connection model) - // LpRegistrationClient uses packet-per-connection model - connect() is gone, - // connection is established during handshake and registration automatically. - info!("Performing LP handshake at {lp_address}..."); - match client.perform_handshake().await { - Ok(_) => { - info!("LP handshake completed successfully"); - lp_outcome.can_connect = true; // Connection succeeded if handshake succeeded - lp_outcome.can_handshake = true; - } - Err(e) => { - let error_msg = format!("LP handshake failed: {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - } - - // Step 2: Register with gateway (send request + receive response in one call) - info!("Sending LP registration request..."); - - // Generate WireGuard keypair for dVPN registration - let mut rng = rand::thread_rng(); - let wg_keypair = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng); - - // Convert gateway identity to ed25519 public key - let gateway_ed25519_pubkey = match nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( - &gateway_identity.to_bytes(), - ) { - Ok(key) => key, - Err(e) => { - let error_msg = format!("Failed to convert gateway identity: {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - }; - - // Register using the new packet-per-connection API (returns GatewayData directly) - let ticket_type = TicketType::V1WireguardEntry; - let gateway_data = if use_mock_ecash { - info!("Using mock ecash credential for LP registration"); - let credential = crate::bandwidth_helpers::create_dummy_credential( - &gateway_ed25519_pubkey.to_bytes(), - ticket_type, - ); - - match client - .register_with_credential(&mut rng, &wg_keypair, credential, ticket_type) - .await - { - Ok(data) => data, - Err(e) => { - let error_msg = format!("LP registration failed (mock ecash): {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - } - } else { - info!("Using real bandwidth controller for LP registration"); - match client - .register( - &mut rng, - &wg_keypair, - &gateway_ed25519_pubkey, - bandwidth_controller, - ticket_type, - ) - .await - { - Ok(data) => data, - Err(e) => { - let error_msg = format!("LP registration failed: {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - } - }; - - info!("LP registration successful! Received gateway data:"); - info!(" - Gateway public key: {:?}", gateway_data.public_key); - info!(" - Private IPv4: {}", gateway_data.private_ipv4); - info!(" - Private IPv6: {}", gateway_data.private_ipv6); - info!(" - Endpoint: {}", gateway_data.endpoint); - lp_outcome.can_register = true; - - Ok(lp_outcome) -} - -/// LP-based WireGuard probe: Tests LP nested session registration + WireGuard tunnel connectivity -/// -/// This function tests the full VPN flow using LP registration instead of mixnet+authenticator: -/// 1. Connects to entry gateway (outer LP session) -/// 2. Registers with exit gateway via entry forwarding (nested LP session) -/// 3. Receives WireGuard configuration from both gateways -/// 4. Tests WireGuard tunnel connectivity (IPv4/IPv6) -/// -/// This validates that IP hiding works (exit sees entry IP, not client IP) and that the -/// full VPN tunnel operates correctly after LP registration. -/// -// Known issue in localnet mode - After this probe runs, container networking -// to the external internet becomes unstable while internal container-to-container traffic -// continues to work. The two-hop WireGuard tunnel itself succeeds (handshake completes), -// but subsequent DNS/ping tests may timeout. This appears to be related to Apple Container -// Runtime networking quirks combined with our NAT/iptables configuration. Tracked in -// beads issue nym-vbdo. Workaround: restart the localnet containers between probe runs. -async fn wg_probe_lp( - entry_gateway: &TestedNodeDetails, - exit_gateway: &TestedNodeDetails, - bandwidth_controller: &nym_bandwidth_controller::BandwidthController< - nym_validator_client::nyxd::NyxdClient, - St, - >, - use_mock_ecash: bool, - awg_args: String, - netstack_args: NetstackArgs, -) -> anyhow::Result -where - St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, - ::StorageError: Send + Sync, -{ - // Validate that both gateways have required information - let entry_lp_data = entry_gateway - .lp_data - .clone() - .ok_or_else(|| anyhow::anyhow!("Entry gateway missing LP data"))?; - - let exit_lp_data = exit_gateway - .lp_data - .clone() - .ok_or_else(|| anyhow::anyhow!("Exit gateway missing LP data"))?; - - let entry_address = entry_lp_data.address; - let exit_address = exit_lp_data.address; - - let entry_ip = entry_address.ip(); - let exit_ip = exit_address.ip(); - - info!("Starting LP-based WireGuard probe (entry→exit via forwarding)"); - - let mut wg_outcome = WgProbeResults::default(); - - // Generate Ed25519 keypairs for LP protocol - let mut rng = rand::thread_rng(); - let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); - let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); - - // Generate WireGuard keypairs for VPN registration - let entry_wg_keypair = x25519::KeyPair::new(&mut rng); - let exit_wg_keypair = x25519::KeyPair::new(&mut rng); - - let entry_peer = to_lp_remote_peer(entry_gateway.identity, entry_lp_data); - let exit_peer = to_lp_remote_peer(exit_gateway.identity, exit_lp_data); - - // STEP 1: Establish outer LP session with entry gateway - // LpRegistrationClient uses packet-per-connection model - connect() is gone, - // connection is established automatically during handshake. - info!("Establishing outer LP session with entry gateway..."); - let mut entry_client = LpRegistrationClient::::new_with_default_config( - entry_lp_keypair, - entry_peer, - entry_address, - ); - - // Perform handshake with entry gateway (connection is implicit) - if let Err(e) = entry_client.perform_handshake().await { - error!("Failed to handshake with entry gateway: {}", e); - return Ok(wg_outcome); - } - info!("Outer LP session with entry gateway established"); - - // STEP 2: Use nested session to register with exit gateway via forwarding - info!("Registering with exit gateway via entry forwarding..."); - let mut nested_session = - NestedLpSession::new(exit_address.to_string(), exit_lp_keypair, exit_peer); - - // 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))?; - - // Perform handshake and registration with exit gateway via forwarding - let exit_gateway_data = if use_mock_ecash { - info!("Using mock ecash credential for exit gateway registration"); - let credential = crate::bandwidth_helpers::create_dummy_credential( - &exit_gateway_pubkey.to_bytes(), - TicketType::V1WireguardExit, - ); - match nested_session - .handshake_and_register_with_credential( - &mut entry_client, - &mut rng, - &exit_wg_keypair, - credential, - TicketType::V1WireguardExit, - ) - .await - { - Ok(data) => data, - Err(e) => { - error!("Failed to register with exit gateway (mock ecash): {}", e); - return Ok(wg_outcome); - } - } - } else { - match nested_session - .handshake_and_register( - &mut entry_client, - &mut rng, - &exit_wg_keypair, - &exit_gateway_pubkey, - bandwidth_controller, - TicketType::V1WireguardExit, - ) - .await - { - Ok(data) => data, - Err(e) => { - error!("Failed to register with exit gateway: {}", e); - return Ok(wg_outcome); - } - } - }; - info!("Exit gateway registration successful via forwarding"); - - // STEP 3: Register with entry gateway - info!("Registering with entry gateway..."); - let entry_gateway_pubkey = - ed25519::PublicKey::from_bytes(&entry_gateway.identity.to_bytes()) - .map_err(|e| anyhow::anyhow!("Invalid entry gateway identity: {}", e))?; - - // Use packet-per-connection register() which returns GatewayData directly - let entry_gateway_data = if use_mock_ecash { - info!("Using mock ecash credential for entry gateway registration"); - let credential = crate::bandwidth_helpers::create_dummy_credential( - &entry_gateway_pubkey.to_bytes(), - TicketType::V1WireguardEntry, - ); - match entry_client - .register_with_credential( - &mut rng, - &entry_wg_keypair, - credential, - TicketType::V1WireguardEntry, - ) - .await - { - Ok(data) => data, - Err(e) => { - error!("Failed to register with entry gateway (mock ecash): {}", e); - return Ok(wg_outcome); - } - } - } else { - match entry_client - .register( - &mut rng, - &entry_wg_keypair, - &entry_gateway_pubkey, - bandwidth_controller, - TicketType::V1WireguardEntry, - ) - .await - { - Ok(data) => data, - Err(e) => { - error!("Failed to register with entry gateway: {}", e); - return Ok(wg_outcome); - } - } - }; - info!("Entry gateway registration successful"); - - info!("LP registration successful for both gateways!"); - wg_outcome.can_register = true; - - // STEP 4: Test WireGuard tunnels using two-hop configuration - // Traffic flows: Exit tunnel -> UDP Forwarder -> Entry tunnel -> Exit Gateway -> Internet - // The exit gateway endpoint is not directly reachable from the host in localnet. - // We must tunnel through the entry gateway using the UDP forwarder pattern. - - // Convert keys to hex for netstack - let entry_private_key_hex = hex::encode(entry_wg_keypair.private_key().to_bytes()); - let entry_public_key_hex = hex::encode(entry_gateway_data.public_key.to_bytes()); - let exit_private_key_hex = hex::encode(exit_wg_keypair.private_key().to_bytes()); - let exit_public_key_hex = hex::encode(exit_gateway_data.public_key.to_bytes()); - - // Build WireGuard endpoint addresses - // Entry endpoint uses entry_ip (host-reachable) + port from registration - let entry_wg_endpoint = format!("{}:{}", entry_ip, entry_gateway_data.endpoint.port()); - // Exit endpoint uses exit_ip + port from registration (forwarded via entry) - let exit_wg_endpoint = format!("{}:{}", exit_ip, exit_gateway_data.endpoint.port()); - - info!("Two-hop WireGuard configuration:"); - info!(" Entry gateway:"); - info!(" Private IPv4: {}", entry_gateway_data.private_ipv4); - info!(" Endpoint: {}", entry_wg_endpoint); - info!(" Exit gateway:"); - info!(" Private IPv4: {}", exit_gateway_data.private_ipv4); - info!(" Endpoint (via forwarder): {}", exit_wg_endpoint); - - // Build two-hop tunnel configuration - let two_hop_config = common::TwoHopWgTunnelConfig::new( - entry_gateway_data.private_ipv4.to_string(), - entry_private_key_hex, - entry_public_key_hex, - entry_wg_endpoint, - awg_args.clone(), // 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 - ); - - // Run two-hop tunnel connectivity tests - common::run_two_hop_tunnel_tests(&two_hop_config, &netstack_args, &mut wg_outcome); - - info!("LP-based two-hop WireGuard probe completed"); - Ok(wg_outcome) -} - -fn mixnet_debug_config( - min_gateway_performance: Option, - ignore_egress_epoch_role: bool, -) -> nym_client_core::config::DebugConfig { - let mut debug_config = nym_client_core::config::DebugConfig::default(); - debug_config - .traffic - .disable_main_poisson_packet_distribution = true; - debug_config.cover_traffic.disable_loop_cover_traffic_stream = true; - if let Some(minimum_gateway_performance) = min_gateway_performance { - debug_config.topology.minimum_gateway_performance = minimum_gateway_performance; - } - if ignore_egress_epoch_role { - debug_config.topology.ignore_egress_epoch_role = ignore_egress_epoch_role; - } - - debug_config -} - -async fn do_ping( - mut mixnet_client: MixnetClient, - our_address: Recipient, - exit_router_address: Option, - tested_entry: bool, -) -> (anyhow::Result, MixnetClient) { - let entry = do_ping_entry(&mut mixnet_client, our_address, tested_entry).await; - - let (exit_result, mixnet_client) = if let Some(exit_router_address) = exit_router_address { - let (maybe_ip_pair, mut mixnet_client) = - connect_exit(mixnet_client, exit_router_address).await; - match maybe_ip_pair { - Some(ip_pair) => ( - do_ping_exit(&mut mixnet_client, ip_pair, exit_router_address).await, - mixnet_client, - ), - None => (Ok(Some(Exit::fail_to_connect())), mixnet_client), - } - } else { - (Ok(None), mixnet_client) - }; - - ( - exit_result.map(|exit| ProbeOutcome { - as_entry: entry, - as_exit: exit, - wg: None, - lp: None, - }), - mixnet_client, - ) -} - -async fn do_ping_entry( - mixnet_client: &mut MixnetClient, - our_address: Recipient, - tested_entry: bool, -) -> Entry { - // Step 1: confirm that the entry gateway is routing our mixnet traffic - info!("Sending mixnet ping to ourselves to verify mixnet connection"); - - if self_ping_and_wait(our_address, mixnet_client) - .await - .is_err() - { - return if tested_entry { - Entry::fail_to_connect() - } else { - Entry::EntryFailure - }; - } - info!("Successfully mixnet pinged ourselves"); - - Entry::success() -} - -async fn connect_exit( - mixnet_client: MixnetClient, - exit_router_address: Recipient, -) -> (Option, MixnetClient) { - // Step 2: connect to the exit gateway - info!( - "Connecting to exit gateway: {}", - exit_router_address.gateway().to_base58_string() - ); - // The IPR supports cancellation, but it's unused in the gateway probe - let cancel_token = CancellationToken::new(); - let mut ipr_client = IprClientConnect::new(mixnet_client, cancel_token); - - let maybe_ip_pair = ipr_client.connect(exit_router_address).await; - let mixnet_client = ipr_client.into_mixnet_client(); - - if let Ok(our_ips) = maybe_ip_pair { - info!("Successfully connected to exit gateway"); - info!("Using mixnet VPN IP addresses: {our_ips}"); - (Some(our_ips), mixnet_client) - } else { - (None, mixnet_client) - } -} - -async fn do_ping_exit( - mixnet_client: &mut MixnetClient, - our_ips: IpPair, - exit_router_address: Recipient, -) -> anyhow::Result> { - // Step 3: perform ICMP connectivity checks for the exit gateway - send_icmp_pings(mixnet_client, our_ips, exit_router_address).await?; - listen_for_icmp_ping_replies(mixnet_client, our_ips).await -} - -async fn send_icmp_pings( - mixnet_client: &MixnetClient, - our_ips: IpPair, - exit_router_address: Recipient, -) -> anyhow::Result<()> { - // ipv4 addresses for testing - let ipr_tun_ip_v4 = NYM_TUN_DEVICE_ADDRESS_V4; - let external_ip_v4 = Ipv4Addr::new(8, 8, 8, 8); - - // ipv6 addresses for testing - let ipr_tun_ip_v6 = NYM_TUN_DEVICE_ADDRESS_V6; - let external_ip_v6 = Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888); - - info!( - "Sending ICMP echo requests to: {ipr_tun_ip_v4}, {ipr_tun_ip_v6}, {external_ip_v4}, {external_ip_v6}" - ); - - // send ipv4 pings - for ii in 0..10 { - send_ping_v4( - mixnet_client, - our_ips, - ii, - ipr_tun_ip_v4, - exit_router_address, - ) - .await?; - send_ping_v4( - mixnet_client, - our_ips, - ii, - external_ip_v4, - exit_router_address, - ) - .await?; - } - - // send ipv6 pings - for ii in 0..10 { - send_ping_v6( - mixnet_client, - our_ips, - ii, - ipr_tun_ip_v6, - exit_router_address, - ) - .await?; - send_ping_v6( - mixnet_client, - our_ips, - ii, - external_ip_v6, - exit_router_address, - ) - .await?; - } - Ok(()) -} - -async fn listen_for_icmp_ping_replies( - mixnet_client: &mut MixnetClient, - our_ips: IpPair, -) -> anyhow::Result> { - let mut multi_ip_packet_decoder = MultiIpPacketCodec::new(); - let mut registered_replies = IpPingReplies::new(); - - loop { - tokio::select! { - _ = tokio::time::sleep(Duration::from_secs(2)) => { - info!("Finished waiting for ICMP echo reply from exit gateway"); - break; - } - Some(reconstructed_message) = mixnet_client.next() => { - let Some(data_response) = unpack_data_response(&reconstructed_message) else { - continue; - }; - - // IP packets are bundled together in a mixnet message - let mut bytes = BytesMut::from(&*data_response.ip_packet); - while let Ok(Some(packet)) = multi_ip_packet_decoder.decode(&mut bytes) { - if let Some(event) = check_for_icmp_beacon_reply(&packet.into_bytes(), icmp_identifier(), our_ips) { - info!("Received ICMP echo reply from exit gateway"); - info!("Connection event: {event:?}"); - registered_replies.register_event(&event); - } - } - } - } - } - - Ok(Some(Exit { - can_connect: true, - can_route_ip_v4: registered_replies.ipr_tun_ip_v4, - can_route_ip_external_v4: registered_replies.external_ip_v4, - can_route_ip_v6: registered_replies.ipr_tun_ip_v6, - can_route_ip_external_v6: registered_replies.external_ip_v6, - })) -} - -fn unpack_data_response(reconstructed_message: &ReconstructedMessage) -> Option { - match IpPacketResponse::from_reconstructed_message(reconstructed_message) { - Ok(response) => match response.data { - IpPacketResponseData::Data(data_response) => Some(data_response), - IpPacketResponseData::Control(control) => match *control { - ControlResponse::Info(info) => { - let msg = format!("Received info response from the mixnet: {}", info.reply); - match info.level { - InfoLevel::Info => info!("{msg}"), - InfoLevel::Warn => warn!("{msg}"), - InfoLevel::Error => error!("{msg}"), - } - None - } - _ => { - info!("Ignoring: {:?}", control); - None - } - }, - }, - Err(err) => { - warn!("Failed to parse mixnet message: {err}"); - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_netstack_args_default_values() { - // Test that the default values are correctly set in the struct definition - // This validates that our changes to the default values are correct - - // Create a default instance to test the values - let args = NetstackArgs { - netstack_download_timeout_sec: 180, - metadata_timeout_sec: 30, - netstack_v4_dns: "1.1.1.1".to_string(), - netstack_v6_dns: "2606:4700:4700::1111".to_string(), - netstack_num_ping: 5, - netstack_send_timeout_sec: 3, - netstack_recv_timeout_sec: 3, - netstack_ping_hosts_v4: vec!["nym.com".to_string()], - netstack_ping_ips_v4: vec!["1.1.1.1".to_string()], - netstack_ping_hosts_v6: vec!["cloudflare.com".to_string()], - netstack_ping_ips_v6: vec![ - "2001:4860:4860::8888".to_string(), - "2606:4700:4700::1111".to_string(), - "2620:fe::fe".to_string(), - ], - }; - - // Test IPv4 defaults - assert_eq!(args.netstack_ping_hosts_v4, vec!["nym.com"]); - assert_eq!(args.netstack_ping_ips_v4, vec!["1.1.1.1"]); - assert_eq!(args.netstack_v4_dns, "1.1.1.1"); - - // Test IPv6 defaults - assert_eq!(args.netstack_ping_hosts_v6, vec!["cloudflare.com"]); - assert_eq!( - args.netstack_ping_ips_v6, - vec![ - "2001:4860:4860::8888", - "2606:4700:4700::1111", - "2620:fe::fe" - ] - ); - assert_eq!(args.netstack_v6_dns, "2606:4700:4700::1111"); - - // Test other defaults - assert_eq!(args.netstack_download_timeout_sec, 180); - assert_eq!(args.netstack_num_ping, 5); - assert_eq!(args.netstack_send_timeout_sec, 3); - assert_eq!(args.netstack_recv_timeout_sec, 3); - } - - #[test] - fn test_netstack_args_custom_construction() { - // Test that we can create instances with custom values - let args = NetstackArgs { - netstack_download_timeout_sec: 300, - metadata_timeout_sec: 30, - netstack_v4_dns: "8.8.8.8".to_string(), - netstack_v6_dns: "2001:4860:4860::8888".to_string(), - netstack_num_ping: 10, - netstack_send_timeout_sec: 5, - netstack_recv_timeout_sec: 5, - netstack_ping_hosts_v4: vec!["example.com".to_string()], - netstack_ping_ips_v4: vec!["8.8.8.8".to_string()], - netstack_ping_hosts_v6: vec!["ipv6.example.com".to_string()], - netstack_ping_ips_v6: vec!["2001:4860:4860::8888".to_string()], - }; - - assert_eq!(args.netstack_ping_hosts_v4, vec!["example.com"]); - assert_eq!(args.netstack_ping_hosts_v6, vec!["ipv6.example.com"]); - assert_eq!(args.netstack_ping_ips_v4, vec!["8.8.8.8"]); - assert_eq!(args.netstack_ping_ips_v6, vec!["2001:4860:4860::8888"]); - assert_eq!(args.netstack_v4_dns, "8.8.8.8"); - assert_eq!(args.netstack_v6_dns, "2001:4860:4860::8888"); - assert_eq!(args.netstack_download_timeout_sec, 300); - assert_eq!(args.netstack_num_ping, 10); - assert_eq!(args.netstack_send_timeout_sec, 5); - assert_eq!(args.netstack_recv_timeout_sec, 5); - } - - #[test] - fn test_netstack_args_multiple_values() { - // Test that multiple hosts and IPs can be stored - let args = NetstackArgs { - netstack_download_timeout_sec: 180, - metadata_timeout_sec: 30, - netstack_v4_dns: "1.1.1.1".to_string(), - netstack_v6_dns: "2606:4700:4700::1111".to_string(), - netstack_num_ping: 5, - netstack_send_timeout_sec: 3, - netstack_recv_timeout_sec: 3, - netstack_ping_hosts_v4: vec!["nym.com".to_string(), "example.com".to_string()], - netstack_ping_ips_v4: vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()], - netstack_ping_hosts_v6: vec![ - "cloudflare.com".to_string(), - "ipv6.example.com".to_string(), - ], - netstack_ping_ips_v6: vec![ - "2001:4860:4860::8888".to_string(), - "2606:4700:4700::1111".to_string(), - ], - }; - - assert_eq!(args.netstack_ping_hosts_v4, vec!["nym.com", "example.com"]); - assert_eq!( - args.netstack_ping_hosts_v6, - vec!["cloudflare.com", "ipv6.example.com"] - ); - assert_eq!(args.netstack_ping_ips_v4, vec!["1.1.1.1", "8.8.8.8"]); - assert_eq!( - args.netstack_ping_ips_v6, - vec!["2001:4860:4860::8888", "2606:4700:4700::1111"] - ); - } - - #[test] - fn test_netstack_args_edge_cases() { - // Test edge cases like zero values and empty vectors - let args = NetstackArgs { - netstack_download_timeout_sec: 0, - metadata_timeout_sec: 30, - netstack_v4_dns: "1.1.1.1".to_string(), - netstack_v6_dns: "2606:4700:4700::1111".to_string(), - netstack_num_ping: 0, - netstack_send_timeout_sec: 0, - netstack_recv_timeout_sec: 0, - netstack_ping_hosts_v4: vec![], - netstack_ping_ips_v4: vec![], - netstack_ping_hosts_v6: vec![], - netstack_ping_ips_v6: vec![], - }; - - assert_eq!(args.netstack_num_ping, 0); - assert_eq!(args.netstack_send_timeout_sec, 0); - assert_eq!(args.netstack_recv_timeout_sec, 0); - assert_eq!(args.netstack_download_timeout_sec, 0); - assert!(args.netstack_ping_hosts_v4.is_empty()); - assert!(args.netstack_ping_ips_v4.is_empty()); - assert!(args.netstack_ping_hosts_v6.is_empty()); - assert!(args.netstack_ping_ips_v6.is_empty()); - } - - #[test] - fn test_netstack_args_domain_validation() { - // Test that our domain choices are reasonable - let args = NetstackArgs { - netstack_download_timeout_sec: 180, - metadata_timeout_sec: 30, - netstack_v4_dns: "1.1.1.1".to_string(), - netstack_v6_dns: "2606:4700:4700::1111".to_string(), - netstack_num_ping: 5, - netstack_send_timeout_sec: 3, - netstack_recv_timeout_sec: 3, - netstack_ping_hosts_v4: vec!["nym.com".to_string()], - netstack_ping_ips_v4: vec!["1.1.1.1".to_string()], - netstack_ping_hosts_v6: vec!["cloudflare.com".to_string()], - netstack_ping_ips_v6: vec!["2001:4860:4860::8888".to_string()], - }; - - assert!(args.netstack_ping_hosts_v4[0].contains("nym")); - - assert!(args.netstack_ping_hosts_v6[0].contains("cloudflare")); - - assert_eq!(args.netstack_v4_dns, "1.1.1.1"); - assert_eq!(args.netstack_v6_dns, "2606:4700:4700::1111"); - } -} diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 0ce0b98b8f..d03a9aa267 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -6,10 +6,9 @@ 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::nodes::{NymApiDirectory, query_gateway_by_ip}; use nym_gateway_probe::{ - CredentialArgs, NetstackArgs, ProbeResult, TestMode, TestedNode, TestedNodeDetails, - TestedNodeLpDetails, + CredentialArgs, NetstackArgs, NymApiDirectory, ProbeResult, TestMode, TestedNode, + TestedNodeDetails, TestedNodeLpDetails, query_gateway_by_ip, }; use nym_kkt_ciphersuite::{HashFunction, KEM}; use nym_sdk::mixnet::NodeIdentity;