From 1fabec4cd957612d84a51be8edc755158d1074c1 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Fri, 13 Feb 2026 10:31:55 +0100 Subject: [PATCH 1/4] testing port checks --- nym-gateway-probe/build.rs | 91 ++++++++ nym-gateway-probe/netstack_ping/lib.go | 133 ++++++++++-- nym-gateway-probe/src/common/netstack.rs | 26 +++ nym-gateway-probe/src/common/probe_tests.rs | 2 + nym-gateway-probe/src/common/types.rs | 18 ++ nym-gateway-probe/src/common/wireguard.rs | 18 +- nym-gateway-probe/src/config/mod.rs | 4 +- nym-gateway-probe/src/config/netstack.rs | 61 ++++++ nym-gateway-probe/src/config/test_mode.rs | 3 + nym-gateway-probe/src/lib.rs | 219 +++++++++++++++++++- nym-gateway-probe/src/run.rs | 109 +++++++++- 11 files changed, 661 insertions(+), 23 deletions(-) diff --git a/nym-gateway-probe/build.rs b/nym-gateway-probe/build.rs index 43af675650..dc53271848 100644 --- a/nym-gateway-probe/build.rs +++ b/nym-gateway-probe/build.rs @@ -9,6 +9,7 @@ use vergen_gitcl::{BuildBuilder, CargoBuilder, Emitter, GitclBuilder, RustcBuild fn main() -> anyhow::Result<()> { build_go()?; + generate_exit_policy_ports()?; Emitter::default() .add_instructions(&BuildBuilder::all_build()?)? @@ -18,6 +19,96 @@ fn main() -> anyhow::Result<()> { .emit() } +/// Parse PORT_MAPPINGS from network-tunnel-manager.sh and generate a sorted +/// Rust const with every unique port. Ranges are represented by their start +/// and end values so a single TCP check can confirm the iptables rule exists. +fn generate_exit_policy_ports() -> anyhow::Result<()> { + use std::collections::BTreeMap; + + let script_path = PathBuf::from("../scripts/nym-node-setup/network-tunnel-manager.sh"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").context("OUT_DIR not set")?); + + println!("cargo::rerun-if-changed={}", script_path.display()); + + let content = std::fs::read_to_string(&script_path).context( + "failed to read network-tunnel-manager.sh — is it present at ../scripts/nym-node-setup/ ?", + )?; + + // port → service name (BTreeMap keeps them sorted) + let mut port_map: BTreeMap = BTreeMap::new(); + let mut in_mappings = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("declare -A PORT_MAPPINGS=(") { + in_mappings = true; + continue; + } + if in_mappings && trimmed == ")" { + break; + } + if !in_mappings { + continue; + } + + // strip comment prefix so we still pick up ports that are opened + // via a separate mechanism (e.g. SMTP/465 with rate limiting) + let stripped = trimmed.trim_start_matches('#').trim(); + + // match ["ServiceName"]="port-or-range" + let Some(name_start) = stripped.find("[\"") else { + continue; + }; + let Some(name_end) = stripped.find("\"]=") else { + continue; + }; + let service = &stripped[name_start + 2..name_end]; + let value = stripped[name_end + 3..].trim_matches('"'); + + if value.contains('-') { + let parts: Vec<&str> = value.split('-').collect(); + if parts.len() == 2 { + if let (Ok(lo), Ok(hi)) = (parts[0].parse::(), parts[1].parse::()) { + port_map + .entry(lo) + .or_insert_with(|| format!("{service} (range start)")); + port_map + .entry(hi) + .or_insert_with(|| format!("{service} (range end)")); + } + } + } else if let Ok(port) = value.parse::() { + port_map.entry(port).or_insert_with(|| service.to_string()); + } + } + + if port_map.is_empty() { + bail!("No ports found in PORT_MAPPINGS — is network-tunnel-manager.sh correct?"); + } + + // write generated Rust source + let mut out = String::new(); + out.push_str( + "// Auto-generated from scripts/nym-node-setup/network-tunnel-manager.sh PORT_MAPPINGS.\n", + ); + out.push_str("// Do not edit — changes are overwritten on rebuild.\n"); + out.push_str("// To add or remove ports, update PORT_MAPPINGS in the shell script.\n\n"); + out.push_str(&format!( + "/// {} unique ports parsed from the canonical exit policy at build time.\n", + port_map.len() + )); + out.push_str("pub const EXIT_POLICY_PORTS: &[u16] = &[\n"); + for (port, service) in &port_map { + let entry = format!("{port},"); + out.push_str(&format!(" {entry:<7}// {service}\n")); + } + out.push_str("];\n"); + + std::fs::write(out_dir.join("exit_policy_ports.rs"), out)?; + Ok(()) +} + fn build_go() -> anyhow::Result<()> { const LIB_NAME: &str = "netstack_ping"; diff --git a/nym-gateway-probe/netstack_ping/lib.go b/nym-gateway-probe/netstack_ping/lib.go index 7d04b7cbf2..8ab6b371ce 100644 --- a/nym-gateway-probe/netstack_ping/lib.go +++ b/nym-gateway-probe/netstack_ping/lib.go @@ -62,21 +62,27 @@ type NetstackRequestGo struct { DownloadTimeoutSec uint64 `json:"download_timeout_sec"` MetadataTimeoutSec uint64 `json:"metadata_timeout_sec"` AwgArgs string `json:"awg_args"` + // exit policy port check + PortCheckTarget string `json:"port_check_target"` + PortCheckPorts []uint16 `json:"port_check_ports"` + PortCheckOnly bool `json:"port_check_only"` + PortCheckTimeoutSec uint64 `json:"port_check_timeout_sec"` } type NetstackResponse struct { - CanHandshake bool `json:"can_handshake"` - CanQueryMetadata bool `json:"can_query_metadata"` - SentIps uint16 `json:"sent_ips"` - ReceivedIps uint16 `json:"received_ips"` - SentHosts uint16 `json:"sent_hosts"` - ReceivedHosts uint16 `json:"received_hosts"` - CanResolveDns bool `json:"can_resolve_dns"` - DownloadedFile string `json:"downloaded_file"` - DownloadedFileSizeBytes uint64 `json:"downloaded_file_size_bytes"` - DownloadDurationSec uint64 `json:"download_duration_sec"` - DownloadDurationMilliseconds uint64 `json:"download_duration_milliseconds"` - DownloadError string `json:"download_error"` + CanHandshake bool `json:"can_handshake"` + CanQueryMetadata bool `json:"can_query_metadata"` + SentIps uint16 `json:"sent_ips"` + ReceivedIps uint16 `json:"received_ips"` + SentHosts uint16 `json:"sent_hosts"` + ReceivedHosts uint16 `json:"received_hosts"` + CanResolveDns bool `json:"can_resolve_dns"` + DownloadedFile string `json:"downloaded_file"` + DownloadedFileSizeBytes uint64 `json:"downloaded_file_size_bytes"` + DownloadDurationSec uint64 `json:"download_duration_sec"` + DownloadDurationMilliseconds uint64 `json:"download_duration_milliseconds"` + DownloadError string `json:"download_error"` + PortCheckResults map[string]bool `json:"port_check_results,omitempty"` } type SuccessResult = struct { @@ -201,7 +207,7 @@ func pingTwoHop(req TwoHopNetstackRequest) (NetstackResponse, error) { log.Printf("Exit WG IP: %s", req.ExitWgIp) log.Printf("IP version: %d", req.IpVersion) - response := NetstackResponse{false, false, 0, 0, 0, 0, false, "", 0, 0, 0, ""} + response := NetstackResponse{} // Parse the exit endpoint to determine IP version for forwarder exitEndpoint, err := netip.ParseAddrPort(req.ExitEndpoint) @@ -439,7 +445,7 @@ func ping(req NetstackRequestGo) (NetstackResponse, error) { ipc.WriteString("\nallowed_ip=::/0\n") } - response := NetstackResponse{false, false, 0, 0, 0, 0, false, "", 0, 0, 0, ""} + response := NetstackResponse{} err = dev.IpcSet(ipc.String()) if err != nil { @@ -463,6 +469,19 @@ func ping(req NetstackRequestGo) (NetstackResponse, error) { response.CanHandshake = true + // port-check-only mode: skip pings/download, only test TCP port reachability + if req.PortCheckOnly && len(req.PortCheckPorts) > 0 { + log.Printf("=== Port Check Only Mode ===") + response.PortCheckResults = checkPorts(req.PortCheckTarget, req.PortCheckPorts, req.PortCheckTimeoutSec, tnet) + return response, nil + } + + // run port checks alongside normal tests if ports were requested + if len(req.PortCheckPorts) > 0 { + log.Printf("=== Running Port Checks (alongside normal tests) ===") + response.PortCheckResults = checkPorts(req.PortCheckTarget, req.PortCheckPorts, req.PortCheckTimeoutSec, tnet) + } + // Skip metadata query if endpoint is empty (e.g., for IPv6 where the IPv4 metadata endpoint is not reachable) if req.MetadataEndpoint != "" { version, duration, err := queryMetadata(req.MetadataEndpoint, req.MetadataTimeoutSec, tnet) @@ -569,6 +588,92 @@ func ping(req NetstackRequestGo) (NetstackResponse, error) { return response, nil } +// checkPorts tests TCP connectivity to a target on each port through the WG +// tunnel. blocked ports (NYM-EXIT iptables) will timeout or get reset. +// runs concurrently. +func checkPorts(target string, ports []uint16, timeoutSec uint64, tnet *netstack.Net) map[string]bool { + if target == "" { + target = "portquiz.net" + } + if timeoutSec == 0 { + timeoutSec = 5 + } + + // resolve target once via host DNS (we only care about TCP reachability + // through the tunnel, not DNS-through-tunnel behaviour) + targetIP := target + if net.ParseIP(target) == nil { + addrs, err := net.LookupHost(target) + if err != nil || len(addrs) == 0 { + log.Printf("Port check: DNS lookup for %s failed (%v), using hostname as-is", target, err) + } else { + targetIP = addrs[0] + log.Printf("Port check: resolved %s -> %s", target, targetIP) + } + } + + timeout := time.Duration(timeoutSec) * time.Second + + // batching: portquiz.net enforces a per-source-IP connection limit. + // testing all 114 ports in one burst trips it after ~29 connections. + // small batches with a cooldown in between keep us under the limit. + const batchSize = 20 + const batchDelay = 45 * time.Second + const dialGap = 200 * time.Millisecond + + results := make(map[string]bool) + totalBatches := (len(ports) + batchSize - 1) / batchSize + + for batchIdx := 0; batchIdx < totalBatches; batchIdx++ { + start := batchIdx * batchSize + end := start + batchSize + if end > len(ports) { + end = len(ports) + } + batch := ports[start:end] + + if batchIdx > 0 { + log.Printf("Batch %d/%d: cooldown %v before next batch...", + batchIdx+1, totalBatches, batchDelay) + time.Sleep(batchDelay) + } + + log.Printf("Batch %d/%d: testing %d ports (%d–%d)...", + batchIdx+1, totalBatches, len(batch), batch[0], batch[len(batch)-1]) + + for i, p := range batch { + if i > 0 { + time.Sleep(dialGap) + } + + addr := fmt.Sprintf("%s:%d", targetIP, p) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + conn, err := tnet.DialContext(ctx, "tcp", addr) + cancel() + + if err != nil { + log.Printf("Port %d: CLOSED (%v)", p, err) + results[fmt.Sprintf("%d", p)] = false + } else { + conn.Close() + log.Printf("Port %d: OPEN", p) + results[fmt.Sprintf("%d", p)] = true + } + } + } + + openCount := 0 + for _, open := range results { + if open { + openCount++ + } + } + log.Printf("Port check complete: %d/%d ports open", openCount, len(ports)) + + return results +} + func sendPing(address string, seq uint8, sendTtimeoutSecs uint64, receiveTimoutSecs uint64, tnet *netstack.Net, ipVersion uint8) (time.Duration, error) { maxPingRetries := 2 baseTimeout := receiveTimoutSecs diff --git a/nym-gateway-probe/src/common/netstack.rs b/nym-gateway-probe/src/common/netstack.rs index 761b6713f0..34faf756e5 100644 --- a/nym-gateway-probe/src/common/netstack.rs +++ b/nym-gateway-probe/src/common/netstack.rs @@ -4,6 +4,7 @@ use crate::config::NetstackArgs; use anyhow::Context; use serde::Deserialize; +use std::collections::HashMap; use std::ffi::{CStr, CString}; mod sys { @@ -25,6 +26,11 @@ pub struct NetstackRequest { v6_ping_config: PingConfig, download_timeout_sec: u64, awg_args: String, + // exit policy port check + port_check_target: String, + port_check_ports: Vec, + port_check_only: bool, + port_check_timeout_sec: u64, } #[derive(serde::Serialize)] @@ -76,6 +82,7 @@ impl NetstackRequest { download_timeout_sec: u64, awg_args: &str, netstack_args: NetstackArgs, + port_check_only: bool, ) -> Self { Self { private_key: private_key.to_string(), @@ -86,6 +93,10 @@ impl NetstackRequest { v4_ping_config: PingConfig::from_netstack_args_v4(wg_ip4, &netstack_args), v6_ping_config: PingConfig::from_netstack_args_v6(wg_ip6, &netstack_args), download_timeout_sec, + port_check_target: netstack_args.port_check_target.clone(), + port_check_ports: netstack_args.port_check_ports.clone(), + port_check_only, + port_check_timeout_sec: netstack_args.port_check_timeout_sec, } } @@ -116,6 +127,11 @@ pub struct NetstackRequestGo { recv_timeout_sec: u64, download_timeout_sec: u64, awg_args: String, + // exit policy port check + port_check_target: String, + port_check_ports: Vec, + port_check_only: bool, + port_check_timeout_sec: u64, } impl NetstackRequestGo { @@ -135,6 +151,10 @@ impl NetstackRequestGo { recv_timeout_sec: req.v4_ping_config.recv_timeout_sec, download_timeout_sec: req.download_timeout_sec, awg_args: req.awg_args.clone(), + port_check_target: req.port_check_target.clone(), + port_check_ports: req.port_check_ports.clone(), + port_check_only: req.port_check_only, + port_check_timeout_sec: req.port_check_timeout_sec, } } @@ -155,6 +175,10 @@ impl NetstackRequestGo { recv_timeout_sec: req.v6_ping_config.recv_timeout_sec, download_timeout_sec: req.download_timeout_sec, awg_args: req.awg_args.clone(), + port_check_target: req.port_check_target.clone(), + port_check_ports: req.port_check_ports.clone(), + port_check_only: req.port_check_only, + port_check_timeout_sec: req.port_check_timeout_sec, } } } @@ -173,6 +197,8 @@ pub struct NetstackResponse { pub downloaded_file_size_bytes: u64, pub download_duration_milliseconds: u64, pub download_error: String, + #[serde(default)] + pub port_check_results: Option>, } #[derive(Clone, Debug, serde::Deserialize)] diff --git a/nym-gateway-probe/src/common/probe_tests.rs b/nym-gateway-probe/src/common/probe_tests.rs index d37a1042d2..f60d78c375 100644 --- a/nym-gateway-probe/src/common/probe_tests.rs +++ b/nym-gateway-probe/src/common/probe_tests.rs @@ -46,6 +46,7 @@ pub async fn wg_probe( auth_version: AuthenticatorVersion, awg_args: Option, netstack_args: NetstackArgs, + port_check_only: bool, // TODO: update type credential: CredentialSpendingData, ) -> anyhow::Result { @@ -149,6 +150,7 @@ pub async fn wg_probe( &tunnel_config, &netstack_args, &awg_args.unwrap_or_default(), + port_check_only, &mut wg_outcome, ); diff --git a/nym-gateway-probe/src/common/types.rs b/nym-gateway-probe/src/common/types.rs index 8541a57421..44529d1855 100644 --- a/nym-gateway-probe/src/common/types.rs +++ b/nym-gateway-probe/src/common/types.rs @@ -1,5 +1,6 @@ use nym_connection_monitor::ConnectionStatusEvent; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; pub use super::bandwidth_helpers::{AttachedTicket, AttachedTicketMaterials}; pub use super::socks5_test::HttpsConnectivityResult; @@ -50,6 +51,10 @@ pub struct WgProbeResults { pub download_duration_milliseconds_v6: u64, pub downloaded_file_v6: String, pub download_error_v6: String, + + /// port → open/closed from exit policy check (if requested) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port_check_results: Option>, } #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] @@ -195,6 +200,19 @@ impl Socks5ProbeResults { } } +/// Output of the `run-ports` subcommand — per-port TCP reachability through +/// the WG exit tunnel, without the full probe outcome. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortCheckResult { + pub gateway: String, + pub can_register: bool, + pub port_check_target: String, + /// port → open/closed + pub ports: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[derive(Debug, Clone, Default)] pub struct IpPingReplies { pub ipr_tun_ip_v4: bool, diff --git a/nym-gateway-probe/src/common/wireguard.rs b/nym-gateway-probe/src/common/wireguard.rs index 3439a4bb5f..740f32700a 100644 --- a/nym-gateway-probe/src/common/wireguard.rs +++ b/nym-gateway-probe/src/common/wireguard.rs @@ -12,6 +12,7 @@ use tracing::{error, info}; use crate::NetstackArgs; use crate::common::netstack::{NetstackRequest, NetstackRequestGo, NetstackResult}; 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 { @@ -63,6 +64,7 @@ impl WgTunnelConfig { /// - DNS resolution /// - ICMP ping to specified hosts and IPs /// - Optional download test +/// - Optional exit policy port check (TCP connect through tunnel) /// /// Results are written directly into the provided `wg_outcome` to avoid field-by-field /// copying at call sites. @@ -71,6 +73,7 @@ impl WgTunnelConfig { /// * `config` - WireGuard tunnel configuration /// * `netstack_args` - Netstack test parameters (DNS, hosts to ping, timeouts, etc.) /// * `awg_args` - Amnezia WireGuard arguments (empty string for standard WG) +/// * `port_check_only` - If true, skip pings/download and only run TCP port checks /// * `wg_outcome` - Mutable reference to write test results into // This function extracts the shared netstack testing logic from // wg_probe() and wg_probe_lp() to eliminate code duplication. @@ -78,6 +81,7 @@ pub fn run_tunnel_tests( config: &WgTunnelConfig, netstack_args: &NetstackArgs, awg_args: &str, + port_check_only: bool, wg_outcome: &mut WgProbeResults, ) { // Build the netstack request @@ -91,9 +95,10 @@ pub fn run_tunnel_tests( netstack_args.netstack_download_timeout_sec, awg_args, netstack_args.clone(), + port_check_only, ); - // Perform IPv4 ping test + // Perform IPv4 ping test (also carries port check results in port-check-only mode) info!("Testing IPv4 tunnel connectivity..."); let ipv4_request = NetstackRequestGo::from_rust_v4(&netstack_request); @@ -122,6 +127,11 @@ pub fn run_tunnel_tests( netstack_response_v4.downloaded_file_size_bytes; wg_outcome.downloaded_file_v4 = netstack_response_v4.downloaded_file; wg_outcome.download_error_v4 = netstack_response_v4.download_error; + + // capture port check results (present when ports were requested) + if netstack_response_v4.port_check_results.is_some() { + wg_outcome.port_check_results = netstack_response_v4.port_check_results; + } } Ok(NetstackResult::Error { error }) => { error!("Netstack runtime error (IPv4): {error}") @@ -131,6 +141,12 @@ pub fn run_tunnel_tests( } } + // in port-check-only mode, skip IPv6 tests — port checks ran through IPv4 above + if port_check_only { + info!("Port-check-only mode: skipping IPv6 tunnel tests"); + return; + } + // Perform IPv6 ping test info!("Testing IPv6 tunnel connectivity..."); let ipv6_request = NetstackRequestGo::from_rust_v6(&netstack_request); diff --git a/nym-gateway-probe/src/config/mod.rs b/nym-gateway-probe/src/config/mod.rs index e110823cbf..c54e8897fd 100644 --- a/nym-gateway-probe/src/config/mod.rs +++ b/nym-gateway-probe/src/config/mod.rs @@ -9,7 +9,7 @@ mod socks5; mod test_mode; pub use credentials::{CredentialArgs, CredentialMode}; -pub use netstack::NetstackArgs; +pub use netstack::{EXIT_POLICY_PORTS, NetstackArgs}; pub use socks5::Socks5Args; pub use test_mode::TestMode; @@ -22,7 +22,7 @@ pub struct ProbeConfig { /// Test mode - explicitly specify which tests to run /// /// Modes: - /// core. - Traditional mixnet testing (entry/exit pings + WireGuard via authenticator) + /// 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) diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs index 50251e835e..433d3a03bd 100644 --- a/nym-gateway-probe/src/config/netstack.rs +++ b/nym-gateway-probe/src/config/netstack.rs @@ -3,6 +3,11 @@ use clap::Args; +// EXIT_POLICY_PORTS is generated at build time by parsing PORT_MAPPINGS +// from scripts/nym-node-setup/network-tunnel-manager.sh. +// To add or remove ports, update PORT_MAPPINGS in the shell script and rebuild. +include!(concat!(env!("OUT_DIR"), "/exit_policy_ports.rs")); + #[derive(Args, Clone, Debug)] pub struct NetstackArgs { #[arg(long, hide = true, env = "PROBE_NETSTACK_DOWNLOAD_TIMEOUT_SEC", default_value_t = NetstackArgs::default().netstack_download_timeout_sec)] @@ -37,6 +42,19 @@ pub struct NetstackArgs { #[arg(long, hide = true, env = "PROBE_NETSTACK_PING_IPS_V6", default_values_t = NetstackArgs::default().netstack_ping_ips_v6)] pub netstack_ping_ips_v6: Vec, + + /// Target host for exit policy port checks (must listen on all tested ports) + #[arg(long = "use-target", default_value = "portquiz.net")] + pub port_check_target: String, + + /// TCP ports to check through the WireGuard tunnel for exit policy verification. + /// Only used with the `run-ports` subcommand. For all exit policy ports, use --check-all-ports instead. + #[arg(long = "check-ports", value_delimiter = ',', default_values_t = Vec::::new())] + pub port_check_ports: Vec, + + /// Timeout in seconds for each individual TCP port check + #[arg(long, default_value_t = 5)] + pub port_check_timeout_sec: u64, } impl Default for NetstackArgs { @@ -87,6 +105,9 @@ mod tests { "2606:4700:4700::1111".to_string(), "2620:fe::fe".to_string(), ], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![], + port_check_timeout_sec: 5, }; // Test IPv4 defaults @@ -111,6 +132,11 @@ mod tests { assert_eq!(args.netstack_num_ping, 5); assert_eq!(args.netstack_send_timeout_sec, 3); assert_eq!(args.netstack_recv_timeout_sec, 3); + + // Test port check defaults + assert_eq!(args.port_check_target, "portquiz.net"); + assert!(args.port_check_ports.is_empty()); + assert_eq!(args.port_check_timeout_sec, 5); } #[test] @@ -128,6 +154,9 @@ mod tests { 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()], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![80, 443, 8332], + port_check_timeout_sec: 10, }; assert_eq!(args.netstack_ping_hosts_v4, vec!["example.com"]); @@ -163,6 +192,9 @@ mod tests { "2001:4860:4860::8888".to_string(), "2606:4700:4700::1111".to_string(), ], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![], + port_check_timeout_sec: 5, }; assert_eq!(args.netstack_ping_hosts_v4, vec!["nym.com", "example.com"]); @@ -192,6 +224,9 @@ mod tests { netstack_ping_ips_v4: vec![], netstack_ping_hosts_v6: vec![], netstack_ping_ips_v6: vec![], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![], + port_check_timeout_sec: 0, }; assert_eq!(args.netstack_num_ping, 0); @@ -219,6 +254,9 @@ mod tests { 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()], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![], + port_check_timeout_sec: 5, }; assert!(args.netstack_ping_hosts_v4[0].contains("nym")); @@ -228,4 +266,27 @@ mod tests { assert_eq!(args.netstack_v4_dns, "1.1.1.1"); assert_eq!(args.netstack_v6_dns, "2606:4700:4700::1111"); } + + #[test] + fn test_exit_policy_ports_no_duplicates_and_sorted() { + let ports = EXIT_POLICY_PORTS; + assert!(!ports.is_empty(), "EXIT_POLICY_PORTS should not be empty"); + + // verify sorted + for window in ports.windows(2) { + assert!( + window[0] < window[1], + "EXIT_POLICY_PORTS out of order or duplicate: {} >= {}", + window[0], + window[1] + ); + } + + // spot-check a few well-known ports + assert!(ports.contains(&22), "should contain SSH (22)"); + assert!(ports.contains(&443), "should contain HTTPS (443)"); + assert!(ports.contains(&22021), "should contain Session (22021)"); + assert!(ports.contains(&8332), "should contain Bitcoin (8332)"); + assert!(ports.contains(&9735), "should contain Lightning (9735)"); + } } diff --git a/nym-gateway-probe/src/config/test_mode.rs b/nym-gateway-probe/src/config/test_mode.rs index eec509f0e9..a1a200404a 100644 --- a/nym-gateway-probe/src/config/test_mode.rs +++ b/nym-gateway-probe/src/config/test_mode.rs @@ -10,6 +10,9 @@ //! - LpOnly: LP registration only, no WireGuard //! - Socks5Only: Socks5 test //! - All: Mixnet, wireguard over authenticator and LP registration +//! +//! Note: Exit policy port checking is handled by the `run-ports` subcommand, +//! not via a test mode. /// Test mode for the gateway probe. /// diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 582c80f89e..df8c2e1e04 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -8,7 +8,7 @@ use crate::common::probe_tests::{ do_ping, do_socks5_connectivity_test, lp_registration_probe, wg_probe, }; use crate::common::types::{Entry, LpProbeResults}; -use crate::config::{CredentialArgs, CredentialMode, NetstackArgs, ProbeConfig}; +use crate::config::{CredentialArgs, CredentialMode, ProbeConfig}; use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; use nym_bandwidth_controller::BandwidthTicketProvider; use nym_client_core::config::ForgetMe; @@ -20,13 +20,14 @@ use nym_sdk::mixnet::{ }; use nym_topology::{HardcodedTopologyProvider, NymTopology}; use rand::rngs::OsRng; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::*; pub use crate::common::nodes::{NymApiDirectory, query_gateway_by_ip}; -pub use crate::common::types::{ProbeOutcome, ProbeResult}; +pub use crate::common::types::{PortCheckResult, ProbeOutcome, ProbeResult}; mod common; pub use common::types; @@ -256,6 +257,218 @@ impl Probe { self.do_probe_test(mixnet_client, bandwidth_provider).await } + /// Run a port-check probe against the exit gateway's WG exit policy + pub async fn probe_run_ports( + mut self, + config_dir: &PathBuf, + credential: CredentialMode, + ) -> anyhow::Result { + let exit_node = self.exit_node.take().unwrap_or(self.entry_node.clone()); + let exit_identity = exit_node.identity.to_string(); + + // need authenticator + IP to be a functional exit + let (authenticator, ip_address) = + match (exit_node.authenticator_address, exit_node.ip_address) { + (Some(auth), Some(ip)) => (auth, ip), + _ => { + anyhow::bail!( + "Gateway {} missing authenticator address or IP — not a functional exit", + exit_identity + ); + } + }; + + let ports = self.config.netstack_args.port_check_ports.clone(); + let port_check_target = self.config.netstack_args.port_check_target.clone(); + + if ports.is_empty() { + anyhow::bail!( + "No ports specified. Use --check-ports 80,443,22021 or --check-all-ports" + ); + } + + info!( + "Port check: testing {} ports on gateway {} via {}", + ports.len(), + exit_identity, + port_check_target + ); + + // storage + credential setup (same as probe_run) + let storage_paths = StoragePaths::new_from_dir(config_dir)?; + let storage = storage_paths + .initialise_default_persistent_storage() + .await?; + + let mixnet_debug_config = helpers::mixnet_debug_config( + self.config.min_gateway_mixnet_performance, + self.config.ignore_egress_epoch_role, + ); + + self.topology = helpers::fetch_topology(&self.network, &mixnet_debug_config) + .await + .inspect_err(|e| warn!("Failed to fetch topology: {e}")) + .ok(); + + let mut mixnet_client_builder = MixnetClientBuilder::new_with_storage(storage.clone()) + .request_gateway(self.entry_node.identity.to_string()) + .network_details(self.network.clone()) + .debug_config(mixnet_debug_config) + .with_forget_me(ForgetMe::new_stats()) + .credentials_mode(!credential.use_mock_ecash); + + if let Some(topology) = &self.topology { + mixnet_client_builder = mixnet_client_builder.custom_topology_provider(Box::new( + HardcodedTopologyProvider::new(topology.clone()), + )); + } + + let disconnected_mixnet_client = mixnet_client_builder.build()?; + + // make sure identity keys exist before credential acquisition + // (acquire_bandwidth → create_bandwidth_client needs them on disk) + let key_store = storage.key_store(); + if key_store.load_keys().await.is_err() { + debug!("Generating new client keys"); + let mut rng = OsRng; + nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; + } + + credential + .acquire(&disconnected_mixnet_client, &storage) + .await?; + + let bandwidth_provider = build_bandwidth_controller( + &self.network, + storage.credential_store().clone(), + credential.use_mock_ecash, + )?; + + let mixnet_client = match disconnected_mixnet_client.connect_to_mixnet().await { + Ok(client) => { + info!( + "Connected to mixnet via entry gateway: {}", + self.entry_node.identity + ); + info!("Our nym address: {}", *client.nym_address()); + client + } + Err(e) => { + return Ok(PortCheckResult { + gateway: exit_identity, + can_register: false, + port_check_target, + ports: HashMap::new(), + error: Some(format!("Failed to connect to mixnet: {e}")), + }); + } + }; + + // warm up mixnet routes via do_ping(), same as core mode. + // without this, auth registration tends to time out on cold routes. + info!("Warming up mixnet routes..."); + let nym_address = *mixnet_client.nym_address(); + let (warmup_result, mixnet_client) = do_ping( + mixnet_client, + nym_address, + exit_node.exit_router_address, + false, + ) + .await; + + match warmup_result { + Ok(_) => info!("Mixnet warmup done"), + Err(e) => warn!("Warmup had issues ({e}), auth may be less reliable"), + } + + // auth registration (with retries) + let nym_address = *mixnet_client.nym_address(); + let mixnet_listener_task = + AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()).start(); + + let wg_ticket_type = TicketType::V1WireguardExit; + + let mut port_results = HashMap::new(); + let mut can_register = false; + let mut last_error = None; + let max_attempts = 3; + + for attempt in 1..=max_attempts { + if attempt > 1 { + info!("Retrying authenticator registration (attempt {attempt}/{max_attempts})..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + + let credential = match bandwidth_provider + .get_ecash_ticket(wg_ticket_type, exit_node.identity, 1) + .await + { + Ok(ticket) => ticket.data, + Err(e) => { + error!("Failed to get ecash ticket: {e}"); + last_error = Some(format!("Failed to get ecash ticket: {e}")); + break; + } + }; + + let netstack_args = self.config.netstack_args.clone(); + let mut rng = rand::thread_rng(); + let auth_client = AuthenticatorClient::new( + mixnet_listener_task.subscribe(), + mixnet_listener_task.mixnet_sender(), + nym_address, + authenticator, + exit_node.authenticator_version, + Arc::new(x25519::KeyPair::new(&mut rng)), + ip_address, + ); + + match wg_probe( + auth_client, + ip_address, + exit_node.authenticator_version, + self.config.amnezia_args.clone(), + netstack_args, + true, // port_check_only + credential, + ) + .await + { + Ok(outcome) => { + if outcome.can_register { + can_register = true; + port_results = outcome.port_check_results.unwrap_or_default(); + let open = port_results.values().filter(|&&v| v).count(); + info!( + "Port check complete: {}/{} ports open", + open, + port_results.len() + ); + break; + } + warn!( + "Auth registration returned but can_register=false (attempt {attempt}/{max_attempts})" + ); + last_error = Some("Auth registration did not complete".into()); + } + Err(e) => { + warn!("WG probe error: {e} (attempt {attempt}/{max_attempts})"); + last_error = Some(format!("WG probe error: {e}")); + } + } + } + + mixnet_listener_task.stop().await; + + Ok(PortCheckResult { + gateway: exit_identity, + can_register, + port_check_target, + ports: port_results, + error: if can_register { None } else { last_error }, + }) + } + #[allow(clippy::too_many_arguments)] pub async fn do_probe_test( self, @@ -356,6 +569,7 @@ impl Probe { let mixnet_listener_task = AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()) .start(); + let mut rng = rand::thread_rng(); let auth_client = AuthenticatorClient::new( mixnet_listener_task.subscribe(), @@ -384,6 +598,7 @@ impl Probe { exit_node.authenticator_version, self.config.amnezia_args.clone(), self.config.netstack_args.clone(), + false, credential, ) .await diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index d1c15e22b2..9ba0937bce 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -5,8 +5,9 @@ use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; use nym_gateway_probe::config::{CredentialArgs, CredentialMode, ProbeConfig}; -use nym_gateway_probe::{NymApiDirectory, ProbeResult, query_gateway_by_ip}; +use nym_gateway_probe::{NymApiDirectory, PortCheckResult, ProbeResult, query_gateway_by_ip}; use nym_sdk::mixnet::NodeIdentity; +use serde::Serialize; use std::path::Path; use std::{path::PathBuf, sync::OnceLock}; use tracing::*; @@ -81,6 +82,35 @@ enum Commands { probe_config: ProbeConfig, }, + /// Check WG exit policy ports on a bonded gateway. + /// Tests TCP connectivity through the WG tunnel for each port. + /// Use --check-ports to pick specific ports, or --check-all-ports for the full exit policy list. + RunPorts { + /// Directory for credential and mixnet storage + #[arg(long)] + config_dir: Option, + + /// Gateway to test (used as both entry and exit unless --exit-gateway is set) + #[arg(long, short = 'g', alias = "gateway")] + entry_gateway: NodeIdentity, + + /// Separate exit gateway to test (entry_gateway is used for mixnet entry) + #[arg(long)] + exit_gateway: Option, + + /// Test every port in the canonical exit policy (network-tunnel-manager.sh PORT_MAPPINGS). + /// Overrides --check-ports. + #[arg(long)] + check_all_ports: bool, + + /// 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. @@ -96,6 +126,14 @@ enum Commands { }, } +/// CLI output wrapper — either a standard probe result or a port-check result +#[derive(Serialize)] +#[serde(untagged)] +pub(crate) enum ProbeOutput { + Standard(ProbeResult), + PortCheck(PortCheckResult), +} + fn setup_logging() { // SAFETY: those are valid directives #[allow(clippy::unwrap_used)] @@ -112,7 +150,7 @@ fn setup_logging() { .init(); } -pub(crate) async fn run() -> anyhow::Result { +pub(crate) async fn run() -> anyhow::Result { let args = CliArgs::parse(); if !args.no_log { setup_logging(); @@ -165,7 +203,9 @@ pub(crate) async fn run() -> anyhow::Result { 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 + Box::pin(trial.probe_run_locally(&config_dir, credential_mode)) + .await + .map(ProbeOutput::Standard) } Commands::Run { entry_gateway, @@ -209,7 +249,68 @@ pub(crate) async fn run() -> anyhow::Result { let trial = nym_gateway_probe::Probe::new(entry_details, exit_details, network, probe_config); - Box::pin(trial.probe_run(&config_dir, credential_mode)).await + Box::pin(trial.probe_run(&config_dir, credential_mode)) + .await + .map(ProbeOutput::Standard) + } + Commands::RunPorts { + entry_gateway, + exit_gateway, + config_dir, + check_all_ports, + credential_mode, + mut probe_config, + } => { + // --check-all-ports overrides --check-ports with the full exit policy list + if check_all_ports { + use nym_gateway_probe::config::EXIT_POLICY_PORTS; + probe_config.netstack_args.port_check_ports = EXIT_POLICY_PORTS.to_vec(); + info!( + "Using full exit policy port list ({} ports)", + EXIT_POLICY_PORTS.len() + ); + } + + 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()?; + + let exit_details = exit_gateway + .map(|id_key| directory.exit_gateway(&id_key)) + .transpose()? + .map(|node| node.to_testable_node()) + .transpose()?; + + 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() + ); + + let trial = + nym_gateway_probe::Probe::new(entry_details, exit_details, network, probe_config); + Box::pin(trial.probe_run_ports(&config_dir, credential_mode)) + .await + .map(ProbeOutput::PortCheck) } Commands::RunAgent { entry_gateway, From 69964374249ed563e6e5e79acea288b007e489fa Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Wed, 18 Feb 2026 17:37:45 +0100 Subject: [PATCH 2/4] add no-log to anywhere --- nym-gateway-probe/src/run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 9ba0937bce..97f92291e1 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -29,7 +29,7 @@ struct CliArgs { config_env_file: Option, /// Disable logging during probe - #[arg(long)] + #[arg(long, global = true)] no_log: bool, } From 67a858f539a246f069f618c79bc58ed4cda8c7b9 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Wed, 11 Mar 2026 09:38:34 +0100 Subject: [PATCH 3/4] add support for not registered nodes ... --- nym-gateway-probe/netstack_ping/lib.go | 2 +- nym-gateway-probe/src/common/nodes.rs | 50 +++++++ nym-gateway-probe/src/config/netstack.rs | 5 +- nym-gateway-probe/src/lib.rs | 165 ++++++++++++++++++++--- nym-gateway-probe/src/run.rs | 163 +++++++++++++++++++--- 5 files changed, 348 insertions(+), 37 deletions(-) diff --git a/nym-gateway-probe/netstack_ping/lib.go b/nym-gateway-probe/netstack_ping/lib.go index 8ab6b371ce..9beb635d0c 100644 --- a/nym-gateway-probe/netstack_ping/lib.go +++ b/nym-gateway-probe/netstack_ping/lib.go @@ -618,7 +618,7 @@ func checkPorts(target string, ports []uint16, timeoutSec uint64, tnet *netstack // testing all 114 ports in one burst trips it after ~29 connections. // small batches with a cooldown in between keep us under the limit. const batchSize = 20 - const batchDelay = 45 * time.Second + const batchDelay = 25 * time.Second const dialGap = 200 * time.Millisecond results := make(map[string]bool) diff --git a/nym-gateway-probe/src/common/nodes.rs b/nym-gateway-probe/src/common/nodes.rs index c36daff81b..240c165db1 100644 --- a/nym-gateway-probe/src/common/nodes.rs +++ b/nym-gateway-probe/src/common/nodes.rs @@ -16,6 +16,7 @@ use nym_lp::packet::version; use nym_lp::peer::{DHPublicKey, LpRemotePeer}; use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; use nym_node_requests::api::client::NymNodeApiClientExt; +use nym_node_requests::api::v1::network_requester::exit_policy::models::UsedExitPolicy; use nym_node_requests::api::v1::node::models::AuxiliaryDetails as NodeAuxiliaryDetails; use nym_sdk::mixnet::NodeIdentity; use nym_sdk::mixnet::Recipient; @@ -361,6 +362,55 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result anyhow::Result { + let addresses_to_try = if address.contains(':') { + vec![format!("http://{address}"), format!("https://{address}")] + } else { + vec![ + format!("http://{address}:{DEFAULT_NYM_NODE_HTTP_PORT}"), + format!("https://{address}"), + format!("http://{address}"), + ] + }; + + let user_agent: UserAgent = nym_bin_common::bin_info_local_vergen!().into(); + let mut last_error = None; + + for base in addresses_to_try { + let client = match nym_node_requests::api::Client::builder(base.clone()) { + Ok(builder) => match builder + .with_timeout(Duration::from_secs(5)) + .no_hickory_dns() + .with_user_agent(user_agent.clone()) + .build() + { + Ok(c) => c, + Err(e) => { + warn!("Failed to build client for {}: {}", base, e); + last_error = Some(e.into()); + continue; + } + }, + Err(e) => { + warn!("Failed to create client builder for {}: {}", base, e); + last_error = Some(e.into()); + continue; + } + }; + + match client.get_exit_policy().await { + Ok(policy) => return Ok(policy), + Err(e) => { + debug!("Failed to query exit policy at {}: {}", base, e); + last_error = Some(e.into()); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Failed to query exit policy at {}", address))) +} + pub struct NymApiDirectory { // nodes: HashMap, nodes: HashMap, diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs index 433d3a03bd..9c42880e4f 100644 --- a/nym-gateway-probe/src/config/netstack.rs +++ b/nym-gateway-probe/src/config/netstack.rs @@ -47,12 +47,11 @@ pub struct NetstackArgs { #[arg(long = "use-target", default_value = "portquiz.net")] pub port_check_target: String, - /// TCP ports to check through the WireGuard tunnel for exit policy verification. - /// Only used with the `run-ports` subcommand. For all exit policy ports, use --check-all-ports instead. + /// List ports to check, separated by a comma. #[arg(long = "check-ports", value_delimiter = ',', default_values_t = Vec::::new())] pub port_check_ports: Vec, - /// Timeout in seconds for each individual TCP port check + /// Timeout in seconds for each individual port check attempt #[arg(long, default_value_t = 5)] pub port_check_timeout_sec: u64, } diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index df8c2e1e04..0d8e003b42 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -8,7 +8,7 @@ use crate::common::probe_tests::{ do_ping, do_socks5_connectivity_test, lp_registration_probe, wg_probe, }; use crate::common::types::{Entry, LpProbeResults}; -use crate::config::{CredentialArgs, CredentialMode, ProbeConfig}; +use crate::config::{CredentialArgs, CredentialMode, NetstackArgs, ProbeConfig}; use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; use nym_bandwidth_controller::BandwidthTicketProvider; use nym_client_core::config::ForgetMe; @@ -21,6 +21,7 @@ use nym_sdk::mixnet::{ use nym_topology::{HardcodedTopologyProvider, NymTopology}; use rand::rngs::OsRng; use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -46,7 +47,47 @@ pub struct Probe { topology: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectPortCheckProtocol { + Auto, + Tcp, + Udp, +} + +#[derive(Debug, Clone)] +pub struct RunPortsConfig { + pub min_gateway_mixnet_performance: Option, + pub ignore_egress_epoch_role: bool, + pub netstack_args: NetstackArgs, +} + impl Probe { + async fn check_tcp_socket(socket: SocketAddr, timeout_duration: std::time::Duration) -> bool { + matches!( + tokio::time::timeout(timeout_duration, tokio::net::TcpStream::connect(socket)).await, + Ok(Ok(_)) + ) + } + + async fn check_udp_socket(socket: SocketAddr, timeout_duration: std::time::Duration) -> bool { + let Ok(udp_socket) = tokio::net::UdpSocket::bind("0.0.0.0:0").await else { + return false; + }; + if udp_socket.connect(socket).await.is_err() { + return false; + } + if udp_socket.send(&[0]).await.is_err() { + return false; + } + + let mut recv_buf = [0u8; 1]; + match tokio::time::timeout(timeout_duration, udp_socket.recv(&mut recv_buf)).await { + Ok(Ok(_)) => true, + Ok(Err(_)) => false, + Err(_) => true, + } + } + /// Create a probe with pre-queried gateway nodes pub fn new( entry_node: TestedNodeDetails, @@ -258,12 +299,15 @@ impl Probe { } /// Run a port-check probe against the exit gateway's WG exit policy - pub async fn probe_run_ports( - mut self, + pub async fn run_ports_bonded( + entry_node: TestedNodeDetails, + exit_node: Option, + network: NymNetworkDetails, + config: &RunPortsConfig, config_dir: &PathBuf, credential: CredentialMode, ) -> anyhow::Result { - let exit_node = self.exit_node.take().unwrap_or(self.entry_node.clone()); + let exit_node = exit_node.unwrap_or(entry_node.clone()); let exit_identity = exit_node.identity.to_string(); // need authenticator + IP to be a functional exit @@ -278,8 +322,8 @@ impl Probe { } }; - let ports = self.config.netstack_args.port_check_ports.clone(); - let port_check_target = self.config.netstack_args.port_check_target.clone(); + let ports = config.netstack_args.port_check_ports.clone(); + let port_check_target = config.netstack_args.port_check_target.clone(); if ports.is_empty() { anyhow::bail!( @@ -301,23 +345,23 @@ impl Probe { .await?; let mixnet_debug_config = helpers::mixnet_debug_config( - self.config.min_gateway_mixnet_performance, - self.config.ignore_egress_epoch_role, + config.min_gateway_mixnet_performance, + config.ignore_egress_epoch_role, ); - self.topology = helpers::fetch_topology(&self.network, &mixnet_debug_config) + let topology = helpers::fetch_topology(&network, &mixnet_debug_config) .await .inspect_err(|e| warn!("Failed to fetch topology: {e}")) .ok(); let mut mixnet_client_builder = MixnetClientBuilder::new_with_storage(storage.clone()) - .request_gateway(self.entry_node.identity.to_string()) - .network_details(self.network.clone()) + .request_gateway(entry_node.identity.to_string()) + .network_details(network.clone()) .debug_config(mixnet_debug_config) .with_forget_me(ForgetMe::new_stats()) .credentials_mode(!credential.use_mock_ecash); - if let Some(topology) = &self.topology { + if let Some(topology) = &topology { mixnet_client_builder = mixnet_client_builder.custom_topology_provider(Box::new( HardcodedTopologyProvider::new(topology.clone()), )); @@ -339,7 +383,7 @@ impl Probe { .await?; let bandwidth_provider = build_bandwidth_controller( - &self.network, + &network, storage.credential_store().clone(), credential.use_mock_ecash, )?; @@ -348,7 +392,7 @@ impl Probe { Ok(client) => { info!( "Connected to mixnet via entry gateway: {}", - self.entry_node.identity + entry_node.identity ); info!("Our nym address: {}", *client.nym_address()); client @@ -411,7 +455,7 @@ impl Probe { } }; - let netstack_args = self.config.netstack_args.clone(); + let netstack_args = config.netstack_args.clone(); let mut rng = rand::thread_rng(); let auth_client = AuthenticatorClient::new( mixnet_listener_task.subscribe(), @@ -427,7 +471,7 @@ impl Probe { auth_client, ip_address, exit_node.authenticator_version, - self.config.amnezia_args.clone(), + None, netstack_args, true, // port_check_only credential, @@ -469,6 +513,95 @@ impl Probe { }) } + /// Run a direct TCP port check against an IP-only target gateway. + /// This bypasses mixnet and auth registration. It is intended for unannounced/local gateways + pub async fn probe_run_ports_direct_ip( + gateway_address: &str, + target_ip: IpAddr, + config: &RunPortsConfig, + protocol: DirectPortCheckProtocol, + ) -> anyhow::Result { + let ports = config.netstack_args.port_check_ports.clone(); + if ports.is_empty() { + anyhow::bail!( + "No ports specified. Use --check-ports 80,443,22021 or --check-all-ports" + ); + } + + // Preferred truth source: gateway-declared exit policy. + if let Ok(used_policy) = + crate::common::nodes::query_exit_policy_by_ip(gateway_address).await + { + if used_policy.enabled { + if let Some(policy) = used_policy.policy { + let mut policy_results = HashMap::with_capacity(ports.len()); + let v4_probe = std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 1, 1, 1)); + let v6_probe = std::net::IpAddr::V6(std::net::Ipv6Addr::new( + 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111, + )); + + for port in &ports { + let allowed_v4 = policy.allows(&v4_probe, *port).unwrap_or(false); + let allowed_v6 = policy.allows(&v6_probe, *port).unwrap_or(false); + policy_results.insert(port.to_string(), allowed_v4 || allowed_v6); + } + + return Ok(PortCheckResult { + gateway: gateway_address.to_string(), + can_register: true, + port_check_target: gateway_address.to_string(), + ports: policy_results, + error: None, + }); + } + } + } + + let timeout_duration = + std::time::Duration::from_secs(config.netstack_args.port_check_timeout_sec); + let mut port_results = HashMap::with_capacity(ports.len()); + + info!( + "Direct {:?} port check: testing {} ports on {} (timeout {}s per port)", + protocol, + ports.len(), + target_ip, + config.netstack_args.port_check_timeout_sec + ); + + for port in ports { + let socket = SocketAddr::new(target_ip, port); + let is_open = match protocol { + DirectPortCheckProtocol::Auto => { + Probe::check_tcp_socket(socket, timeout_duration).await + || Probe::check_udp_socket(socket, timeout_duration).await + } + DirectPortCheckProtocol::Tcp => { + Probe::check_tcp_socket(socket, timeout_duration).await + } + DirectPortCheckProtocol::Udp => { + Probe::check_udp_socket(socket, timeout_duration).await + } + }; + port_results.insert(port.to_string(), is_open); + } + + let open = port_results.values().filter(|&&is_open| is_open).count(); + info!( + "Direct port check complete: {}/{} ports reachable", + open, + port_results.len() + ); + + Ok(PortCheckResult { + gateway: gateway_address.to_string(), + can_register: true, + port_check_target: gateway_address.to_string(), + ports: port_results, + error: None, + }) + } + #[allow(clippy::too_many_arguments)] pub async fn do_probe_test( self, diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 97f92291e1..6614e87302 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -1,11 +1,14 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use clap::{Parser, Subcommand}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; -use nym_gateway_probe::config::{CredentialArgs, CredentialMode, ProbeConfig}; -use nym_gateway_probe::{NymApiDirectory, PortCheckResult, ProbeResult, query_gateway_by_ip}; +use nym_gateway_probe::config::{CredentialArgs, CredentialMode, NetstackArgs, ProbeConfig}; +use nym_gateway_probe::{ + DirectPortCheckProtocol, NymApiDirectory, PortCheckResult, ProbeResult, RunPortsConfig, + query_gateway_by_ip, +}; use nym_sdk::mixnet::NodeIdentity; use serde::Serialize; use std::path::Path; @@ -90,12 +93,20 @@ enum Commands { #[arg(long)] config_dir: Option, - /// Gateway to test (used as both entry and exit unless --exit-gateway is set) - #[arg(long, short = 'g', alias = "gateway")] - entry_gateway: NodeIdentity, + /// Bonded gateway identity. + /// Cannot be used with --gateway-ip. + #[arg(long, short = 'g', alias = "gateway", conflicts_with = "gateway_ip")] + entry_gateway: Option, + + /// Gateway queried directly by IP address (unannounced/local gateways) + /// Cannot be used with --gateway/--entry-gateway. + /// Cannot be used with --mnemonic or --use-mock-ecash. + #[arg(long, conflicts_with = "entry_gateway", conflicts_with = "gateway")] + gateway_ip: Option, /// Separate exit gateway to test (entry_gateway is used for mixnet entry) - #[arg(long)] + /// Cannot be used with --gateway-ip. + #[arg(long, conflicts_with = "gateway_ip")] exit_gateway: Option, /// Test every port in the canonical exit policy (network-tunnel-manager.sh PORT_MAPPINGS). @@ -103,12 +114,17 @@ enum Commands { #[arg(long)] check_all_ports: bool, - /// Arguments to manage credentials + /// Specify the protocol used for tests with --gateway-ip. + #[arg(long, value_enum, default_value_t = PortCheckProtocol::Auto)] + check_protocol: PortCheckProtocol, + + /// Optional credential arguments. + /// Required only in bonded mode (when using --gateway/--entry-gateway). #[command(flatten)] - credential_mode: CredentialMode, + credential_mode: OptionalCredentialMode, #[command(flatten)] - probe_config: ProbeConfig, + probe_config: RunPortsProbeConfig, }, /// Run the probe by NS agents @@ -126,6 +142,60 @@ enum Commands { }, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum PortCheckProtocol { + Auto, + Tcp, + Udp, +} + +#[derive(Debug, Args, Clone)] +#[command(group( + ArgGroup::new("run_ports_credential_mode") + .args(["use_mock_ecash","mnemonic"]) + .required(false) + .multiple(false) +))] +struct OptionalCredentialMode { + /// Use mock ecash credentials for testing + #[arg(long, action = clap::ArgAction::SetTrue, conflicts_with = "gateway_ip")] + use_mock_ecash: bool, + + /// Mnemonic to get credentials from the blockchain + #[arg(long, conflicts_with = "gateway_ip")] + mnemonic: Option, +} + +impl OptionalCredentialMode { + fn into_required(self) -> anyhow::Result { + if self.use_mock_ecash || self.mnemonic.is_some() { + Ok(CredentialMode { + use_mock_ecash: self.use_mock_ecash, + mnemonic: self.mnemonic, + }) + } else { + anyhow::bail!( + "missing credentials for bonded run-ports mode: provide --mnemonic or --use-mock-ecash" + ) + } + } +} + +#[derive(Debug, Args, Clone)] +struct RunPortsProbeConfig { + /// Only choose gateway with that minimum performance + #[arg(long)] + min_gateway_mixnet_performance: Option, + + /// Ignore egress epoch role constraints + #[arg(long, global = true)] + ignore_egress_epoch_role: bool, + + /// Arguments to manage netstack downloads and port checks + #[command(flatten)] + netstack_args: NetstackArgs, +} + /// CLI output wrapper — either a standard probe result or a port-check result #[derive(Serialize)] #[serde(untagged)] @@ -255,22 +325,71 @@ pub(crate) async fn run() -> anyhow::Result { } Commands::RunPorts { entry_gateway, + gateway_ip, exit_gateway, config_dir, check_all_ports, + check_protocol, credential_mode, - mut probe_config, + probe_config, } => { + let mut run_ports_config = RunPortsConfig { + min_gateway_mixnet_performance: probe_config.min_gateway_mixnet_performance, + ignore_egress_epoch_role: probe_config.ignore_egress_epoch_role, + netstack_args: probe_config.netstack_args, + }; + // --check-all-ports overrides --check-ports with the full exit policy list if check_all_ports { use nym_gateway_probe::config::EXIT_POLICY_PORTS; - probe_config.netstack_args.port_check_ports = EXIT_POLICY_PORTS.to_vec(); + run_ports_config.netstack_args.port_check_ports = EXIT_POLICY_PORTS.to_vec(); info!( "Using full exit policy port list ({} ports)", EXIT_POLICY_PORTS.len() ); } + if let Some(gateway_ip) = gateway_ip { + info!("Using direct IP-only port check mode for gateway: {gateway_ip}"); + if entry_gateway.is_some() { + anyhow::bail!("--gateway/--entry-gateway cannot be used with --gateway-ip"); + } + + let target_ip: std::net::IpAddr = gateway_ip.parse().map_err(|_| { + anyhow::anyhow!( + "invalid --gateway-ip value '{gateway_ip}': expected plain IP address" + ) + })?; + + let direct_protocol = match check_protocol { + PortCheckProtocol::Auto => DirectPortCheckProtocol::Auto, + PortCheckProtocol::Tcp => DirectPortCheckProtocol::Tcp, + PortCheckProtocol::Udp => DirectPortCheckProtocol::Udp, + }; + + return Box::pin(nym_gateway_probe::Probe::probe_run_ports_direct_ip( + &gateway_ip, + target_ip, + &run_ports_config, + direct_protocol, + )) + .await + .map(ProbeOutput::PortCheck); + } + + if check_protocol == PortCheckProtocol::Udp { + anyhow::bail!( + "--check-protocol udp is only supported with --gateway-ip direct mode" + ); + } + if check_protocol == PortCheckProtocol::Auto { + info!( + "Bonded run-ports mode uses TCP checks; treating --check-protocol auto as tcp" + ); + } + + let credential_mode = credential_mode.into_required()?; + let api_url = network .endpoints .first() @@ -278,6 +397,11 @@ pub(crate) async fn run() -> anyhow::Result { .ok_or(anyhow::anyhow!("missing api url"))?; let directory = NymApiDirectory::new(api_url).await?; + let entry_gateway = entry_gateway.ok_or_else(|| { + anyhow::anyhow!( + "missing gateway selection: provide --gateway or --gateway-ip " + ) + })?; let entry_details = directory .entry_gateway(&entry_gateway)? @@ -306,11 +430,16 @@ pub(crate) async fn run() -> anyhow::Result { config_dir.display() ); - let trial = - nym_gateway_probe::Probe::new(entry_details, exit_details, network, probe_config); - Box::pin(trial.probe_run_ports(&config_dir, credential_mode)) - .await - .map(ProbeOutput::PortCheck) + Box::pin(nym_gateway_probe::Probe::run_ports_bonded( + entry_details, + exit_details, + network, + &run_ports_config, + &config_dir, + credential_mode, + )) + .await + .map(ProbeOutput::PortCheck) } Commands::RunAgent { entry_gateway, From 0789f55bd7bf1a25fd9fbb69e05e7bf360b29ba0 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Mon, 13 Apr 2026 13:24:25 +0200 Subject: [PATCH 4/4] address comments --- nym-gateway-probe/Cargo.toml | 2 +- nym-gateway-probe/src/common/wireguard.rs | 1 - nym-gateway-probe/src/config/netstack.rs | 5 ++++- nym-gateway-probe/src/run.rs | 6 ++++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 684f54a288..ca423624ad 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -17,7 +17,7 @@ anyhow = { workspace = true } base64 = { workspace = true } bs58 = { workspace = true } bytes = { workspace = true } -clap = { workspace = true, features = ["cargo", "derive"] } +clap = { workspace = true, features = ["cargo", "derive", "env"] } futures = { workspace = true } hex = { workspace = true } tracing = { workspace = true } diff --git a/nym-gateway-probe/src/common/wireguard.rs b/nym-gateway-probe/src/common/wireguard.rs index 740f32700a..a2cb783433 100644 --- a/nym-gateway-probe/src/common/wireguard.rs +++ b/nym-gateway-probe/src/common/wireguard.rs @@ -12,7 +12,6 @@ use tracing::{error, info}; use crate::NetstackArgs; use crate::common::netstack::{NetstackRequest, NetstackRequestGo, NetstackResult}; use crate::common::types::WgProbeResults; -use crate::config::NetstackArgs; /// Safe division that returns 0.0 when divisor is 0 (instead of NaN/Inf) fn safe_ratio(received: u16, sent: u16) -> f32 { diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs index 9c42880e4f..3479b42274 100644 --- a/nym-gateway-probe/src/config/netstack.rs +++ b/nym-gateway-probe/src/config/netstack.rs @@ -52,7 +52,7 @@ pub struct NetstackArgs { pub port_check_ports: Vec, /// Timeout in seconds for each individual port check attempt - #[arg(long, default_value_t = 5)] + #[arg(long, default_value_t = NetstackArgs::default().port_check_timeout_sec)] pub port_check_timeout_sec: u64, } @@ -74,6 +74,9 @@ impl Default for NetstackArgs { "2606:4700:4700::1111".to_string(), "2620:fe::fe".to_string(), ], + port_check_target: "portquiz.net".to_string(), + port_check_ports: vec![], + port_check_timeout_sec: 5, } } } diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 6614e87302..8f6535e6ca 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -230,7 +230,7 @@ pub(crate) async fn run() -> anyhow::Result { setup_env(args.config_env_file.as_ref()); let network = nym_sdk::NymNetworkDetails::new_from_env(); - info!("{:#?}", network); + debug!("{:#?}", network); match args.command { Commands::RunLocal { @@ -449,7 +449,9 @@ pub(crate) async fn run() -> anyhow::Result { let trial = nym_gateway_probe::Probe::new_for_agent(entry_gateway, network, probe_config) .await?; - Box::pin(trial.probe_run_agent(credential_args)).await + Box::pin(trial.probe_run_agent(credential_args)) + .await + .map(ProbeOutput::Standard) } } }