Add socks5 test to gateway-probe (#6393)

* Socks5 in GW probe

Bump NS agent version

Fix bugs
- force route construction
- use same entry = exit

Fix NS API version check workflow

PR feedback

More robust test attempts

CLI arg validation

Fix clippy

PR feedback

* Test provided endpoints in config at startup

Require one valid endpoint

* Bump agent to 1.1.0
This commit is contained in:
dynco-nym
2026-01-29 18:20:51 +01:00
committed by GitHub
parent f4ba8ac2b3
commit 9a931b9251
46 changed files with 914 additions and 529 deletions
@@ -3,7 +3,7 @@ name: ci-check-ns-api-version
on:
pull_request:
paths:
- "nym-node-status-api/**"
- "nym-node-status-api/nym-node-status-api/**"
env:
WORKING_DIRECTORY: "nym-node-status-api/nym-node-status-api"
Generated
+2 -1
View File
@@ -6489,6 +6489,7 @@ dependencies = [
"nym-validator-client",
"pnet_packet",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -7234,7 +7235,7 @@ dependencies = [
[[package]]
name = "nym-node-status-agent"
version = "1.0.7"
version = "1.1.0"
dependencies = [
"anyhow",
"clap",
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM expiration_date_signatures\n WHERE expiration_date = ?\n ",
"describe": {
"columns": [
{
"name": "epoch_id: u32",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "serialised_signatures",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "serialization_revision: u8",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "00d857b624e7edab1198114b17cbad1e16988a3f9989d135840500e1143ce5e5"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT timestamp, reliability as \"reliability: u8\"\n FROM gateway_status\n JOIN gateway_details\n ON gateway_status.gateway_details_id = gateway_details.id\n WHERE gateway_details.node_id=? AND gateway_status.timestamp > ?;\n ",
"describe": {
"columns": [
{
"name": "timestamp",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "reliability: u8",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
true
]
},
"hash": "1d4535b58abdefaaca96bc7312fe14f63ccb56fa62976f7ce3d3b4f6eca8b711"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM emergency_credential\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM emergency_credential\n WHERE type = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM expiration_date_signatures\n WHERE expiration_date = ? AND epoch_id = ?\n ",
"describe": {
"columns": [
{
"name": "serialised_signatures",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "serialization_revision: u8",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n d.node_id as \"node_id: NodeId\",\n CASE WHEN count(*) > 3 THEN AVG(reliability) ELSE 100 END as \"value: f32\"\n FROM\n gateway_details d\n JOIN\n gateway_status s on d.id = s.gateway_details_id\n WHERE\n timestamp >= ? AND\n timestamp <= ?\n GROUP BY 1\n ",
"describe": {
"columns": [
{
"name": "node_id: NodeId",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "value: f32",
"ordinal": 1,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
null
]
},
"hash": "676299beb2004ab89f7b38cf21ffb84ab5e7d7435297573523e2532560c2e302"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures\n FROM global_expiration_date_signatures\n WHERE expiration_date = ?\n ",
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures\n FROM global_expiration_date_signatures\n WHERE expiration_date = ? AND epoch_id = ?\n ",
"describe": {
"columns": [
{
@@ -15,12 +15,12 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "5eb13bfbee53b50641f69d4d6b62383c7f43864bffe98642bb8d1cf7c259d7be"
"hash": "697e6d738aecf115a3608139579b2d1d937dd4cc4778dfb42709adf08c1497f2"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT timestamp, reliability as \"reliability: u8\"\n FROM mixnode_status\n JOIN mixnode_details\n ON mixnode_status.mixnode_details_id = mixnode_details.id\n WHERE mixnode_details.mix_id=? AND mixnode_status.timestamp > ?;\n ",
"describe": {
"columns": [
{
"name": "timestamp",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "reliability: u8",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
true
]
},
"hash": "73ca856950a0157acfd3e2ed07b11aca3d875f67c77e2e7c75653c3f337d594e"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures\n FROM partial_expiration_date_signatures\n WHERE expiration_date = ?\n ",
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures\n FROM partial_expiration_date_signatures\n WHERE expiration_date = ? AND epoch_id = ?\n ",
"describe": {
"columns": [
{
@@ -15,12 +15,12 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "1f72d6f538a3655a031a3a8706794559c4c0df6defdfd179c84d02d3b8a6c055"
"hash": "7e1829a17b81d7ee3dd8134b7a2be38ffdcaa672afbae44993365ca6910f68f6"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO emergency_credential\n (type, content, expiration)\n VALUES (?, ?, ?)\n ON CONFLICT(type, content) DO NOTHING;\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT identity FROM gateway_details WHERE node_id = ?",
"describe": {
"columns": [
{
"name": "identity",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "924f8eb10c6cbb7f35da6c1bb77e1025442a594dcb5c6401b3dfac7df9c25073"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n d.mix_id as \"mix_id: NodeId\",\n AVG(s.reliability) as \"value: f32\"\n FROM\n mixnode_details d\n JOIN\n mixnode_status s on d.id = s.mixnode_details_id\n WHERE\n timestamp >= ? AND\n timestamp <= ?\n GROUP BY 1\n ",
"describe": {
"columns": [
{
"name": "mix_id: NodeId",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "value: f32",
"ordinal": 1,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
null
]
},
"hash": "c19e1b3768bf2929407599e6e8783ead09f4d7319b7997fa2a9bb628f9404166"
}
@@ -1,27 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO transaction\n (hash, height, index, success, messages, memo, signatures, signer_infos, fee, gas_wanted, gas_used, raw_log, logs, events)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ON CONFLICT (hash) DO UPDATE\n SET height = excluded.height,\n index = excluded.index,\n success = excluded.success,\n messages = excluded.messages,\n memo = excluded.memo,\n signatures = excluded.signatures,\n signer_infos = excluded.signer_infos,\n fee = excluded.fee,\n gas_wanted = excluded.gas_wanted,\n gas_used = excluded.gas_used,\n raw_log = excluded.raw_log,\n logs = excluded.logs,\n events = excluded.events\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Int4",
"Bool",
"Jsonb",
"Text",
"TextArray",
"Jsonb",
"Jsonb",
"Int8",
"Int8",
"Text",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "08f4e54ac24fccd54f4208797b3749e457f8cd4ba3d7d906a7ab3bf5b4e7dc9c"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO validator (consensus_address, consensus_pubkey)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0d3709efacf763b06bf14803bb803b5ee5b27879b0026bb0480b3f2722318a75"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM pre_commit WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "1c2fb0e9ffceca21ef8dbea19b116422b1f723d0a316314b50c43c8b29f8891d"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n ORDER BY height ASC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "2561fb016951ea4cd29e43fb9a4a93e944b0d44ed1f7c1036f306e34372da11c"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE metadata SET last_processed_height = GREATEST(last_processed_height, $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "2679cdf11fa66c7920678cde860c57402119ec7c3aae731b0da831327301466f"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE pruning SET last_pruned_height = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "36ba5941aca6e7b604a10b8b0aba70635028f392fe794d6131827b083e1755e1"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT last_pruned_height FROM pruning\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_pruned_height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "3bdf81a9db6075f6f77224c30553f419a849d4ec45af40b052a4cbf09b44f3ec"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM message WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "52c27143720ddfdfd0f5644b60f5b67fd9281ce1de0653efa53b9d9b93cf335d"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO pre_commit (validator_address, height, timestamp, voting_power, proposer_priority)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (validator_address, timestamp) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Timestamp",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "62e14613f5ffe692346a79086857a22f0444fbc679db1c06b651fb8b5538b278"
}
@@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO block (height, hash, num_txs, total_gas, proposer_address, timestamp)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Int4",
"Int8",
"Text",
"Timestamp"
]
},
"nullable": []
},
"hash": "64a484fd46d8ec46797f944a4cced56b6e270ce186f0e49528865d1924343b78"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n WHERE timestamp < $1\n ORDER BY timestamp DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "7e82426f5dbcadf1631ba1a806e19cc462d04222fb20ad76de2a40f3f4f8fe15"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n WHERE timestamp > $1\n ORDER BY timestamp\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "9455331f9be5a3be28e2bd399a36b2e2d6a9ad4b225c4c883aafc4e9f0428008"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) as count FROM pre_commit\n WHERE\n validator_address = $1\n AND height >= $2\n AND height <= $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "bc7795e58ce71893c3f32a19db8e77b7bc0a1af315ffd42c3e68156d6e4ace70"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT * FROM validator\n WHERE EXISTS (\n SELECT 1 FROM pre_commit\n WHERE height = $1\n AND pre_commit.validator_address = validator.consensus_address\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "consensus_address",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "consensus_pubkey",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
},
"hash": "be43d4873911deca784b7be0531ab7bd82ecd68041aa932a56c8ce09623251e4"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT last_processed_height FROM metadata\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_processed_height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c88d07fecc3f33deaa6e93db1469ce71582635df47f52dcf3fd1df4e7be6b96d"
}
@@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO message(transaction_hash, index, type, value, involved_accounts_addresses, height)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (transaction_hash, index) DO UPDATE\n SET height = excluded.height,\n type = excluded.type,\n value = excluded.value,\n involved_accounts_addresses = excluded.involved_accounts_addresses\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Jsonb",
"TextArray",
"Int8"
]
},
"nullable": []
},
"hash": "cc0ae74082d7d8a89f2d3364676890bbf6150ab394c72783114340d4def5f9ef"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM block WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "cdba9b267f143c8a8c6c3d6ed713cf00236490b86779559d84740ec18bcfa3a9"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM transaction WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "d89558c37c51e8e6b1e6a9d5a2b13d0598fd856aa019a0cbbae12d7cafb4672f"
}
+1
View File
@@ -24,6 +24,7 @@ hex.workspace = true
tracing.workspace = true
pnet_packet.workspace = true
rand.workspace = true
reqwest = { workspace = true, features = ["socks"] }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+1
View File
@@ -12,5 +12,6 @@ pub(crate) mod icmp;
pub(crate) mod netstack;
pub(crate) mod nodes;
pub(crate) mod probe_tests;
pub(crate) mod socks5_test;
pub(crate) mod types;
pub(crate) mod wireguard;
+29 -4
View File
@@ -4,8 +4,9 @@
use anyhow::{Context, anyhow, bail};
use nym_api_requests::models::{
AuthenticatorDetailsV2, DeclaredRolesV2, DescribedNodeTypeV2, HostInformationV2,
IpPacketRouterDetailsV2, LewesProtocolDetailsV1, NetworkRequesterDetailsV2, NymNodeDataV2,
OffsetDateTimeJsonSchemaWrapper, WebSocketsV2, WireguardDetailsV2,
IpPacketRouterDetailsV2, LewesProtocolDetailsV1, NetworkRequesterDetailsV1,
NetworkRequesterDetailsV2, NymNodeDataV2, OffsetDateTimeJsonSchemaWrapper, WebSocketsV2,
WireguardDetailsV2,
};
use nym_authenticator_requests::AuthenticatorVersion;
use nym_bin_common::build_information::BinaryBuildInformationOwned;
@@ -161,10 +162,12 @@ impl DirectoryNode {
}),
_ => None,
};
let network_requester_details = self.described.description.network_requester.clone();
Ok(TestedNodeDetails {
identity: self.identity(),
exit_router_address,
network_requester_details,
authenticator_address,
authenticator_version,
ip_address: Some(ip_address),
@@ -409,7 +412,17 @@ impl NymApiDirectory {
.iter()
.filter(|(_, n)| n.described.description.ip_packet_router.is_some())
.choose(&mut rand::thread_rng())
.ok_or(anyhow!("no gateways running IPR available"))
.context("no gateways running IPR available")
.map(|(id, _)| *id)
}
pub fn random_exit_with_nr(&self) -> anyhow::Result<NodeIdentity> {
info!("Selecting random gateway with NR enabled");
self.nodes
.iter()
.filter(|(_, n)| n.described.description.ip_packet_router.is_some())
.choose(&mut rand::thread_rng())
.context("no gateways running NR available")
.map(|(id, _)| *id)
}
@@ -419,7 +432,7 @@ impl NymApiDirectory {
.iter()
.filter(|(_, n)| n.described.description.declared_role.entry)
.choose(&mut rand::thread_rng())
.ok_or(anyhow!("no entry gateways available"))
.context("no entry gateways available")
.map(|(id, _)| *id)
}
@@ -439,6 +452,16 @@ impl NymApiDirectory {
};
Ok(maybe_entry)
}
pub fn exit_gateway_nr(&self, identity: &NodeIdentity) -> anyhow::Result<DirectoryNode> {
let Some(maybe_entry) = self.nodes.get(identity).cloned() else {
bail!("{identity} not found in directory")
};
if !maybe_entry.described.description.declared_role.exit_nr {
bail!("{identity} doesn't support exit NR mode")
};
Ok(maybe_entry)
}
}
#[derive(Default, Debug)]
@@ -468,6 +491,7 @@ impl TestedNode {
pub struct TestedNodeDetails {
pub identity: NodeIdentity,
pub exit_router_address: Option<Recipient>,
pub network_requester_details: Option<NetworkRequesterDetailsV1>,
pub authenticator_address: Option<Recipient>,
pub authenticator_version: AuthenticatorVersion,
pub ip_address: Option<IpAddr>,
@@ -489,6 +513,7 @@ impl TestedNodeDetails {
identity,
ip_address: Some(lp_data.address.ip()),
lp_data: Some(lp_data),
network_requester_details: None,
// These are None in localnet mode - only needed for mixnet/authenticator
exit_router_address: None,
authenticator_address: None,
+187 -2
View File
@@ -1,9 +1,12 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::NymApiDirectory;
use crate::common::helpers::mixnet_debug_config;
use crate::common::nodes::{TestedNodeDetails, TestedNodeLpDetails};
use crate::common::socks5_test::HttpsConnectivityTest;
use crate::common::types::{
Entry, Exit, IpPingReplies, LpProbeResults, ProbeOutcome, WgProbeResults,
Entry, Exit, IpPingReplies, LpProbeResults, ProbeOutcome, Socks5ProbeResults, WgProbeResults,
};
use crate::common::wireguard::{
TwoHopWgTunnelConfig, WgTunnelConfig, run_tunnel_tests, run_two_hop_tunnel_tests,
@@ -27,7 +30,12 @@ use nym_crypto::asymmetric::{ed25519, x25519};
use nym_ip_packet_client::IprClientConnect;
use nym_ip_packet_requests::{IpPair, codec::MultiIpPacketCodec};
use nym_registration_client::{LpRegistrationClient, NestedLpSession};
use nym_sdk::mixnet::{MixnetClient, NodeIdentity, Recipient};
use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder, NodeIdentity, Recipient, Socks5};
use nym_sdk::{
DebugConfig, NymApiTopologyProvider, NymApiTopologyProviderConfig, NymNetworkDetails,
TopologyProvider,
};
use nym_topology::{HardcodedTopologyProvider, NymTopology};
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::Arc,
@@ -36,6 +44,7 @@ use std::{
use tokio::net::TcpStream;
use tokio_util::{codec::Decoder, sync::CancellationToken};
use tracing::*;
use url::Url;
pub async fn wg_probe(
mut auth_client: AuthenticatorClient,
@@ -446,6 +455,7 @@ pub async fn do_ping(
exit_result.map(|exit| ProbeOutcome {
as_entry: entry,
as_exit: exit,
socks5: None,
wg: None,
lp: None,
}),
@@ -609,3 +619,178 @@ pub async fn listen_for_icmp_ping_replies(
can_route_ip_external_v6: registered_replies.external_ip_v6,
}))
}
/// Creates a SOCKS5 proxy connection through the mixnet to the exit GW
/// and performs necessary tests.
#[allow(clippy::too_many_arguments)]
#[instrument(level = "info", name = "socks5_test", skip_all)]
pub(crate) async fn do_socks5_connectivity_test(
network_requester_address: &str,
network_details: NymNetworkDetails,
directory: &NymApiDirectory,
json_rpc_endpoints: Vec<String>,
mixnet_client_timeout: u64,
test_run_count: u64,
failure_count_cutoff: usize,
topology: Option<NymTopology>,
) -> anyhow::Result<Socks5ProbeResults> {
info!(
"Starting SOCKS5 test through Network Requester: {}",
network_requester_address
);
if json_rpc_endpoints.is_empty() {
bail!("You need to define JSON RPC URLs in order to test SOCKS5")
}
// parse the network requester address
let nr_recipient = match network_requester_address.parse::<Recipient>() {
Ok(addr) => addr,
Err(e) => {
error!("Invalid Network Requester address: {}", e);
return Ok(Socks5ProbeResults::error_before_connecting(format!(
"Invalid NR address: {}",
e
)));
}
};
info!(
"Network Requester gateway: {}",
nr_recipient.gateway().to_base58_string()
);
info!(
"Network Requester identity: {}",
nr_recipient.identity().to_base58_string()
);
// create ephemeral SOCKS5 client
let socks5_config = Socks5::new(network_requester_address.to_string());
// since we define both entry & exit gateways to be the same tested GW,
// this shouldn't negatively affect mixnet layers but it will force route
// construction in case GW would get filtered out of topology
let min_gw_performance = Some(0);
// debug config similar to main probe
let debug_config = mixnet_debug_config(min_gw_performance, true);
// Verify the NR gateway exists in the directory with exit_nr role
let nr_gateway_id = nr_recipient.gateway();
if let Err(e) = directory.exit_gateway_nr(&nr_gateway_id) {
return Ok(Socks5ProbeResults::error_before_connecting(e.to_string()));
} else {
info!("✔️ Network Requester gateway found in directory with exit_nr role");
}
// use intended exit as entry as well
let entry_gateway = nr_gateway_id;
// use existing topology if available, otherwise fetch it
let topology_provider: Box<HardcodedTopologyProvider> = match topology {
Some(t) => {
info!("✔️ Reusing topology from main mixnet client");
Box::new(HardcodedTopologyProvider::new(t))
}
None => {
info!("Fetching topology for SOCKS5 client...");
match hardcoded_topology(&network_details, &debug_config).await {
Ok(provider) => provider,
Err(e) => return Ok(Socks5ProbeResults::error_before_connecting(e)),
}
}
};
let socks5_client_builder = MixnetClientBuilder::new_ephemeral()
// Specify entry gateway explicitly
.request_gateway(entry_gateway.to_base58_string())
.socks5_config(socks5_config)
.network_details(network_details)
.debug_config(debug_config)
.custom_topology_provider(topology_provider)
.build()?;
// connect to mixnet via SOCKS5
let socks5_client = match socks5_client_builder.connect_to_mixnet_via_socks5().await {
Ok(client) => {
info!("🌐 Successfully connected to mixnet via SOCKS5 proxy");
info!(
"Connected via entry gateway: {}",
client.nym_address().gateway().to_base58_string()
);
client
}
Err(e) => {
error!("Failed to establish SOCKS5 connection: {}", e);
return Ok(Socks5ProbeResults::error_before_connecting(format!(
"SOCKS5 connection failed: {}",
e
)));
}
};
let test = match HttpsConnectivityTest::new(
test_run_count,
mixnet_client_timeout,
failure_count_cutoff,
json_rpc_endpoints,
socks5_client.socks5_url(),
) {
Ok(test) => test,
Err(err) => {
socks5_client.disconnect().await;
error!("{err}");
return Ok(Socks5ProbeResults::error_after_connecting(
"Failed to create client",
));
}
};
let result = test.run_tests().await;
socks5_client.disconnect().await;
Ok(Socks5ProbeResults::with_http_result(result))
}
async fn hardcoded_topology(
network_details: &NymNetworkDetails,
debug_config: &DebugConfig,
) -> Result<Box<HardcodedTopologyProvider>, String> {
// get Nym API URLs from network_details
let nym_api_urls: Vec<Url> = network_details
.nym_api_urls
.as_ref()
.map(|urls| urls.iter().filter_map(|u| u.url.parse().ok()).collect())
.or_else(|| {
network_details
.endpoints
.first()
.and_then(|e| e.api_url())
.map(|url| vec![url])
})
.unwrap_or_default();
if nym_api_urls.is_empty() {
return Err(String::from("No nym-api URLs available to fetch topology"));
}
let topology_config = NymApiTopologyProviderConfig {
min_mixnode_performance: debug_config.topology.minimum_mixnode_performance,
min_gateway_performance: debug_config.topology.minimum_gateway_performance,
use_extended_topology: debug_config.topology.use_extended_topology,
ignore_egress_epoch_role: debug_config.topology.ignore_egress_epoch_role,
};
let api_client = nym_http_api_client::Client::new_url(nym_api_urls[0].clone(), None)
.map_err(|e| e.to_string())?;
let mut provider = NymApiTopologyProvider::new(topology_config, nym_api_urls, api_client);
match provider.get_new_topology().await {
Some(topology) => {
info!("Fetched network topology");
Ok(Box::new(HardcodedTopologyProvider::new(topology)))
}
None => Err(String::from("Failed to fetch network topology")),
}
}
@@ -0,0 +1,255 @@
use anyhow::bail;
use rand::Rng;
use reqwest::Proxy;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::Instant;
use tracing::{debug, error, info, warn};
use crate::common::socks5_test::SingleHttpsTestResult;
pub struct JsonRpcClient {
client: reqwest::Client,
client_timeout: Duration,
test_endpoints: Vec<String>,
}
impl JsonRpcClient {
pub fn new(
client_timeout: u64,
proxy: Option<Proxy>,
test_endpoints: Vec<String>,
) -> anyhow::Result<Self> {
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(client_timeout));
if let Some(proxy) = proxy {
builder = builder.proxy(proxy);
}
let client = builder.build()?;
Ok(Self {
client_timeout: Duration::from_secs(client_timeout),
test_endpoints,
client,
})
}
pub(super) async fn https_request_with_fallbacks(&self) -> SingleHttpsTestResult {
let mut error_msg = String::new();
// endpoints are used as fallbacks: in case of success, return early
for endpoint in self.test_endpoints.iter() {
info!(
"Testing against {} with timeout {}s",
endpoint,
self.client_timeout.as_secs()
);
let start = Instant::now();
let res = self.eth_chainid(endpoint).await;
let elapsed = start.elapsed();
match res {
Ok((status, JsonRpcResponse::Ok { .. })) => {
debug!(
"HTTPS test completed: status={}, latency={}ms",
status.as_u16(),
elapsed.as_millis()
);
return SingleHttpsTestResult {
success: true,
status_code: Some(status.as_u16()),
latency_ms: Some(elapsed.as_millis() as u64),
endpoint_used: Some(endpoint.to_string()),
error: None,
};
}
Ok((_, JsonRpcResponse::Err { error, .. })) => {
warn!("JSON-RPC error: {} (code: {})", error.message, error.code);
error_msg = format!("JSON-RPC error: {}", error.message);
}
Err(e) => {
error_msg = e.to_string();
error!("{}", &error_msg);
}
}
}
SingleHttpsTestResult {
success: false,
status_code: None,
latency_ms: None,
endpoint_used: self.test_endpoints.last().cloned(),
error: Some(error_msg),
}
}
async fn eth_chainid(
&self,
endpoint: &str,
) -> anyhow::Result<(reqwest::StatusCode, JsonRpcResponse)> {
match self
.client
.post(endpoint)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&JsonRpcRequestBody::eth_chainid())
.send()
.await
.and_then(reqwest::Response::error_for_status)
{
Ok(response) => {
let status = response.status();
if status.is_success() {
// Deserialize body into JsonRpcResponse
response
.json::<JsonRpcResponse>()
.await
.map(|res| (status, res))
.map_err(From::from)
} else {
bail!("HTTP error status: {}", status.as_u16());
}
}
Err(e) => {
error!("HTTPS request failed: {}", e);
bail!("HTTPS request failed: {}", e);
}
}
}
pub async fn ensure_endpoint_works(&self) -> anyhow::Result<()> {
let mut any_works = false;
for endpoint in self.test_endpoints.iter() {
if let Err(err) = self.eth_chainid(endpoint).await {
warn!("Endpoint {endpoint} error: {err}");
} else {
any_works = true;
}
}
if any_works {
Ok(())
} else {
bail!("None of the endpoints are valid, see logs");
}
}
}
/// https://www.jsonrpc.org/specification
#[derive(Serialize)]
struct JsonRpcRequestBody {
// A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".
jsonrpc: String,
method: String,
// A Structured value that holds the parameter values to be used during the invocation of the method. This member MAY be omitted.
params: serde_json::Value,
// The Server MUST reply with the same value in the Response object if included.
// This member is used to correlate the context between the two objects.
id: i64,
}
impl JsonRpcRequestBody {
/// Very simple endpoint that requires no dynamic input
///
/// https://ethereum.org/developers/docs/apis/json-rpc/#eth_chainId
pub fn eth_chainid() -> Self {
Self {
jsonrpc: String::from("2.0"),
method: String::from("eth_chainId"),
params: serde_json::json!([]),
id: rand::thread_rng().r#gen(),
}
}
/// Create an eth_getBlockByNumber request with invalid params for testing error responses
#[cfg(test)]
pub fn eth_get_block_by_number_invalid() -> Self {
Self {
jsonrpc: String::from("2.0"),
method: String::from("eth_getBlockByNumber"),
// Invalid params: should be [blockNumber, boolean] but we pass garbage
params: serde_json::json!(["invalid_block_number"]),
id: rand::thread_rng().r#gen(),
}
}
}
// dead code: we need these fields for deserialization, even if we don't read them explicitly
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(untagged)]
enum JsonRpcResponse {
Ok {
jsonrpc: String,
// have to use opaque Value because spec say this might be string, number or null (we don't care either way)
id: serde_json::Value,
// we don't really care for the exact result, just whether the response is OK or error
result: serde_json::Value,
},
Err {
jsonrpc: String,
// have to use opaque Value because spec say this might be string, number or null (we don't care either way)
id: serde_json::Value,
error: JsonRpcError,
},
}
// dead code: we need these fields for deserialization, even if we don't read them explicitly
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct JsonRpcError {
pub code: i64,
pub message: String,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
#[cfg(test)]
mod test {
use super::*;
const JSON_RPC_ENDPOINT: &str = "https://cloudflare-eth.com";
#[tokio::test]
async fn test_eth_chainid_returns_ok_response() {
let client = reqwest::Client::new();
let response = client
.post(JSON_RPC_ENDPOINT)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&JsonRpcRequestBody::eth_chainid())
.send()
.await
.expect("Failed to send request");
assert!(response.status().is_success());
let json_response: JsonRpcResponse =
response.json().await.expect("Failed to parse response");
assert!(
matches!(json_response, JsonRpcResponse::Ok { .. }),
"Expected Ok variant for eth_chainId"
);
}
#[tokio::test]
async fn test_eth_get_block_by_number_invalid_returns_error_response() {
let client = reqwest::Client::new();
let response = client
.post(JSON_RPC_ENDPOINT)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&JsonRpcRequestBody::eth_get_block_by_number_invalid())
.send()
.await
.expect("Failed to send request");
assert!(response.status().is_success()); // HTTP 200 but JSON-RPC error
let json_response: JsonRpcResponse =
response.json().await.expect("Failed to parse response");
assert!(
matches!(json_response, JsonRpcResponse::Err { .. }),
"Expected Err variant for invalid params"
);
}
}
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
pub(crate) use json_rpc_client::JsonRpcClient;
mod json_rpc_client;
pub struct HttpsConnectivityTest {
test_count: u64,
failure_count_cutoff: usize,
client: JsonRpcClient,
}
impl HttpsConnectivityTest {
pub fn new(
test_count: u64,
mixnet_client_timeout: u64,
failure_count_cutoff: usize,
json_rpc_test_endpoints: Vec<String>,
socks5_proxy_url: String,
) -> anyhow::Result<Self> {
let proxy = reqwest::Proxy::all(socks5_proxy_url)
.map_err(|e| anyhow::anyhow!("Failed to create proxy: {}", e))?;
let client =
JsonRpcClient::new(mixnet_client_timeout, Some(proxy), json_rpc_test_endpoints)?;
let res = Self {
test_count: std::cmp::max(test_count, 1),
failure_count_cutoff,
client,
};
Ok(res)
}
pub async fn run_tests(self) -> HttpsConnectivityResult {
let mut results = Vec::new();
for i in 1..=self.test_count {
info!("Running test {}/{}", i, self.test_count);
let interim_res = self.client.https_request_with_fallbacks().await;
if interim_res.success {
info!(
"{}/{} latency: {}ms",
i,
self.test_count,
interim_res.latency_ms.unwrap_or(0)
);
}
results.push(interim_res);
// early exit
let unsuccessful = results.iter().filter(|r| !r.success).count();
if unsuccessful > self.failure_count_cutoff {
warn!("Too many failed runs: returning early...");
break;
}
}
let final_result = HttpsConnectivityResult::from_results(results);
info!("AVG latency (in ms): {:?}", final_result.https_latency_ms);
final_result
}
}
/// single HTTPS test attempt
struct SingleHttpsTestResult {
success: bool,
status_code: Option<u16>,
latency_ms: Option<u64>,
endpoint_used: Option<String>,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HttpsConnectivityResult {
/// successfully completed HTTPS request
https_success: bool,
/// HTTPS status code received
https_status_code: Option<u16>,
/// average HTTPS request latency in milliseconds
https_latency_ms: Option<u64>,
/// among multiple endpoints available, list the one actually used
endpoint_used: Option<String>,
/// error message(s) (if any)
error: Option<Vec<String>>,
}
impl HttpsConnectivityResult {
pub fn with_errors(error: Vec<String>) -> Self {
Self {
https_success: false,
https_status_code: None,
https_latency_ms: None,
endpoint_used: None,
error: Some(error),
}
}
fn from_results(results: Vec<SingleHttpsTestResult>) -> Self {
let (successes, errors): (Vec<SingleHttpsTestResult>, Vec<SingleHttpsTestResult>) =
results.into_iter().partition(|r| r.success);
let errors = errors
.into_iter()
.map(|r| r.error)
.collect::<Option<Vec<_>>>()
// partition above guarantees this vec is non-empty
.unwrap_or_default();
// use the last successful result for status_code and endpoint
// this works as an empty check as well: if there is no last success, array must be empty hence only errors are present
let Some(last_success) = successes.last() else {
return Self::with_errors(errors);
};
// average latency from successful runs
let mut successes_count = 0;
let total_latency: u64 = successes
.iter()
.filter_map(|r| {
successes_count += 1;
r.latency_ms
})
.sum();
let avg_latency = total_latency / successes_count as u64;
Self {
https_success: true,
https_status_code: last_success.status_code,
https_latency_ms: Some(avg_latency),
endpoint_used: last_success.endpoint_used.clone(),
// even in case of success, some errors were possible
error: if errors.is_empty() {
None
} else {
Some(errors)
},
}
}
pub fn https_success(&self) -> bool {
self.https_success
}
pub fn https_status_code(&self) -> Option<&u16> {
self.https_status_code.as_ref()
}
pub fn https_latency_ms(&self) -> Option<&u64> {
self.https_latency_ms.as_ref()
}
pub fn endpoint_used(&self) -> Option<&String> {
self.endpoint_used.as_ref()
}
}
+35
View File
@@ -1,6 +1,8 @@
use nym_connection_monitor::ConnectionStatusEvent;
use serde::{Deserialize, Serialize};
pub use super::socks5_test::HttpsConnectivityResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub node: String,
@@ -12,6 +14,7 @@ pub struct ProbeResult {
pub struct ProbeOutcome {
pub as_entry: Entry,
pub as_exit: Option<Exit>,
pub socks5: Option<Socks5ProbeResults>,
pub wg: Option<WgProbeResults>,
pub lp: Option<LpProbeResults>,
}
@@ -132,6 +135,38 @@ impl Exit {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Socks5ProbeResults {
/// whether we could establish a SOCKS5 proxy connection
can_connect_socks5: bool,
/// HTTPS connectivity test
https_connectivity: HttpsConnectivityResult,
}
impl Socks5ProbeResults {
pub fn with_http_result(https_connectivity: HttpsConnectivityResult) -> Self {
Self {
can_connect_socks5: true,
https_connectivity,
}
}
pub fn error_before_connecting(error: impl Into<String>) -> Self {
Self {
can_connect_socks5: false,
https_connectivity: HttpsConnectivityResult::with_errors(vec![error.into()]),
}
}
pub fn error_after_connecting(error: impl Into<String>) -> Self {
Self {
can_connect_socks5: true,
https_connectivity: HttpsConnectivityResult::with_errors(vec![error.into()]),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IpPingReplies {
pub ipr_tun_ip_v4: bool,
+2
View File
@@ -3,8 +3,10 @@
mod credentials;
mod netstack;
mod socks5;
mod test_mode;
pub use credentials::CredentialArgs;
pub use netstack::NetstackArgs;
pub use socks5::Socks5Args;
pub use test_mode::TestMode;
+32
View File
@@ -0,0 +1,32 @@
use clap::Args;
use crate::common::socks5_test::JsonRpcClient;
#[derive(Args)]
pub struct Socks5Args {
#[arg(long, value_delimiter = ';')]
pub socks5_json_rpc_url_list: Vec<String>,
#[arg(long, default_value_t = 30)]
pub mixnet_client_timeout_sec: u64,
#[arg(long, default_value_t = 10)]
pub test_count: u64,
/// stops socks5 test early after this many failed attempts
#[arg(long, default_value_t = 3)]
pub failure_count_cutoff: usize,
}
impl Socks5Args {
pub async fn validate_socks5_endpoints(&self) -> anyhow::Result<()> {
let client = JsonRpcClient::new(
self.mixnet_client_timeout_sec,
None,
self.socks5_json_rpc_url_list.clone(),
)?;
client.ensure_endpoint_works().await?;
Ok(())
}
}
+101 -9
View File
@@ -2,9 +2,13 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::common::helpers;
use crate::common::probe_tests::{do_ping, lp_registration_probe, wg_probe, wg_probe_lp};
use crate::common::types::{Entry, Exit, WgProbeResults};
use crate::common::probe_tests::{
do_ping, do_socks5_connectivity_test, lp_registration_probe, wg_probe, wg_probe_lp,
};
use crate::common::types::{Entry, Exit, Socks5ProbeResults, WgProbeResults};
use crate::config::Socks5Args;
use anyhow::bail;
use nym_api_requests::models::NetworkRequesterDetailsV1;
use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient};
use nym_client_core::config::ForgetMe;
use nym_config::defaults::NymNetworkDetails;
@@ -14,6 +18,7 @@ use nym_sdk::mixnet::{
CredentialStorage, Ephemeral, KeyStore, MixnetClient, MixnetClientBuilder, MixnetClientStorage,
NodeIdentity, StoragePaths,
};
use nym_topology::NymTopology;
use rand::rngs::OsRng;
use std::path::PathBuf;
use std::sync::Arc;
@@ -48,6 +53,7 @@ pub struct Probe {
localnet_entry: Option<TestedNodeDetails>,
/// Localnet exit gateway info (used when --exit-gateway-identity is specified)
localnet_exit: Option<TestedNodeDetails>,
socks5_args: Socks5Args,
}
impl Probe {
@@ -56,6 +62,7 @@ impl Probe {
tested_node: TestedNode,
netstack_args: NetstackArgs,
credentials_args: CredentialArgs,
socks5_args: Socks5Args,
) -> Self {
Self {
entrypoint,
@@ -67,6 +74,7 @@ impl Probe {
exit_gateway_node: None,
localnet_entry: None,
localnet_exit: None,
socks5_args,
}
}
@@ -77,6 +85,7 @@ impl Probe {
netstack_args: NetstackArgs,
credentials_args: CredentialArgs,
gateway_node: DirectoryNode,
socks5_args: Socks5Args,
) -> Self {
Self {
entrypoint,
@@ -88,6 +97,7 @@ impl Probe {
exit_gateway_node: None,
localnet_entry: None,
localnet_exit: None,
socks5_args,
}
}
@@ -99,6 +109,7 @@ impl Probe {
credentials_args: CredentialArgs,
entry_gateway_node: DirectoryNode,
exit_gateway_node: DirectoryNode,
socks5_args: Socks5Args,
) -> Self {
Self {
entrypoint,
@@ -110,6 +121,7 @@ impl Probe {
exit_gateway_node: Some(exit_gateway_node),
localnet_entry: None,
localnet_exit: None,
socks5_args,
}
}
@@ -120,6 +132,7 @@ impl Probe {
exit: Option<TestedNodeDetails>,
netstack_args: NetstackArgs,
credentials_args: CredentialArgs,
socks5_args: Socks5Args,
) -> Self {
let entrypoint = entry.identity;
Self {
@@ -132,6 +145,7 @@ impl Probe {
exit_gateway_node: None,
localnet_entry: Some(entry),
localnet_exit: exit,
socks5_args,
}
}
@@ -150,6 +164,7 @@ impl Probe {
only_lp_registration: bool,
test_lp_wg: bool,
min_mixnet_performance: Option<u8>,
network_details: NymNetworkDetails,
) -> anyhow::Result<ProbeResult> {
let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?;
@@ -161,7 +176,7 @@ impl Probe {
// Connect to the mixnet via the entry gateway
let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone())
.request_gateway(mixnet_entry_gateway_id.to_string())
.network_details(NymNetworkDetails::new_from_env())
.network_details(network_details.clone())
.debug_config(helpers::mixnet_debug_config(
min_mixnet_performance,
ignore_egress_epoch_role,
@@ -176,6 +191,15 @@ impl Probe {
let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await;
// Extract topology from the connected client (if successful) to reuse for SOCKS5 test
let topology = match &mixnet_client {
Ok(client) => client
.read_current_route_provider()
.await
.map(|rp| rp.topology.clone()),
Err(_) => None,
};
// Convert legacy flags to TestMode
let has_exit = self.exit_gateway_node.is_some() || self.localnet_exit.is_some();
let test_mode =
@@ -192,6 +216,8 @@ impl Probe {
test_mode,
only_wireguard,
false, // Not using mock ecash in regular probe mode
network_details,
topology,
)
.await
}
@@ -209,6 +235,7 @@ impl Probe {
test_lp_wg: bool,
min_mixnet_performance: Option<u8>,
use_mock_ecash: bool,
network_details: NymNetworkDetails,
) -> anyhow::Result<ProbeResult> {
// Localnet mode - identity + LP address from CLI, no HTTP query
// This path is used when --entry-gateway-identity is specified
@@ -249,6 +276,8 @@ impl Probe {
test_mode,
only_wireguard,
use_mock_ecash,
network_details,
None, // No topology (no mixnet client in localnet mode)
)
.await;
}
@@ -296,6 +325,8 @@ impl Probe {
test_mode,
only_wireguard,
use_mock_ecash,
network_details,
None, // No topology (no mixnet client in direct gateway mode)
)
.await;
}
@@ -328,7 +359,7 @@ impl Probe {
// and keeps its bandwidth between probe runs
let disconnected_mixnet_client = MixnetClientBuilder::new_with_storage(storage.clone())
.request_gateway(mixnet_entry_gateway_id.to_string())
.network_details(NymNetworkDetails::new_from_env())
.network_details(network_details.clone())
.debug_config(helpers::mixnet_debug_config(
min_mixnet_performance,
ignore_egress_epoch_role,
@@ -371,6 +402,15 @@ impl Probe {
let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await;
// extract topology from the connected client (if any) to reuse for SOCKS5 test
let topology = match &mixnet_client {
Ok(client) => client
.read_current_route_provider()
.await
.map(|rp| rp.topology.clone()),
Err(_) => None,
};
// Convert legacy flags to TestMode
let has_exit = self.exit_gateway_node.is_some() || self.localnet_exit.is_some();
let test_mode =
@@ -387,6 +427,8 @@ impl Probe {
test_mode,
only_wireguard,
use_mock_ecash,
network_details,
topology,
)
.await
}
@@ -458,11 +500,44 @@ impl Probe {
Some(Exit::fail_to_connect())
},
wg: None,
socks5: None,
lp: Some(lp_outcome),
},
})
}
async fn test_socks5_if_possible(
&self,
network_details: NymNetworkDetails,
network_requester_details: &Option<NetworkRequesterDetailsV1>,
directory: &NymApiDirectory,
topology: Option<NymTopology>,
) -> Option<Socks5ProbeResults> {
if let Some(nr_details) = network_requester_details {
match do_socks5_connectivity_test(
&nr_details.address,
network_details,
directory,
self.socks5_args.socks5_json_rpc_url_list.clone(),
self.socks5_args.mixnet_client_timeout_sec,
self.socks5_args.test_count,
self.socks5_args.failure_count_cutoff,
topology,
)
.await
{
Ok(results) => Some(results),
Err(e) => {
error!("SOCKS5 test failed: {}", e);
None
}
}
} else {
info!("No NR available, skipping SOCKS5 tests");
None
}
}
pub async fn lookup_gateway(
&self,
directory: &Option<NymApiDirectory>,
@@ -535,11 +610,16 @@ impl Probe {
test_mode: TestMode,
only_wireguard: bool,
use_mock_ecash: bool,
network_details: NymNetworkDetails,
topology: Option<NymTopology>,
) -> anyhow::Result<ProbeResult>
where
T: MixnetClientStorage + Clone + 'static,
<T::CredentialStore as CredentialStorage>::StorageError: Send + Sync,
{
let Some(directory) = directory else {
bail!("You need to provide NYM API through environment")
};
// test_mode replaces the old only_lp_registration and test_lp_wg flags.
// only_wireguard is kept separate as it controls ping behavior within Mixnet mode.
let mut rng = rand::thread_rng();
@@ -557,6 +637,7 @@ impl Probe {
Entry::EntryFailure
},
as_exit: None,
socks5: None,
wg: None,
lp: None,
},
@@ -595,6 +676,7 @@ impl Probe {
Entry::NotTested
},
as_exit: None,
socks5: None,
wg: None,
lp: None,
}),
@@ -609,6 +691,7 @@ impl Probe {
Ok(ProbeOutcome {
as_entry: Entry::NotTested,
as_exit: None,
socks5: None,
wg: None,
lp: None,
}),
@@ -624,6 +707,7 @@ impl Probe {
Entry::EntryFailure
},
as_exit: None,
socks5: None,
wg: None,
lp: None,
}),
@@ -681,8 +765,6 @@ impl Probe {
// The tested node is the exit
let exit_gateway = node_info.clone();
let directory = directory
.ok_or_else(|| anyhow::anyhow!("Directory is required for LP-WG test mode"))?;
let entry_gateway_node = directory.entry_gateway(&mixnet_entry_gateway_id)?;
let entry_gateway = entry_gateway_node.to_testable_node()?;
@@ -723,9 +805,8 @@ impl Probe {
Arc::new(KeyPair::new(&mut rng)),
ip_address,
);
let config = nym_validator_client::nyxd::Config::try_from_nym_network_details(
&NymNetworkDetails::new_from_env(),
)?;
let config =
nym_validator_client::nyxd::Config::try_from_nym_network_details(&network_details)?;
let client =
nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?;
let bw_controller = nym_bandwidth_controller::BandwidthController::new(
@@ -790,10 +871,21 @@ impl Probe {
None
};
// test failure doesn't stop further tests
let socks5_outcome = self
.test_socks5_if_possible(
network_details,
&node_info.network_requester_details,
directory,
topology,
)
.await;
// Disconnect the mixnet client gracefully
outcome.map(|mut outcome| {
outcome.wg = Some(wg_outcome);
outcome.lp = lp_outcome;
outcome.socks5 = socks5_outcome;
ProbeResult {
node: node_info.identity.to_string(),
used_entry: mixnet_entry_gateway_id.to_string(),
+23 -3
View File
@@ -6,6 +6,7 @@ use clap::{Parser, Subcommand};
use nym_bin_common::bin_info;
use nym_config::defaults::setup_env;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_gateway_probe::config::Socks5Args;
use nym_gateway_probe::{
CredentialArgs, NetstackArgs, NymApiDirectory, ProbeResult, TestMode, TestedNode,
TestedNodeDetails, TestedNodeLpDetails, query_gateway_by_ip,
@@ -172,6 +173,10 @@ struct CliArgs {
/// Arguments to manage credentials
#[command(flatten)]
credential_args: CredentialArgs,
/// Arguments to configure socks5 probe
#[command(flatten)]
socks5_args: Socks5Args,
}
const DEFAULT_CONFIG_DIR: &str = "/tmp/nym-gateway-probe/config/";
@@ -263,6 +268,8 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
.map(|ep| ep.nyxd_url())
.ok_or(anyhow::anyhow!("missing nyxd url"))?;
args.socks5_args.validate_socks5_endpoints().await?;
// Three resolution modes in priority order:
// 1. Localnet mode: --entry-gateway-identity provided (no HTTP query)
// 2. Direct IP mode: --gateway-ip provided (queries HTTP API)
@@ -381,6 +388,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
exit_details,
args.netstack_args,
args.credential_args,
args.socks5_args,
);
if let Some(awg_args) = args.amnezia_args {
@@ -414,6 +422,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
test_lp_wg,
args.min_gateway_mixnet_performance,
*use_mock_ecash,
network,
))
.await
}
@@ -426,6 +435,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
only_lp_registration,
test_lp_wg,
args.min_gateway_mixnet_performance,
network,
))
.await
}
@@ -515,6 +525,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
args.credential_args,
entry_node.clone(),
exit_node.clone(),
args.socks5_args,
)
} else if let Some(gw_node) = gateway_node {
// Only entry gateway provided
@@ -524,17 +535,24 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
args.netstack_args,
args.credential_args,
gw_node,
args.socks5_args,
)
} else {
// No direct gateways, use directory lookup
nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args)
nym_gateway_probe::Probe::new(
entry,
test_point,
args.netstack_args,
args.credential_args,
args.socks5_args,
)
};
if let Some(awg_args) = args.amnezia_args {
trial.with_amnezia(&awg_args);
}
match &args.command {
match args.command {
Some(Commands::RunLocal {
mnemonic,
config_dir,
@@ -559,7 +577,8 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
only_lp_registration,
test_lp_wg,
args.min_gateway_mixnet_performance,
*use_mock_ecash,
use_mock_ecash,
network,
))
.await
}
@@ -572,6 +591,7 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
only_lp_registration,
test_lp_wg,
args.min_gateway_mixnet_performance,
network,
))
.await
}
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-agent"
version = "1.0.7"
version = "1.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -1,15 +1,16 @@
#!/bin/bash
# used primarily for local testing
set -eu
export ENVIRONMENT=${ENVIRONMENT:-"mainnet"}
probe_git_ref="nym-vpn-core-v1.4.0"
crate_root=$(dirname $(realpath "$0"))
echo crate_root=${crate_root}
monorepo_root=$(realpath "${crate_root}/../..")
echo monorepo_root=${monorepo_root}
echo "Expecting nym-vpn-client repo at a sibling level of nym monorepo dir"
gateway_probe_src=$(dirname "${monorepo_root}")/nym-vpn-client/nym-vpn-core
gateway_probe_src="${monorepo_root}/nym-gateway-probe"
echo "gateway_probe_src=$gateway_probe_src"
set -a
@@ -25,7 +26,8 @@ export RUST_LOG="info"
NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1"
NODE_STATUS_AGENT_SERVER_PORT="8000"
SERVER="${NODE_STATUS_AGENT_SERVER_ADDRESS}|${NODE_STATUS_AGENT_SERVER_PORT}"
export NODE_STATUS_AGENT_AUTH_KEY="BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT"
# hardcoded key used only for LOCAL TESTING
export NODE_STATUS_AGENT_AUTH_KEY=${NODE_STATUS_AGENT_AUTH_KEY_STAGING:-"BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT"}
export NODE_STATUS_AGENT_PROBE_PATH="$crate_root/nym-gateway-probe"
export NODE_STATUS_AGENT_PROBE_EXTRA_ARGS="netstack-download-timeout-sec=30,netstack-num-ping=2,netstack-send-timeout-sec=1,netstack-recv-timeout-sec=1"
@@ -35,11 +37,9 @@ echo "Running $workers workers in parallel"
# build & copy over GW probe
function copy_gw_probe() {
pushd $gateway_probe_src
git fetch -a
git checkout $probe_git_ref
cargo build --release --package nym-gateway-probe
cp target/release/nym-gateway-probe "$crate_root"
cp "${monorepo_root}/target/release/nym-gateway-probe" "$crate_root"
$crate_root/nym-gateway-probe --version
popd
+7
View File
@@ -601,6 +601,13 @@ where
);
let available_gateways = self.available_gateways().await?;
for node in available_gateways.iter() {
debug!(
"node_id={}, identity_key={}",
node.node_id,
node.identity_key.to_base58_string()
);
}
Ok(GatewaySetup::New {
specification: selection_spec,