Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f0baf78c | |||
| f90ee91c98 | |||
| 7624b51b9c | |||
| 4ac11b9ef4 | |||
| 7df8ff4506 | |||
| e787c19233 | |||
| 0789f55bd7 | |||
| 67a858f539 | |||
| 6996437424 | |||
| 1fabec4cd9 |
@@ -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 }
|
||||
|
||||
@@ -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<u16, String> = 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::<u16>(), parts[1].parse::<u16>()) {
|
||||
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::<u16>() {
|
||||
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";
|
||||
|
||||
|
||||
@@ -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 = 25 * 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
|
||||
|
||||
@@ -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<u16>,
|
||||
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<u16>,
|
||||
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<HashMap<String, bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
|
||||
@@ -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<DirectoryNod
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("Failed to connect to gateway at {}", address)))
|
||||
}
|
||||
|
||||
/// Query only the exit policy from a gateway HTTP API by address.
|
||||
pub async fn query_exit_policy_by_ip(address: &str) -> anyhow::Result<UsedExitPolicy> {
|
||||
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<NodeIdentity, DescribedNodeWithPerformance>,
|
||||
nodes: HashMap<NodeIdentity, DirectoryNode>,
|
||||
|
||||
@@ -46,6 +46,7 @@ pub async fn wg_probe(
|
||||
auth_version: AuthenticatorVersion,
|
||||
awg_args: Option<String>,
|
||||
netstack_args: NetstackArgs,
|
||||
port_check_only: bool,
|
||||
// TODO: update type
|
||||
credential: CredentialSpendingData,
|
||||
) -> anyhow::Result<WgProbeResults> {
|
||||
@@ -149,6 +150,7 @@ pub async fn wg_probe(
|
||||
&tunnel_config,
|
||||
&netstack_args,
|
||||
&awg_args.unwrap_or_default(),
|
||||
port_check_only,
|
||||
&mut wg_outcome,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<HashMap<String, bool>>,
|
||||
}
|
||||
|
||||
#[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<String, bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IpPingReplies {
|
||||
pub ipr_tun_ip_v4: bool,
|
||||
|
||||
@@ -63,6 +63,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 +72,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 +80,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 +94,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 +126,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 +140,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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,18 @@ 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<String>,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// List ports to check, separated by a comma.
|
||||
#[arg(long = "check-ports", value_delimiter = ',', default_values_t = Vec::<u16>::new())]
|
||||
pub port_check_ports: Vec<u16>,
|
||||
|
||||
/// Timeout in seconds for each individual port check attempt
|
||||
#[arg(long, default_value_t = NetstackArgs::default().port_check_timeout_sec)]
|
||||
pub port_check_timeout_sec: u64,
|
||||
}
|
||||
|
||||
impl Default for NetstackArgs {
|
||||
@@ -57,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +107,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 +134,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 +156,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 +194,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 +226,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 +256,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 +268,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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -20,13 +20,15 @@ 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;
|
||||
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;
|
||||
@@ -45,7 +47,47 @@ pub struct Probe {
|
||||
topology: Option<NymTopology>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirectPortCheckProtocol {
|
||||
Auto,
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunPortsConfig {
|
||||
pub min_gateway_mixnet_performance: Option<u8>,
|
||||
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,
|
||||
@@ -256,6 +298,310 @@ 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 run_ports_bonded(
|
||||
entry_node: TestedNodeDetails,
|
||||
exit_node: Option<TestedNodeDetails>,
|
||||
network: NymNetworkDetails,
|
||||
config: &RunPortsConfig,
|
||||
config_dir: &PathBuf,
|
||||
credential: CredentialMode,
|
||||
) -> anyhow::Result<PortCheckResult> {
|
||||
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
|
||||
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 = config.netstack_args.port_check_ports.clone();
|
||||
let port_check_target = 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(
|
||||
config.min_gateway_mixnet_performance,
|
||||
config.ignore_egress_epoch_role,
|
||||
);
|
||||
|
||||
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(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) = &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(
|
||||
&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: {}",
|
||||
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 = 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,
|
||||
None,
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<PortCheckResult> {
|
||||
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,
|
||||
@@ -356,6 +702,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 +731,7 @@ impl Probe {
|
||||
exit_node.authenticator_version,
|
||||
self.config.amnezia_args.clone(),
|
||||
self.config.netstack_args.clone(),
|
||||
false,
|
||||
credential,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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, 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;
|
||||
use std::{path::PathBuf, sync::OnceLock};
|
||||
use tracing::*;
|
||||
@@ -28,7 +32,7 @@ struct CliArgs {
|
||||
config_env_file: Option<PathBuf>,
|
||||
|
||||
/// Disable logging during probe
|
||||
#[arg(long)]
|
||||
#[arg(long, global = true)]
|
||||
no_log: bool,
|
||||
}
|
||||
|
||||
@@ -81,6 +85,48 @@ 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<PathBuf>,
|
||||
|
||||
/// Bonded gateway identity.
|
||||
/// Cannot be used with --gateway-ip.
|
||||
#[arg(long, short = 'g', alias = "gateway", conflicts_with = "gateway_ip")]
|
||||
entry_gateway: Option<NodeIdentity>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Separate exit gateway to test (entry_gateway is used for mixnet entry)
|
||||
/// Cannot be used with --gateway-ip.
|
||||
#[arg(long, conflicts_with = "gateway_ip")]
|
||||
exit_gateway: Option<NodeIdentity>,
|
||||
|
||||
/// Test every port in the canonical exit policy (network-tunnel-manager.sh PORT_MAPPINGS).
|
||||
/// Overrides --check-ports.
|
||||
#[arg(long)]
|
||||
check_all_ports: bool,
|
||||
|
||||
/// 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: OptionalCredentialMode,
|
||||
|
||||
#[command(flatten)]
|
||||
probe_config: RunPortsProbeConfig,
|
||||
},
|
||||
|
||||
/// Run the probe by NS agents
|
||||
RunAgent {
|
||||
/// The specific gateway specified by ID.
|
||||
@@ -96,6 +142,68 @@ 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<String>,
|
||||
}
|
||||
|
||||
impl OptionalCredentialMode {
|
||||
fn into_required(self) -> anyhow::Result<CredentialMode> {
|
||||
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 <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<u8>,
|
||||
|
||||
/// 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)]
|
||||
pub(crate) enum ProbeOutput {
|
||||
Standard(ProbeResult),
|
||||
PortCheck(PortCheckResult),
|
||||
}
|
||||
|
||||
fn setup_logging() {
|
||||
// SAFETY: those are valid directives
|
||||
#[allow(clippy::unwrap_used)]
|
||||
@@ -112,7 +220,7 @@ fn setup_logging() {
|
||||
.init();
|
||||
}
|
||||
|
||||
pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
|
||||
pub(crate) async fn run() -> anyhow::Result<ProbeOutput> {
|
||||
let args = CliArgs::parse();
|
||||
if !args.no_log {
|
||||
setup_logging();
|
||||
@@ -122,7 +230,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
|
||||
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 {
|
||||
@@ -165,7 +273,9 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
|
||||
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 +319,127 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
|
||||
|
||||
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,
|
||||
gateway_ip,
|
||||
exit_gateway,
|
||||
config_dir,
|
||||
check_all_ports,
|
||||
check_protocol,
|
||||
credential_mode,
|
||||
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;
|
||||
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()
|
||||
.and_then(|ep| ep.api_url())
|
||||
.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 <ID> or --gateway-ip <IP[:PORT]>"
|
||||
)
|
||||
})?;
|
||||
|
||||
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()
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -219,7 +449,9 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user