NS API socks5 support (#6361)
* Add conversion from gw_probe crate type * Move code around - split 1000+ LoC files into smaller ones * Add socks5 field - code improvements in gw_probe crate * Fix docker build - install go - required as build dependency of gw probe * Add logs to agent * NS API: configure DB via env * rebase fix * socks5 score calc * Cargo fmt * use existing div_ceil * Code improvements * Bump NS API version * Rename variables * Bump API & agent version * Try to fix CI * Build only on linux
This commit is contained in:
@@ -89,7 +89,7 @@ jobs:
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --workspace --all-targets --exclude nym-gateway-probe -- -D warnings
|
||||
args: --workspace --all-targets --exclude nym-gateway-probe --exclude nym-node-status-api -- -D warnings
|
||||
|
||||
- name: Clippy (non-macos)
|
||||
if: contains(matrix.os, 'linux') || contains(matrix.os, 'windows')
|
||||
@@ -104,6 +104,14 @@ jobs:
|
||||
with:
|
||||
command: build
|
||||
|
||||
# only build on linux because of wg FFI bindings of its dependency (network probe)
|
||||
- name: Build nym-node-status-api (linux only)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: -p nym-node-status-api
|
||||
|
||||
- name: Build all examples
|
||||
if: contains(matrix.os, 'linux')
|
||||
uses: actions-rs/cargo@v1
|
||||
|
||||
Generated
+6
-2
@@ -6499,6 +6499,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"vergen-gitcl",
|
||||
"x25519-dalek",
|
||||
]
|
||||
@@ -7235,7 +7236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-node-status-agent"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -7244,6 +7245,8 @@ dependencies = [
|
||||
"nym-crypto",
|
||||
"nym-node-status-client",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7252,7 +7255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-node-status-api"
|
||||
version = "4.0.12"
|
||||
version = "4.0.14"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -7273,6 +7276,7 @@ dependencies = [
|
||||
"nym-credentials",
|
||||
"nym-crypto",
|
||||
"nym-ecash-time",
|
||||
"nym-gateway-probe",
|
||||
"nym-http-api-client",
|
||||
"nym-http-api-common",
|
||||
"nym-mixnet-contract-common",
|
||||
|
||||
@@ -185,7 +185,6 @@ default-members = [
|
||||
"nym-credential-proxy/nym-credential-proxy",
|
||||
"nym-node",
|
||||
"nym-node-status-api/nym-node-status-agent",
|
||||
"nym-node-status-api/nym-node-status-api",
|
||||
"nym-statistics-api",
|
||||
"nym-validator-rewarder",
|
||||
"nyx-chain-watcher",
|
||||
|
||||
@@ -38,6 +38,7 @@ tokio = { workspace = true, features = [
|
||||
tokio-util.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url = { workspace = true }
|
||||
utoipa = { workspace = true, optional = true }
|
||||
x25519-dalek = { workspace = true, features = [
|
||||
"reusable_secrets",
|
||||
"static_secrets",
|
||||
@@ -76,6 +77,9 @@ time = { workspace = true }
|
||||
# TEMP: REMOVE BEFORE PR
|
||||
nym-topology = { workspace = true }
|
||||
|
||||
[features]
|
||||
utoipa = ["dep:utoipa"]
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
vergen-gitcl = { workspace = true, default-features = false, features = [
|
||||
|
||||
@@ -13,5 +13,5 @@ pub(crate) mod netstack;
|
||||
pub(crate) mod nodes;
|
||||
pub(crate) mod probe_tests;
|
||||
pub(crate) mod socks5_test;
|
||||
pub(crate) mod types;
|
||||
pub mod types;
|
||||
pub(crate) mod wireguard;
|
||||
|
||||
@@ -73,6 +73,7 @@ struct SingleHttpsTestResult {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct HttpsConnectivityResult {
|
||||
/// successfully completed HTTPS request
|
||||
@@ -88,17 +89,17 @@ pub struct HttpsConnectivityResult {
|
||||
endpoint_used: Option<String>,
|
||||
|
||||
/// error message(s) (if any)
|
||||
error: Option<Vec<String>>,
|
||||
errors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl HttpsConnectivityResult {
|
||||
pub fn with_errors(error: Vec<String>) -> Self {
|
||||
pub fn with_errors(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
https_success: false,
|
||||
https_status_code: None,
|
||||
https_latency_ms: None,
|
||||
endpoint_used: None,
|
||||
error: Some(error),
|
||||
errors: Some(errors),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ impl HttpsConnectivityResult {
|
||||
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() {
|
||||
errors: if errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(errors)
|
||||
@@ -158,4 +159,8 @@ impl HttpsConnectivityResult {
|
||||
pub fn endpoint_used(&self) -> Option<&String> {
|
||||
self.endpoint_used.as_ref()
|
||||
}
|
||||
|
||||
pub fn errors(&self) -> Option<&Vec<String>> {
|
||||
self.errors.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct ProbeResult {
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeOutcome {
|
||||
pub as_entry: Entry,
|
||||
@@ -19,6 +20,7 @@ pub struct ProbeOutcome {
|
||||
pub lp: Option<LpProbeResults>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename = "wg")]
|
||||
pub struct WgProbeResults {
|
||||
@@ -48,6 +50,7 @@ pub struct WgProbeResults {
|
||||
pub download_error_v6: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename = "lp")]
|
||||
pub struct LpProbeResults {
|
||||
@@ -57,6 +60,7 @@ pub struct LpProbeResults {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
@@ -98,12 +102,14 @@ impl Entry {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntryTestResult {
|
||||
pub can_connect: bool,
|
||||
pub can_route: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Exit {
|
||||
pub can_connect: bool,
|
||||
@@ -135,7 +141,8 @@ impl Exit {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Socks5ProbeResults {
|
||||
/// whether we could establish a SOCKS5 proxy connection
|
||||
can_connect_socks5: bool,
|
||||
@@ -165,6 +172,14 @@ impl Socks5ProbeResults {
|
||||
https_connectivity: HttpsConnectivityResult::with_errors(vec![error.into()]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_connect_socks5(&self) -> bool {
|
||||
self.can_connect_socks5
|
||||
}
|
||||
|
||||
pub fn https_connectivity(&self) -> &HttpsConnectivityResult {
|
||||
&self.https_connectivity
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -27,6 +27,7 @@ use tracing::*;
|
||||
use url::Url;
|
||||
|
||||
mod common;
|
||||
pub use common::types;
|
||||
pub mod config;
|
||||
|
||||
use crate::common::bandwidth_helpers::{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
[package]
|
||||
name = "nym-node-status-agent"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
@@ -23,6 +23,8 @@ nym-crypto = { workspace = true, features = ["asymmetric", "rand"] }
|
||||
|
||||
nym-node-status-client = { path = "../nym-node-status-client" }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
|
||||
@@ -4,7 +4,6 @@ An agent to run tests and report results back to the Node Status API.
|
||||
|
||||
Environment variables that can be set individually are:
|
||||
|
||||
- `NYM_NODE_MNEMONICS` - mnemonic to get tickets for tests
|
||||
- `NODE_STATUS_AGENT_SERVER_PORT` - Node Status API port
|
||||
- `NODE_STATUS_AGENT_SERVER_ADDRESS` - Node Status API address
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@ set -a
|
||||
source "${monorepo_root}/envs/${ENVIRONMENT}.env"
|
||||
set +a
|
||||
|
||||
if [ -z "$NYM_NODE_MNEMONICS" ]; then
|
||||
echo "NYM_NODE_MNEMONICS is required to run an agent"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export RUST_LOG="info"
|
||||
NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1"
|
||||
@@ -29,7 +25,7 @@ SERVER="${NODE_STATUS_AGENT_SERVER_ADDRESS}|${NODE_STATUS_AGENT_SERVER_PORT}"
|
||||
# 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"
|
||||
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,socks5-json-rpc-url-list=https://cloudflare-eth.com;https://ethereum.publicnode.com"
|
||||
|
||||
workers=${1:-1}
|
||||
echo "Running $workers workers in parallel"
|
||||
|
||||
@@ -57,6 +57,36 @@ pub(crate) async fn run_probe(
|
||||
testrun.ticket_materials,
|
||||
);
|
||||
|
||||
// Inspect the probe output for socks5 field
|
||||
// Extract JSON from log output (probe outputs logs followed by JSON)
|
||||
let json_str = extract_json_from_log(&log);
|
||||
if json_str.is_empty() {
|
||||
tracing::error!("Failed to extract JSON from probe output");
|
||||
} else {
|
||||
match serde_json::from_str::<serde_json::Value>(&json_str) {
|
||||
Ok(json) => {
|
||||
if let Some(outcome) = json.get("outcome") {
|
||||
match outcome.get("socks5") {
|
||||
Some(socks5) if socks5.is_null() => {
|
||||
tracing::warn!("🌐⚠️ socks5 field is NULL in probe output");
|
||||
}
|
||||
Some(socks5) => {
|
||||
tracing::info!("🌐 socks5 field present: {}", socks5);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("🌐⚠️ socks5 field is MISSING from probe output");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("🌐⚠️ outcome field is MISSING from probe output");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse probe output as JSON: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit to ALL servers in parallel
|
||||
let handles = servers
|
||||
.iter()
|
||||
@@ -122,3 +152,17 @@ pub(crate) async fn run_probe(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract JSON from probe log output.
|
||||
/// The probe outputs log lines followed by JSON starting with `\n{ `.
|
||||
fn extract_json_from_log(log: &str) -> String {
|
||||
static RE: std::sync::LazyLock<regex::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::Regex::new(r"\n\{\s").expect("Invalid regex pattern"));
|
||||
|
||||
let result: Vec<_> = RE.splitn(log, 2).collect();
|
||||
if result.len() == 2 {
|
||||
format!("{{ {}", result[1])
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_key, serialization_revision as \"serialization_revision: u8\"\n FROM master_verification_key WHERE epoch_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "epoch_id: u32",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "serialised_key",
|
||||
"ordinal": 1,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "serialization_revision: u8",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO ecash_deposit_usage (deposit_id, ticketbooks_requested_on, client_pubkey, request_uuid)\n VALUES (?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM blinded_shares WHERE created < ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO global_expiration_date_signatures(expiration_date, epoch_id, serialised_signatures, serialization_revision)\n VALUES (?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT error_message\n FROM blinded_shares\n WHERE id = ?;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "error_message",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d"
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM global_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": "3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM partial_blinded_wallet_failure WHERE created < ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO master_verification_key(epoch_id, serialised_key, serialization_revision) VALUES (?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO partial_blinded_wallet_failure(corresponding_deposit, epoch_id, expiration_date, node_id, created, failure_message)\n VALUES (?, ?, ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41"
|
||||
}
|
||||
-32
@@ -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 global_coin_index_signatures WHERE epoch_id = ?\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": "a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM partial_blinded_wallet WHERE created < ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n t1.node_id as \"node_id!\",\n t1.blinded_signature as \"blinded_signature!\",\n t1.epoch_id as \"epoch_id!\",\n t1.expiration_date as \"expiration_date!: Date\"\n FROM partial_blinded_wallet as t1\n JOIN ecash_deposit_usage as t2\n on t1.corresponding_deposit = t2.deposit_id\n JOIN blinded_shares as t3\n ON t2.request_uuid = t3.request_uuid\n WHERE t3.device_id = ? AND t3.credential_id = ?;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "node_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blinded_signature!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "epoch_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "expiration_date!: Date",
|
||||
"ordinal": 3,
|
||||
"type_info": "Date"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO global_coin_index_signatures(epoch_id, serialised_signatures, serialization_revision) VALUES (?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO partial_blinded_wallet(corresponding_deposit, epoch_id, expiration_date, node_id, created, blinded_signature)\n VALUES (?, ?, ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE ecash_deposit_usage\n SET ticketbook_request_error = ?\n WHERE deposit_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT t1.node_id, t1.blinded_signature, t1.epoch_id, t1.expiration_date as \"expiration_date!: Date\"\n FROM partial_blinded_wallet as t1\n JOIN ecash_deposit_usage as t2\n on t1.corresponding_deposit = t2.deposit_id\n JOIN blinded_shares as t3\n ON t2.request_uuid = t3.request_uuid\n WHERE t3.id = ?;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "node_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blinded_signature",
|
||||
"ordinal": 1,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "epoch_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "expiration_date!: Date",
|
||||
"ordinal": 3,
|
||||
"type_info": "Date"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT error_message\n FROM blinded_shares\n WHERE device_id = ? AND credential_id = ?;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "error_message",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
[package]
|
||||
name = "nym-node-status-api"
|
||||
version = "4.0.12"
|
||||
version = "4.0.14"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
@@ -34,6 +34,7 @@ nym-mixnet-contract-common = { workspace = true, features = ["utoipa"] }
|
||||
nym-bin-common = { workspace = true, features = ["models"] }
|
||||
nym-node-status-client = { path = "../nym-node-status-client" }
|
||||
nym-crypto = { workspace = true, features = ["asymmetric", "serde"] }
|
||||
nym-gateway-probe = { path = "../../nym-gateway-probe", features = ["utoipa"] }
|
||||
nym-http-api-client = { workspace = true }
|
||||
nym-http-api-common = { workspace = true, features = ["middleware"] }
|
||||
nym-network-defaults = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# this will only work with VPN, otherwise remove the harbor part
|
||||
FROM harbor.nymte.ch/dockerhub/rust:latest AS builder
|
||||
|
||||
RUN apt update && apt install -yy libdbus-1-dev pkg-config libclang-dev
|
||||
|
||||
# Install go (required for nym-gateway-probe dependency)
|
||||
RUN wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz -O go.tar.gz
|
||||
RUN tar -xzvf go.tar.gz -C /usr/local
|
||||
ENV PATH=/go/bin:/usr/local/go/bin:$PATH
|
||||
|
||||
COPY ./ /usr/src/nym
|
||||
WORKDIR /usr/src/nym/nym-node-status-api/nym-node-status-api/
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# --- Configuration ---
|
||||
TEST_DATABASE_URL := postgres://testuser:testpass@localhost:5433/nym_node_status_api_test
|
||||
ENVIRONMENT ?= mainnet
|
||||
MONOREPO_ROOT := $(shell realpath ../..)
|
||||
|
||||
# Docker compose service names
|
||||
DB_SERVICE_NAME := postgres-test
|
||||
@@ -54,6 +56,20 @@ test-db-prepare: ## Run sqlx prepare for compile-time query verification
|
||||
@echo "Running sqlx prepare for PostgreSQL..."
|
||||
DATABASE_URL="$(TEST_DATABASE_URL)" cargo sqlx prepare
|
||||
|
||||
# --- Run Targets ---
|
||||
.PHONY: run
|
||||
run: ## Run the service (assumes database is already running)
|
||||
@echo "Starting nym-node-status-api with $(ENVIRONMENT) environment..."
|
||||
@set -a && source "$(MONOREPO_ROOT)/envs/$(ENVIRONMENT).env" && set +a && \
|
||||
DATABASE_URL="$(TEST_DATABASE_URL)" \
|
||||
NYM_API_CLIENT_TIMEOUT="$${NYM_API_CLIENT_TIMEOUT:-60}" \
|
||||
NODE_STATUS_API_AGENT_KEY_LIST="$${NODE_STATUS_API_AGENT_KEY_LIST:-H4z8kx5Kkf5JMQHhxaW1MwYndjKCDHC7HsVhHTFfBZ4J}" \
|
||||
RUST_LOG="$${RUST_LOG:-debug}" \
|
||||
cargo run --package nym-node-status-api
|
||||
|
||||
.PHONY: run-with-db
|
||||
run-with-db: dev-db run ## Start database and run the service
|
||||
|
||||
# --- Build and Test Targets ---
|
||||
.PHONY: test-db-run
|
||||
test-db-run: ## Run tests
|
||||
|
||||
@@ -53,10 +53,19 @@ pub(crate) struct Cli {
|
||||
#[clap(long, env = "DATABASE_URL")]
|
||||
pub(crate) database_url: String,
|
||||
|
||||
/// SQLx pool acquire timeout in seconds (how long to wait for a connection)
|
||||
#[clap(long, default_value = "5", env = "SQLX_BUSY_TIMEOUT_S")]
|
||||
#[arg(value_parser = parse_duration_std)]
|
||||
pub(crate) sqlx_busy_timeout_s: Duration,
|
||||
|
||||
/// Maximum number of connections in the SQLx pool
|
||||
#[clap(long, default_value_t = 20, env = "SQLX_MAX_CONNECTIONS")]
|
||||
pub(crate) sqlx_max_connections: u32,
|
||||
|
||||
/// Minimum number of connections to keep in the SQLx pool
|
||||
#[clap(long, default_value_t = 5, env = "SQLX_MIN_CONNECTIONS")]
|
||||
pub(crate) sqlx_min_connections: u32,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "300",
|
||||
|
||||
@@ -7,7 +7,11 @@ pub(crate) mod queries;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use sqlx::{ConnectOptions, PgPool, Postgres, migrate::Migrator, postgres::PgConnectOptions};
|
||||
use sqlx::{
|
||||
ConnectOptions, PgPool, Postgres,
|
||||
migrate::Migrator,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations_pg");
|
||||
|
||||
@@ -21,7 +25,12 @@ pub(crate) struct Storage {
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub async fn init(connection_url: String, _busy_timeout: Duration) -> Result<Self> {
|
||||
pub async fn init(
|
||||
connection_url: String,
|
||||
acquire_timeout: Duration,
|
||||
max_connections: u32,
|
||||
min_connections: u32,
|
||||
) -> Result<Self> {
|
||||
use std::env;
|
||||
let mut connect_options =
|
||||
PgConnectOptions::from_str(&connection_url)?.disable_statement_logging();
|
||||
@@ -33,7 +42,19 @@ impl Storage {
|
||||
.ssl_mode(sqlx::postgres::PgSslMode::Require)
|
||||
.ssl_root_cert(ssl_cert);
|
||||
}
|
||||
let pool = sqlx::PgPool::connect_with(connect_options)
|
||||
|
||||
tracing::info!(
|
||||
"Initializing DB pool: max_connections={}, min_connections={}, acquire_timeout={}s",
|
||||
max_connections,
|
||||
min_connections,
|
||||
acquire_timeout.as_secs()
|
||||
);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
.acquire_timeout(acquire_timeout)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.map_err(|err| anyhow!("Failed to connect to {}: {}", &connection_url, err))?;
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum::EnumString;
|
||||
use tracing::error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub(crate) mod socks5_calc;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct LastProbeResult {
|
||||
node: String,
|
||||
used_entry: String,
|
||||
outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::ProbeResult as ProbeResultLatest;
|
||||
|
||||
impl From<ProbeResultLatest> for LastProbeResult {
|
||||
fn from(value: ProbeResultLatest) -> Self {
|
||||
Self {
|
||||
node: value.node,
|
||||
used_entry: value.used_entry,
|
||||
outcome: value.outcome.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LastProbeResult {
|
||||
pub(crate) fn deserialize_with_fallback(value: serde_json::Value) -> anyhow::Result<Self> {
|
||||
// first try matching latest struct from GW probe crate
|
||||
let mut probe_result = match serde_json::from_value::<ProbeResultLatest>(value.clone()) {
|
||||
Ok(probe_result) => probe_result.into(),
|
||||
// as a fallback, try parsing struct from this crate
|
||||
Err(_) => match serde_json::from_value::<Self>(value) {
|
||||
Ok(probe_result) => probe_result,
|
||||
Err(e) => {
|
||||
error!("Failed to deserialize probe result: {e}");
|
||||
return Err(e.into());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
probe_result.outcome.wg = probe_result.outcome.wg.clone().map(|mut wg| {
|
||||
if wg.can_handshake.is_none() {
|
||||
wg.can_handshake = Some(wg.can_handshake_v4);
|
||||
}
|
||||
if wg.can_resolve_dns.is_none() {
|
||||
wg.can_resolve_dns = Some(wg.can_resolve_dns_v4);
|
||||
}
|
||||
if wg.ping_hosts_performance.is_none() {
|
||||
wg.ping_hosts_performance = Some(wg.ping_hosts_performance_v4);
|
||||
}
|
||||
if wg.ping_ips_performance.is_none() {
|
||||
wg.ping_ips_performance = Some(wg.ping_ips_performance_v4);
|
||||
}
|
||||
wg
|
||||
});
|
||||
|
||||
Ok(probe_result)
|
||||
}
|
||||
|
||||
pub(crate) fn outcome(self) -> ProbeOutcome {
|
||||
self.outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// gateway probe output returned on the API
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DvpnGwProbe {
|
||||
last_updated_utc: String,
|
||||
outcome: DvpnProbeOutcome,
|
||||
}
|
||||
|
||||
impl DvpnGwProbe {
|
||||
pub fn from_outcome(outcome: DvpnProbeOutcome, last_updated_utc: String) -> Self {
|
||||
Self {
|
||||
last_updated_utc,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_route_entry(&self) -> bool {
|
||||
match &self.outcome.as_entry {
|
||||
Entry::Tested(entry_test_result) => entry_test_result.can_route,
|
||||
Entry::NotTested | Entry::EntryFailure => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_route_exit(&self) -> Option<bool> {
|
||||
self.outcome
|
||||
.as_exit
|
||||
.as_ref()
|
||||
.map(|outcome| outcome.can_route_ip_external_v4 && outcome.can_route_ip_external_v6)
|
||||
}
|
||||
}
|
||||
|
||||
/// this structure is parsed on VPN API so it has some fields which must not be changed
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DvpnProbeOutcome {
|
||||
pub as_entry: Entry,
|
||||
pub as_exit: Option<Exit>,
|
||||
pub wg: Option<WgProbeResults>,
|
||||
pub socks5: Option<Socks5>,
|
||||
}
|
||||
|
||||
impl DvpnProbeOutcome {
|
||||
pub fn from_raw_probe_outcome(outcome: ProbeOutcome, score: ScoreValue) -> Self {
|
||||
let errors = outcome
|
||||
.socks5
|
||||
.clone()
|
||||
.and_then(|s| s.https_connectivity.errors);
|
||||
let can_proxy_https = outcome
|
||||
.socks5
|
||||
.map(|s| s.https_connectivity.https_success)
|
||||
.unwrap_or_else(|| match score {
|
||||
ScoreValue::Offline => false,
|
||||
ScoreValue::Low | ScoreValue::Medium | ScoreValue::High => true,
|
||||
});
|
||||
Self {
|
||||
as_entry: outcome.as_entry.clone(),
|
||||
as_exit: outcome.as_exit.clone(),
|
||||
wg: outcome.wg.clone(),
|
||||
socks5: Some(Socks5 {
|
||||
can_proxy_https,
|
||||
score,
|
||||
errors,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct ProbeOutcome {
|
||||
pub as_entry: Entry,
|
||||
pub as_exit: Option<Exit>,
|
||||
pub wg: Option<WgProbeResults>,
|
||||
pub socks5: Option<Socks5ProbeResults>,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::ProbeOutcome as ProbeOutcomeLatest;
|
||||
|
||||
impl From<ProbeOutcomeLatest> for ProbeOutcome {
|
||||
fn from(value: ProbeOutcomeLatest) -> Self {
|
||||
Self {
|
||||
as_entry: value.as_entry.into(),
|
||||
as_exit: value.as_exit.map(From::from),
|
||||
wg: value.wg.map(From::from),
|
||||
socks5: value.socks5.map(From::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum Entry {
|
||||
Tested(EntryTestResult),
|
||||
NotTested,
|
||||
EntryFailure,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::Entry as EntryLatest;
|
||||
|
||||
impl From<EntryLatest> for Entry {
|
||||
fn from(value: EntryLatest) -> Self {
|
||||
match value {
|
||||
EntryLatest::Tested(entry_test_result) => Self::Tested(entry_test_result.into()),
|
||||
EntryLatest::NotTested => Self::NotTested,
|
||||
EntryLatest::EntryFailure => Self::EntryFailure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::EntryTestResult as EntryTestResultLatest;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EntryTestResult {
|
||||
pub can_connect: bool,
|
||||
pub can_route: bool,
|
||||
}
|
||||
|
||||
impl From<EntryTestResultLatest> for EntryTestResult {
|
||||
fn from(value: EntryTestResultLatest) -> Self {
|
||||
Self {
|
||||
can_connect: value.can_connect,
|
||||
can_route: value.can_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct Exit {
|
||||
pub can_connect: bool,
|
||||
pub can_route_ip_v4: bool,
|
||||
pub can_route_ip_external_v4: bool,
|
||||
pub can_route_ip_v6: bool,
|
||||
pub can_route_ip_external_v6: bool,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::Exit as ExitLatest;
|
||||
|
||||
impl From<ExitLatest> for Exit {
|
||||
fn from(value: ExitLatest) -> Self {
|
||||
Self {
|
||||
can_connect: value.can_connect,
|
||||
can_route_ip_v4: value.can_route_ip_v4,
|
||||
can_route_ip_external_v4: value.can_route_ip_external_v4,
|
||||
can_route_ip_v6: value.can_route_ip_v6,
|
||||
can_route_ip_external_v6: value.can_route_ip_external_v6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct WgProbeResults {
|
||||
// mandatory fields
|
||||
pub can_register: bool,
|
||||
pub can_handshake: Option<bool>,
|
||||
pub can_resolve_dns: Option<bool>,
|
||||
pub ping_hosts_performance: Option<f32>,
|
||||
pub ping_ips_performance: Option<f32>,
|
||||
|
||||
pub can_query_metadata_v4: Option<bool>,
|
||||
pub can_handshake_v4: bool,
|
||||
pub can_resolve_dns_v4: bool,
|
||||
pub ping_hosts_performance_v4: f32,
|
||||
pub ping_ips_performance_v4: f32,
|
||||
|
||||
pub can_handshake_v6: bool,
|
||||
pub can_resolve_dns_v6: bool,
|
||||
pub ping_hosts_performance_v6: f32,
|
||||
pub ping_ips_performance_v6: f32,
|
||||
|
||||
pub download_duration_sec_v4: u64,
|
||||
pub download_duration_milliseconds_v4: Option<u64>,
|
||||
pub downloaded_file_size_bytes_v4: Option<u64>,
|
||||
pub downloaded_file_v4: String,
|
||||
pub download_error_v4: String,
|
||||
|
||||
pub download_duration_sec_v6: u64,
|
||||
pub download_duration_milliseconds_v6: Option<u64>,
|
||||
pub downloaded_file_size_bytes_v6: Option<u64>,
|
||||
pub downloaded_file_v6: String,
|
||||
pub download_error_v6: String,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::WgProbeResults as WgProbeResultsLatest;
|
||||
|
||||
use crate::http::models::Gateway;
|
||||
|
||||
impl From<WgProbeResultsLatest> for WgProbeResults {
|
||||
fn from(value: WgProbeResultsLatest) -> Self {
|
||||
Self {
|
||||
can_register: value.can_register,
|
||||
can_handshake: Some(value.can_handshake_v4),
|
||||
can_resolve_dns: Some(value.can_resolve_dns_v4),
|
||||
ping_hosts_performance: Some(value.ping_hosts_performance_v4),
|
||||
ping_ips_performance: Some(value.ping_ips_performance_v4),
|
||||
|
||||
can_query_metadata_v4: Some(value.can_query_metadata_v4),
|
||||
can_handshake_v4: value.can_handshake_v4,
|
||||
can_resolve_dns_v4: value.can_resolve_dns_v4,
|
||||
ping_hosts_performance_v4: value.ping_hosts_performance_v4,
|
||||
ping_ips_performance_v4: value.ping_ips_performance_v4,
|
||||
|
||||
can_handshake_v6: value.can_handshake_v6,
|
||||
can_resolve_dns_v6: value.can_resolve_dns_v6,
|
||||
ping_hosts_performance_v6: value.ping_hosts_performance_v6,
|
||||
ping_ips_performance_v6: value.ping_ips_performance_v6,
|
||||
|
||||
download_duration_sec_v4: value.download_duration_sec_v4,
|
||||
download_duration_milliseconds_v4: Some(value.download_duration_milliseconds_v4),
|
||||
downloaded_file_size_bytes_v4: Some(value.downloaded_file_size_bytes_v4),
|
||||
downloaded_file_v4: value.downloaded_file_v4,
|
||||
download_error_v4: value.download_error_v4,
|
||||
|
||||
download_duration_sec_v6: value.download_duration_sec_v6,
|
||||
download_duration_milliseconds_v6: Some(value.download_duration_milliseconds_v6),
|
||||
downloaded_file_size_bytes_v6: Some(value.downloaded_file_size_bytes_v6),
|
||||
downloaded_file_v6: value.downloaded_file_v6,
|
||||
download_error_v6: value.download_error_v6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NodeScore {
|
||||
download_speed_score: f64,
|
||||
ping_ips_score: f64,
|
||||
mixnet_performance: f64,
|
||||
}
|
||||
|
||||
impl NodeScore {
|
||||
// Weighted scoring: mixnet performance (40%), download speed (30%), ping performance (30%)
|
||||
fn calculate_weighted_score(&self) -> f64 {
|
||||
(self.mixnet_performance * 0.4)
|
||||
+ (self.download_speed_score * 0.3)
|
||||
+ (self.ping_ips_score * 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, EnumString)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[derive(PartialEq)]
|
||||
pub enum ScoreValue {
|
||||
Offline,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// calculates a visual score for the gateway using weighted metrics
|
||||
pub(super) fn calculate_score(gateway: &Gateway, probe_outcome: &LastProbeResult) -> ScoreValue {
|
||||
let mixnet_performance = gateway.performance as f64 / 100.0;
|
||||
|
||||
let node_score = probe_outcome
|
||||
.outcome
|
||||
.wg
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let ping_ips_performance = p.ping_ips_performance_v4 as f64;
|
||||
|
||||
let duration_sec =
|
||||
p.download_duration_milliseconds_v4
|
||||
.unwrap_or_else(|| p.download_duration_sec_v4 * 1000) as f64
|
||||
/ 1000f64;
|
||||
|
||||
// get the file size downloaded in bytes and convert to MB, or default to 1MB
|
||||
let file_size_mb =
|
||||
p.downloaded_file_size_bytes_v4.unwrap_or(1048576) as f64 / 1024f64 / 1024f64;
|
||||
let speed_mbps = file_size_mb / duration_sec;
|
||||
|
||||
let file_download_score = if speed_mbps > 5.0 {
|
||||
1.0
|
||||
} else if speed_mbps > 2.0 {
|
||||
0.75
|
||||
} else if speed_mbps > 1.0 {
|
||||
0.5
|
||||
} else if speed_mbps > 0.5 {
|
||||
0.25
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
|
||||
NodeScore {
|
||||
download_speed_score: file_download_score,
|
||||
ping_ips_score: ping_ips_performance,
|
||||
mixnet_performance,
|
||||
}
|
||||
})
|
||||
.unwrap_or(NodeScore {
|
||||
download_speed_score: 0.0,
|
||||
ping_ips_score: 0.0,
|
||||
mixnet_performance,
|
||||
});
|
||||
|
||||
let weighted_score = node_score.calculate_weighted_score();
|
||||
|
||||
if weighted_score > 0.75 {
|
||||
ScoreValue::High
|
||||
} else if weighted_score > 0.5 {
|
||||
ScoreValue::Medium
|
||||
} else if weighted_score > 0.1 {
|
||||
ScoreValue::Low
|
||||
} else {
|
||||
ScoreValue::Offline
|
||||
}
|
||||
}
|
||||
|
||||
/// calculates a visual load score for the gateway
|
||||
pub(super) fn calculate_load(probe_outcome: &LastProbeResult) -> ScoreValue {
|
||||
let score = probe_outcome
|
||||
.outcome
|
||||
.wg
|
||||
.clone()
|
||||
.map(|p| p.ping_ips_performance_v4 as f64)
|
||||
.unwrap_or(0f64);
|
||||
|
||||
if score > 0.8 {
|
||||
ScoreValue::Low
|
||||
} else if score > 0.4 {
|
||||
ScoreValue::Medium
|
||||
} else {
|
||||
ScoreValue::High
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Socks5 {
|
||||
pub can_proxy_https: bool,
|
||||
pub score: ScoreValue,
|
||||
pub errors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::Socks5ProbeResults as Socks5ProbeResultsLatest;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Socks5ProbeResults {
|
||||
/// whether we could establish a SOCKS5 proxy connection
|
||||
pub can_connect_socks5: bool,
|
||||
|
||||
/// HTTPS connectivity test
|
||||
pub https_connectivity: HttpsConnectivityResult,
|
||||
}
|
||||
|
||||
impl From<Socks5ProbeResultsLatest> for Socks5ProbeResults {
|
||||
fn from(value: Socks5ProbeResultsLatest) -> Self {
|
||||
Self {
|
||||
can_connect_socks5: value.can_connect_socks5(),
|
||||
https_connectivity: value.https_connectivity().clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
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)
|
||||
errors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
use nym_gateway_probe::types::HttpsConnectivityResult as HttpsConnectivityResultLatest;
|
||||
|
||||
impl From<HttpsConnectivityResultLatest> for HttpsConnectivityResult {
|
||||
fn from(value: HttpsConnectivityResultLatest) -> Self {
|
||||
Self {
|
||||
https_success: value.https_success(),
|
||||
https_status_code: value.https_status_code().cloned(),
|
||||
https_latency_ms: value.https_latency_ms().cloned(),
|
||||
endpoint_used: value.endpoint_used().cloned(),
|
||||
errors: value.errors().cloned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::http::models::{
|
||||
Gateway,
|
||||
gw_probe::{LastProbeResult, ScoreValue},
|
||||
};
|
||||
|
||||
pub(crate) fn calculate_socks5_percentiles(gateways: &[Gateway]) -> HashMap<String, ScoreValue> {
|
||||
let parsed_gateways = gateways
|
||||
.iter()
|
||||
// discard untested gateways
|
||||
.filter_map(|gw| {
|
||||
gw.last_probe_result
|
||||
.as_ref()
|
||||
.map(|res| (gw.gateway_identity_key.clone(), res))
|
||||
})
|
||||
// discard unparsable probe results (error)
|
||||
.filter_map(|(id, value)| {
|
||||
LastProbeResult::deserialize_with_fallback(value.to_owned())
|
||||
.inspect_err(|err| warn!("Failed to deserialize probe result: {err}"))
|
||||
.ok()
|
||||
.map(|parsed| (id, parsed))
|
||||
})
|
||||
.map(|(id, res)| {
|
||||
let latency = res
|
||||
.outcome
|
||||
.socks5
|
||||
// if socks5 is null, test failed or gw doesn't support it
|
||||
.and_then(|socks5| socks5.https_connectivity.https_latency_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
(id, latency)
|
||||
})
|
||||
.collect::<Vec<(_, _)>>();
|
||||
|
||||
score_from_sorted_latencies(parsed_gateways)
|
||||
}
|
||||
|
||||
/// Assigns a score to each gateway based on their relative latency compared to
|
||||
/// the whole set. Higher score = lower latency.
|
||||
///
|
||||
/// - latency == 0 => Offline
|
||||
/// - nonzero buckets:
|
||||
/// - High = lowest 50%
|
||||
/// - Medium = next 25%
|
||||
/// - Low = worst 25%
|
||||
pub fn score_from_sorted_latencies(gateways: Vec<(String, u64)>) -> HashMap<String, ScoreValue> {
|
||||
// sort ascending
|
||||
let mut gateways = gateways;
|
||||
gateways.sort_by_cached_key(|(_, latency)| *latency);
|
||||
|
||||
let (offline_gws, online_gws): (Vec<_>, Vec<_>) =
|
||||
gateways.into_iter().partition(|(_, latency)| *latency == 0);
|
||||
|
||||
let online_count = online_gws.len();
|
||||
|
||||
// x / 2 = 0.5x
|
||||
let high_end_idx = online_count.div_ceil(2);
|
||||
// 3x / 4 = 0.75x
|
||||
let medium_end_idx = online_count.saturating_mul(3).div_ceil(4);
|
||||
// `Low` gets assigned to everything else
|
||||
|
||||
let mut result = HashMap::new();
|
||||
|
||||
// first flag all the offline gateways
|
||||
for (id, _lat) in offline_gws {
|
||||
result.entry(id).or_insert(ScoreValue::Offline);
|
||||
}
|
||||
|
||||
// secondly go over remaining non-zero elements, assign into buckets
|
||||
for (idx, (id, _)) in online_gws.into_iter().enumerate() {
|
||||
let score = if idx < high_end_idx {
|
||||
ScoreValue::High
|
||||
} else if idx < medium_end_idx {
|
||||
ScoreValue::Medium
|
||||
} else {
|
||||
ScoreValue::Low
|
||||
};
|
||||
|
||||
result.entry(id).or_insert(score);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod socks5_score_calc_tests {
|
||||
// clippy complains despite imports being used
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
let result = score_from_sorted_latencies(vec![]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_offline() {
|
||||
let items = vec![
|
||||
("a".to_string(), 0),
|
||||
("b".to_string(), 0),
|
||||
("c".to_string(), 0),
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::Offline));
|
||||
assert_eq!(result.get("b"), Some(&ScoreValue::Offline));
|
||||
assert_eq!(result.get("c"), Some(&ScoreValue::Offline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_zero() {
|
||||
let items = vec![("a".to_string(), 0)];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::Offline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_nonzero() {
|
||||
// Single non-zero element: lowest 50% of 1 = ceil(1/2) = 1, so it's High
|
||||
let items = vec![("a".to_string(), 100)];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nonzero_elements() {
|
||||
// 2 non-zero: high_end = ceil(2/2) = 1, medium_end = ceil(6/4) = 2
|
||||
// idx 0 -> High, idx 1 -> Medium
|
||||
let items = vec![("a".to_string(), 100), ("b".to_string(), 200)];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("b"), Some(&ScoreValue::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_zeros_and_nonzeros() {
|
||||
// Zeros become Offline, non-zeros get percentile scores
|
||||
let items = vec![
|
||||
("offline1".to_string(), 0),
|
||||
("fast".to_string(), 50),
|
||||
("offline2".to_string(), 0),
|
||||
("slow".to_string(), 200),
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result.get("offline1"), Some(&ScoreValue::Offline));
|
||||
assert_eq!(result.get("offline2"), Some(&ScoreValue::Offline));
|
||||
// 2 non-zero: high_end = 1, medium_end = 2
|
||||
assert_eq!(result.get("fast"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("slow"), Some(&ScoreValue::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_input() {
|
||||
// Input is unsorted, function should work regardless
|
||||
let items = vec![
|
||||
("slow".to_string(), 300),
|
||||
("fast".to_string(), 100),
|
||||
("medium".to_string(), 200),
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
// 3 non-zero: high_end = ceil(3/2) = 2, medium_end = ceil(9/4) = 3
|
||||
// sorted: fast(100), medium(200), slow(300)
|
||||
// idx 0,1 -> High, idx 2 -> Medium
|
||||
assert_eq!(result.get("fast"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("medium"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("slow"), Some(&ScoreValue::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_keeps_first() {
|
||||
// Duplicate IDs: first occurrence is kept
|
||||
let items = vec![
|
||||
("dup".to_string(), 100), // first occurrence, fast
|
||||
("other".to_string(), 200),
|
||||
("dup".to_string(), 300), // duplicate, slow - should be ignored
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
// 3 non-zero: high_end = 2, medium_end = 3
|
||||
// sorted: dup(100), other(200), dup(300)
|
||||
// idx 0 -> High (dup first), idx 1 -> High (other), idx 2 -> Medium (dup second, ignored)
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result.get("dup"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("other"), Some(&ScoreValue::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_nonzero_clean_percentiles() {
|
||||
// 4 non-zero: high_end = ceil(4/2) = 2, medium_end = ceil(12/4) = 3
|
||||
// idx 0,1 -> High (50%), idx 2 -> Medium (25%), idx 3 -> Low (25%)
|
||||
let items = vec![
|
||||
("a".to_string(), 100),
|
||||
("b".to_string(), 200),
|
||||
("c".to_string(), 300),
|
||||
("d".to_string(), 400),
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("b"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("c"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("d"), Some(&ScoreValue::Low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_nonzero_clean_percentiles() {
|
||||
// 8 non-zero: high_end = ceil(8/2) = 4, medium_end = ceil(24/4) = 6
|
||||
// idx 0-3 -> High, idx 4-5 -> Medium, idx 6-7 -> Low
|
||||
let items: Vec<(String, u64)> = (1..=8)
|
||||
.map(|i| (format!("node{}", i), i as u64 * 100))
|
||||
.collect();
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.len(), 8);
|
||||
assert_eq!(result.get("node1"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("node2"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("node3"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("node4"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("node5"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("node6"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("node7"), Some(&ScoreValue::Low));
|
||||
assert_eq!(result.get("node8"), Some(&ScoreValue::Low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_nonzero_ceiling_division() {
|
||||
// 5 non-zero: high_end = ceil(5/2) = 3, medium_end = ceil(15/4) = 4
|
||||
// idx 0,1,2 -> High (3), idx 3 -> Medium (1), idx 4 -> Low (1)
|
||||
let items: Vec<(String, u64)> = (1..=5)
|
||||
.map(|i| (format!("n{}", i), i as u64 * 10))
|
||||
.collect();
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.get("n1"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n2"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n3"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n4"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("n5"), Some(&ScoreValue::Low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_nonzero_ceiling_division() {
|
||||
// 7 non-zero: high_end = ceil(7/2) = 4, medium_end = ceil(21/4) = 6
|
||||
// idx 0-3 -> High (4), idx 4-5 -> Medium (2), idx 6 -> Low (1)
|
||||
let items: Vec<(String, u64)> = (1..=7)
|
||||
.map(|i| (format!("n{}", i), i as u64 * 10))
|
||||
.collect();
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.get("n1"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n2"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n3"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n4"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("n5"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("n6"), Some(&ScoreValue::Medium));
|
||||
assert_eq!(result.get("n7"), Some(&ScoreValue::Low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_nonzero() {
|
||||
// 3 non-zero: high_end = ceil(3/2) = 2, medium_end = ceil(9/4) = 3
|
||||
// idx 0,1 -> High, idx 2 -> Medium (no Low bucket)
|
||||
let items = vec![
|
||||
("a".to_string(), 100),
|
||||
("b".to_string(), 200),
|
||||
("c".to_string(), 300),
|
||||
];
|
||||
let result = score_from_sorted_latencies(items);
|
||||
|
||||
assert_eq!(result.get("a"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("b"), Some(&ScoreValue::High));
|
||||
assert_eq!(result.get("c"), Some(&ScoreValue::Medium));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
use crate::http::models::Gateway;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_weighted_score_calculation() {
|
||||
// Helper function to create a test gateway
|
||||
fn create_test_gateway(performance: u8) -> Gateway {
|
||||
Gateway {
|
||||
gateway_identity_key: "test_key".to_string(),
|
||||
bonded: true,
|
||||
performance,
|
||||
self_described: None,
|
||||
explorer_pretty_bond: None,
|
||||
description: nym_node_requests::api::v1::node::models::NodeDescription {
|
||||
moniker: "test".to_string(),
|
||||
details: "test".to_string(),
|
||||
security_contact: "test@example.com".to_string(),
|
||||
website: "https://example.com".to_string(),
|
||||
},
|
||||
last_probe_result: None,
|
||||
last_probe_log: None,
|
||||
last_testrun_utc: None,
|
||||
last_updated_utc: "2025-10-10T00:00:00Z".to_string(),
|
||||
routing_score: 0.0,
|
||||
config_score: 0,
|
||||
bridges: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a test probe outcome
|
||||
fn create_test_probe_outcome(
|
||||
download_speed_mbps: f64,
|
||||
ping_ips_performance: f32,
|
||||
) -> LastProbeResult {
|
||||
let duration_sec = 1.0;
|
||||
let file_size_mb = download_speed_mbps;
|
||||
|
||||
LastProbeResult {
|
||||
node: "test_node".to_string(),
|
||||
used_entry: "test_entry".to_string(),
|
||||
outcome: ProbeOutcome {
|
||||
as_entry: Entry::Tested(EntryTestResult {
|
||||
can_connect: true,
|
||||
can_route: true,
|
||||
}),
|
||||
as_exit: None,
|
||||
wg: Some(WgProbeResults {
|
||||
can_register: true,
|
||||
can_handshake: Some(true),
|
||||
can_resolve_dns: Some(true),
|
||||
ping_hosts_performance: Some(ping_ips_performance),
|
||||
ping_ips_performance: Some(ping_ips_performance),
|
||||
can_query_metadata_v4: Some(true),
|
||||
can_handshake_v4: true,
|
||||
can_resolve_dns_v4: true,
|
||||
ping_hosts_performance_v4: ping_ips_performance,
|
||||
ping_ips_performance_v4: ping_ips_performance,
|
||||
can_handshake_v6: true,
|
||||
can_resolve_dns_v6: true,
|
||||
ping_hosts_performance_v6: ping_ips_performance,
|
||||
ping_ips_performance_v6: ping_ips_performance,
|
||||
download_duration_sec_v4: (duration_sec * 1000.0) as u64,
|
||||
download_duration_milliseconds_v4: Some((duration_sec * 1000.0) as u64),
|
||||
downloaded_file_size_bytes_v4: Some((file_size_mb * 1024.0 * 1024.0) as u64),
|
||||
downloaded_file_v4: "test".to_string(),
|
||||
download_error_v4: "".to_string(),
|
||||
download_duration_sec_v6: 0,
|
||||
download_duration_milliseconds_v6: Some(0),
|
||||
downloaded_file_size_bytes_v6: Some(0),
|
||||
downloaded_file_v6: "".to_string(),
|
||||
download_error_v6: "".to_string(),
|
||||
}),
|
||||
// TODO dz test this as well
|
||||
socks5: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Test case 1: Excellent node (should be High)
|
||||
let gateway = create_test_gateway(90); // 90% mixnet performance
|
||||
let probe = create_test_probe_outcome(6.0, 1.0); // 6 Mbps, 100% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::High, "Excellent node should be High");
|
||||
|
||||
// Test case 2: Good node (should be High with weighted scoring)
|
||||
let gateway = create_test_gateway(90); // 90% mixnet performance
|
||||
let probe = create_test_probe_outcome(3.0, 0.9); // 3 Mbps (0.75 score), 90% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(
|
||||
score,
|
||||
ScoreValue::High,
|
||||
"Good node should be High with weighted scoring"
|
||||
);
|
||||
|
||||
// Test case 3: Medium node
|
||||
let gateway = create_test_gateway(80); // 80% mixnet performance
|
||||
let probe = create_test_probe_outcome(1.5, 0.8); // 1.5 Mbps (0.5 score), 80% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Medium, "Medium node should be Medium");
|
||||
|
||||
// Test case 4: Poor node
|
||||
let gateway = create_test_gateway(60); // 60% mixnet performance
|
||||
let probe = create_test_probe_outcome(0.3, 0.3); // 0.3 Mbps (0.1 score), 30% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Low, "Poor node should be Low");
|
||||
|
||||
// Test case 5: Failed node
|
||||
let gateway = create_test_gateway(10); // 10% mixnet performance
|
||||
let probe = create_test_probe_outcome(0.1, 0.0); // 0.1 Mbps (0.1 score), 0% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Offline, "Failed node should be Offline");
|
||||
|
||||
// Test case 6: Edge case - just above threshold
|
||||
let gateway = create_test_gateway(76); // 76% mixnet performance
|
||||
let probe = create_test_probe_outcome(2.1, 0.75); // 2.1 Mbps (0.75 score), 75% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
// Weighted: (0.76 * 0.4) + (0.75 * 0.3) + (0.75 * 0.3) = 0.304 + 0.225 + 0.225 = 0.754
|
||||
assert_eq!(
|
||||
score,
|
||||
ScoreValue::High,
|
||||
"Edge case just above 0.75 threshold should be High"
|
||||
);
|
||||
}
|
||||
|
||||
/// Smoke test to ensure conversion from nym_gateway_probe crate's ProbeResult
|
||||
/// to this crate's ProbeResult doesn't silently drop or default fields when
|
||||
/// nym_gateway_probe types change.
|
||||
///
|
||||
/// All values are set to non-default (booleans=true, numbers non-zero, strings non-empty)
|
||||
/// to catch cases where new fields might be left as default after conversion.
|
||||
#[test]
|
||||
fn conversion_from_gw_probe_latest() {
|
||||
use nym_gateway_probe::types::{
|
||||
Entry as EntryLatest, EntryTestResult as EntryTestResultLatest, Exit as ExitLatest,
|
||||
ProbeOutcome as ProbeOutcomeLatest, ProbeResult as ProbeResultLatest,
|
||||
WgProbeResults as WgProbeResultsLatest,
|
||||
};
|
||||
|
||||
// Build a ProbeResultLatest with ALL non-default values
|
||||
let wg_latest = WgProbeResultsLatest {
|
||||
can_register: true,
|
||||
can_query_metadata_v4: true,
|
||||
can_handshake_v4: true,
|
||||
can_resolve_dns_v4: true,
|
||||
ping_hosts_performance_v4: 0.95,
|
||||
ping_ips_performance_v4: 0.92,
|
||||
can_handshake_v6: true,
|
||||
can_resolve_dns_v6: true,
|
||||
ping_hosts_performance_v6: 0.88,
|
||||
ping_ips_performance_v6: 0.85,
|
||||
download_duration_sec_v4: 5,
|
||||
download_duration_milliseconds_v4: 5123,
|
||||
downloaded_file_size_bytes_v4: 10485760,
|
||||
downloaded_file_v4: "test-file-v4.bin".to_string(),
|
||||
download_error_v4: "none-v4".to_string(),
|
||||
download_duration_sec_v6: 6,
|
||||
download_duration_milliseconds_v6: 6234,
|
||||
downloaded_file_size_bytes_v6: 20971520,
|
||||
downloaded_file_v6: "test-file-v6.bin".to_string(),
|
||||
download_error_v6: "none-v6".to_string(),
|
||||
};
|
||||
let probe_latest = ProbeResultLatest {
|
||||
node: "test-node-identity-key".to_string(),
|
||||
used_entry: "test-entry-node".to_string(),
|
||||
outcome: ProbeOutcomeLatest {
|
||||
as_entry: EntryLatest::Tested(EntryTestResultLatest {
|
||||
can_connect: true,
|
||||
can_route: true,
|
||||
}),
|
||||
as_exit: Some(ExitLatest {
|
||||
can_connect: true,
|
||||
can_route_ip_v4: true,
|
||||
can_route_ip_external_v4: true,
|
||||
can_route_ip_v6: true,
|
||||
can_route_ip_external_v6: true,
|
||||
}),
|
||||
socks5: None,
|
||||
lp: None,
|
||||
wg: Some(wg_latest.clone()),
|
||||
},
|
||||
};
|
||||
|
||||
// convert to this crate's LastProbeResult
|
||||
let result: LastProbeResult = probe_latest.clone().into();
|
||||
|
||||
assert_eq!(result.node, probe_latest.node);
|
||||
assert_eq!(result.used_entry, probe_latest.used_entry);
|
||||
|
||||
match &result.outcome.as_entry {
|
||||
Entry::Tested(entry) => {
|
||||
assert!(entry.can_connect);
|
||||
assert!(entry.can_route);
|
||||
}
|
||||
other => panic!("Expected Entry::Tested, got {:?}", other),
|
||||
}
|
||||
|
||||
// Exit conversion
|
||||
let exit = result
|
||||
.outcome
|
||||
.as_exit
|
||||
.as_ref()
|
||||
.expect("as_exit should be Some");
|
||||
assert!(exit.can_connect);
|
||||
assert!(exit.can_route_ip_v4);
|
||||
assert!(exit.can_route_ip_external_v4,);
|
||||
assert!(exit.can_route_ip_v6);
|
||||
assert!(exit.can_route_ip_external_v6,);
|
||||
|
||||
// WgProbeResults conversion
|
||||
let wg = result.outcome.wg.as_ref().expect("wg should be Some");
|
||||
assert!(wg.can_register);
|
||||
assert_eq!(wg.can_query_metadata_v4, Some(true),);
|
||||
assert!(wg.can_handshake_v4);
|
||||
assert!(wg.can_resolve_dns_v4,);
|
||||
assert_eq!(
|
||||
wg.ping_hosts_performance_v4,
|
||||
wg_latest.ping_hosts_performance_v4
|
||||
);
|
||||
assert_eq!(
|
||||
wg.ping_ips_performance_v4,
|
||||
wg_latest.ping_ips_performance_v4
|
||||
);
|
||||
assert!(wg.can_handshake_v6);
|
||||
assert!(wg.can_resolve_dns_v6);
|
||||
assert_eq!(
|
||||
wg.ping_hosts_performance_v6,
|
||||
wg_latest.ping_hosts_performance_v6
|
||||
);
|
||||
assert_eq!(
|
||||
wg.ping_ips_performance_v6,
|
||||
wg_latest.ping_ips_performance_v6
|
||||
);
|
||||
assert_eq!(
|
||||
wg.download_duration_sec_v4,
|
||||
wg_latest.download_duration_sec_v4
|
||||
);
|
||||
assert_eq!(
|
||||
wg.download_duration_milliseconds_v4,
|
||||
Some(wg_latest.download_duration_milliseconds_v4),
|
||||
);
|
||||
assert_eq!(
|
||||
wg.downloaded_file_size_bytes_v4,
|
||||
Some(wg_latest.downloaded_file_size_bytes_v4),
|
||||
);
|
||||
assert_eq!(wg.downloaded_file_v4, wg_latest.downloaded_file_v4);
|
||||
assert_eq!(wg.download_error_v4, wg_latest.download_error_v4);
|
||||
assert_eq!(
|
||||
wg.download_duration_sec_v6,
|
||||
wg_latest.download_duration_sec_v6
|
||||
);
|
||||
assert_eq!(
|
||||
wg.download_duration_milliseconds_v6,
|
||||
Some(wg_latest.download_duration_milliseconds_v6)
|
||||
);
|
||||
assert_eq!(
|
||||
wg.downloaded_file_size_bytes_v6,
|
||||
Some(wg_latest.downloaded_file_size_bytes_v6)
|
||||
);
|
||||
assert_eq!(wg.downloaded_file_v6, wg_latest.downloaded_file_v6);
|
||||
assert_eq!(wg.download_error_v6, wg_latest.download_error_v6);
|
||||
|
||||
// fields that map from v4 values
|
||||
assert_eq!(wg.can_handshake, Some(true));
|
||||
assert_eq!(wg.can_resolve_dns, Some(true));
|
||||
assert_eq!(
|
||||
wg.ping_hosts_performance,
|
||||
Some(wg_latest.ping_hosts_performance_v4)
|
||||
);
|
||||
assert_eq!(
|
||||
wg.ping_ips_performance,
|
||||
Some(wg_latest.ping_ips_performance_v4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_entry_variants() {
|
||||
use nym_gateway_probe::types::Entry as EntryLatest;
|
||||
|
||||
let not_tested: Entry = EntryLatest::NotTested.into();
|
||||
assert!(matches!(not_tested, Entry::NotTested));
|
||||
|
||||
let failure: Entry = EntryLatest::EntryFailure.into();
|
||||
assert!(matches!(failure, Entry::EntryFailure));
|
||||
}
|
||||
|
||||
/// Backwards compatibility: this crate's struct may be present in DB of
|
||||
/// some gateways even after the new nym_gateway_probe format is published.
|
||||
/// DB entry needs to stay deserializable into a valid struct.
|
||||
#[test]
|
||||
fn deserialize_this_crate_format() {
|
||||
// JSON that matches this crate's ProbeResult format (not nym_gateway_probe)
|
||||
let old_format_json = serde_json::json!({
|
||||
"node": "old-node-key",
|
||||
"used_entry": "old-entry-key",
|
||||
"outcome": {
|
||||
"as_entry": {
|
||||
"can_connect": true,
|
||||
"can_route": false
|
||||
},
|
||||
"as_exit": null,
|
||||
"wg": null
|
||||
}
|
||||
});
|
||||
|
||||
let result = LastProbeResult::deserialize_with_fallback(old_format_json)
|
||||
.expect("Should deserialize old format");
|
||||
|
||||
assert_eq!(result.node, "old-node-key");
|
||||
assert_eq!(result.used_entry, "old-entry-key");
|
||||
match &result.outcome.as_entry {
|
||||
Entry::Tested(entry) => {
|
||||
assert!(entry.can_connect);
|
||||
assert!(!entry.can_route);
|
||||
}
|
||||
other => panic!("Expected Entry::Tested, got {:?}", other),
|
||||
}
|
||||
assert!(result.outcome.as_exit.is_none());
|
||||
assert!(result.outcome.wg.is_none());
|
||||
}
|
||||
|
||||
/// Test that the latest nym_gateway_probe format deserializes correctly
|
||||
#[test]
|
||||
fn deserialize_latest_gw_probe_format() {
|
||||
// JSON that matches nym_gateway_probe::types::ProbeResult format
|
||||
let latest_format_json = serde_json::json!({
|
||||
"node": "new-node-key",
|
||||
"used_entry": "new-entry-key",
|
||||
"outcome": {
|
||||
"as_entry": {
|
||||
"can_connect": true,
|
||||
"can_route": true
|
||||
},
|
||||
"as_exit": {
|
||||
"can_connect": true,
|
||||
"can_route_ip_v4": true,
|
||||
"can_route_ip_external_v4": true,
|
||||
"can_route_ip_v6": false,
|
||||
"can_route_ip_external_v6": false
|
||||
},
|
||||
"socks5": null,
|
||||
"lp": null,
|
||||
"wg": {
|
||||
"can_register": true,
|
||||
"can_query_metadata_v4": true,
|
||||
"can_handshake_v4": true,
|
||||
"can_resolve_dns_v4": true,
|
||||
"ping_hosts_performance_v4": 0.9,
|
||||
"ping_ips_performance_v4": 0.85,
|
||||
"can_handshake_v6": false,
|
||||
"can_resolve_dns_v6": false,
|
||||
"ping_hosts_performance_v6": 0.0,
|
||||
"ping_ips_performance_v6": 0.0,
|
||||
"download_duration_sec_v4": 3,
|
||||
"download_duration_milliseconds_v4": 3456,
|
||||
"downloaded_file_size_bytes_v4": 5242880,
|
||||
"downloaded_file_v4": "5mb.bin",
|
||||
"download_error_v4": "",
|
||||
"download_duration_sec_v6": 0,
|
||||
"download_duration_milliseconds_v6": 0,
|
||||
"downloaded_file_size_bytes_v6": 0,
|
||||
"downloaded_file_v6": "",
|
||||
"download_error_v6": "ipv6 not supported"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = LastProbeResult::deserialize_with_fallback(latest_format_json)
|
||||
.expect("Should deserialize latest format");
|
||||
|
||||
assert_eq!(result.node, "new-node-key");
|
||||
assert_eq!(result.used_entry, "new-entry-key");
|
||||
|
||||
let exit = result.outcome.as_exit.as_ref().expect("should have exit");
|
||||
assert!(exit.can_route_ip_external_v4);
|
||||
assert!(!exit.can_route_ip_external_v6);
|
||||
|
||||
let wg = result.outcome.wg.as_ref().expect("should have wg");
|
||||
assert!(wg.can_register);
|
||||
assert_eq!(wg.download_duration_milliseconds_v4, Some(3456));
|
||||
assert_eq!(wg.download_error_v6, "ipv6 not supported");
|
||||
}
|
||||
|
||||
/// Serialize LastProbeResult to JSON and back to ensure serde attributes
|
||||
/// work correctly and no data is lost.
|
||||
#[test]
|
||||
fn round_trip_serialization() {
|
||||
let original = LastProbeResult {
|
||||
node: "round-trip-node".to_string(),
|
||||
used_entry: "round-trip-entry".to_string(),
|
||||
outcome: ProbeOutcome {
|
||||
as_entry: Entry::Tested(EntryTestResult {
|
||||
can_connect: true,
|
||||
can_route: true,
|
||||
}),
|
||||
as_exit: Some(Exit {
|
||||
can_connect: true,
|
||||
can_route_ip_v4: true,
|
||||
can_route_ip_external_v4: true,
|
||||
can_route_ip_v6: true,
|
||||
can_route_ip_external_v6: true,
|
||||
}),
|
||||
wg: Some(WgProbeResults {
|
||||
can_register: true,
|
||||
can_handshake: Some(true),
|
||||
can_resolve_dns: Some(true),
|
||||
ping_hosts_performance: Some(0.95),
|
||||
ping_ips_performance: Some(0.92),
|
||||
can_query_metadata_v4: Some(true),
|
||||
can_handshake_v4: true,
|
||||
can_resolve_dns_v4: true,
|
||||
ping_hosts_performance_v4: 0.95,
|
||||
ping_ips_performance_v4: 0.92,
|
||||
can_handshake_v6: true,
|
||||
can_resolve_dns_v6: true,
|
||||
ping_hosts_performance_v6: 0.88,
|
||||
ping_ips_performance_v6: 0.85,
|
||||
download_duration_sec_v4: 5,
|
||||
download_duration_milliseconds_v4: Some(5123),
|
||||
downloaded_file_size_bytes_v4: Some(10485760),
|
||||
downloaded_file_v4: "test-file-v4.bin".to_string(),
|
||||
download_error_v4: "none-v4".to_string(),
|
||||
download_duration_sec_v6: 6,
|
||||
download_duration_milliseconds_v6: Some(6234),
|
||||
downloaded_file_size_bytes_v6: Some(20971520),
|
||||
downloaded_file_v6: "test-file-v6.bin".to_string(),
|
||||
download_error_v6: "none-v6".to_string(),
|
||||
}),
|
||||
// TODO dz test this as well
|
||||
socks5: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Serialize to JSON
|
||||
let json_string =
|
||||
serde_json::to_string(&original).expect("Should serialize LastProbeResult to JSON");
|
||||
|
||||
// Deserialize back
|
||||
let deserialized: LastProbeResult =
|
||||
serde_json::from_str(&json_string).expect("Should deserialize JSON to LastProbeResult");
|
||||
|
||||
// Verify top-level fields
|
||||
assert_eq!(deserialized.node, original.node);
|
||||
assert_eq!(deserialized.used_entry, original.used_entry);
|
||||
|
||||
// Verify Entry
|
||||
match (&original.outcome.as_entry, &deserialized.outcome.as_entry) {
|
||||
(Entry::Tested(orig), Entry::Tested(deser)) => {
|
||||
assert_eq!(orig.can_connect, deser.can_connect);
|
||||
assert_eq!(orig.can_route, deser.can_route);
|
||||
}
|
||||
_ => panic!("Entry mismatch after round-trip"),
|
||||
}
|
||||
|
||||
// Verify Exit
|
||||
let orig_exit = original.outcome.as_exit.as_ref().unwrap();
|
||||
let deser_exit = deserialized.outcome.as_exit.as_ref().unwrap();
|
||||
assert_eq!(orig_exit.can_connect, deser_exit.can_connect);
|
||||
assert_eq!(orig_exit.can_route_ip_v4, deser_exit.can_route_ip_v4);
|
||||
assert_eq!(
|
||||
orig_exit.can_route_ip_external_v4,
|
||||
deser_exit.can_route_ip_external_v4
|
||||
);
|
||||
assert_eq!(orig_exit.can_route_ip_v6, deser_exit.can_route_ip_v6);
|
||||
assert_eq!(
|
||||
orig_exit.can_route_ip_external_v6,
|
||||
deser_exit.can_route_ip_external_v6
|
||||
);
|
||||
|
||||
// Verify WgProbeResults
|
||||
let orig_wg = original.outcome.wg.as_ref().unwrap();
|
||||
let deser_wg = deserialized.outcome.wg.as_ref().unwrap();
|
||||
assert_eq!(orig_wg.can_register, deser_wg.can_register);
|
||||
assert_eq!(orig_wg.can_handshake, deser_wg.can_handshake);
|
||||
assert_eq!(orig_wg.can_resolve_dns, deser_wg.can_resolve_dns);
|
||||
assert_eq!(
|
||||
orig_wg.ping_hosts_performance,
|
||||
deser_wg.ping_hosts_performance
|
||||
);
|
||||
assert_eq!(orig_wg.ping_ips_performance, deser_wg.ping_ips_performance);
|
||||
assert_eq!(
|
||||
orig_wg.can_query_metadata_v4,
|
||||
deser_wg.can_query_metadata_v4
|
||||
);
|
||||
assert_eq!(orig_wg.can_handshake_v4, deser_wg.can_handshake_v4);
|
||||
assert_eq!(orig_wg.can_resolve_dns_v4, deser_wg.can_resolve_dns_v4);
|
||||
assert_eq!(
|
||||
orig_wg.ping_hosts_performance_v4,
|
||||
deser_wg.ping_hosts_performance_v4
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.ping_ips_performance_v4,
|
||||
deser_wg.ping_ips_performance_v4
|
||||
);
|
||||
assert_eq!(orig_wg.can_handshake_v6, deser_wg.can_handshake_v6);
|
||||
assert_eq!(orig_wg.can_resolve_dns_v6, deser_wg.can_resolve_dns_v6);
|
||||
assert_eq!(
|
||||
orig_wg.ping_hosts_performance_v6,
|
||||
deser_wg.ping_hosts_performance_v6
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.ping_ips_performance_v6,
|
||||
deser_wg.ping_ips_performance_v6
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.download_duration_sec_v4,
|
||||
deser_wg.download_duration_sec_v4
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.download_duration_milliseconds_v4,
|
||||
deser_wg.download_duration_milliseconds_v4
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.downloaded_file_size_bytes_v4,
|
||||
deser_wg.downloaded_file_size_bytes_v4
|
||||
);
|
||||
assert_eq!(orig_wg.downloaded_file_v4, deser_wg.downloaded_file_v4);
|
||||
assert_eq!(orig_wg.download_error_v4, deser_wg.download_error_v4);
|
||||
assert_eq!(
|
||||
orig_wg.download_duration_sec_v6,
|
||||
deser_wg.download_duration_sec_v6
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.download_duration_milliseconds_v6,
|
||||
deser_wg.download_duration_milliseconds_v6
|
||||
);
|
||||
assert_eq!(
|
||||
orig_wg.downloaded_file_size_bytes_v6,
|
||||
deser_wg.downloaded_file_size_bytes_v6
|
||||
);
|
||||
assert_eq!(orig_wg.downloaded_file_v6, deser_wg.downloaded_file_v6);
|
||||
assert_eq!(orig_wg.download_error_v6, deser_wg.download_error_v6);
|
||||
}
|
||||
+27
-358
@@ -1,6 +1,11 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::monitor::ExplorerPrettyBond;
|
||||
use crate::{
|
||||
http::models::gw_probe::{
|
||||
DvpnGwProbe, DvpnProbeOutcome, LastProbeResult, ScoreValue, calculate_load, calculate_score,
|
||||
},
|
||||
monitor::ExplorerPrettyBond,
|
||||
};
|
||||
use cosmwasm_std::{Addr, Coin, Decimal};
|
||||
use nym_mixnet_contract_common::CoinSchema;
|
||||
use nym_node_requests::api::v1::node::models::NodeDescription;
|
||||
@@ -17,8 +22,11 @@ use utoipa::ToSchema;
|
||||
|
||||
use crate::db::models::NymNodeDataDeHelper;
|
||||
use crate::node_scraper::models::BridgeInformation;
|
||||
|
||||
pub(crate) use nym_node_status_client::models::TestrunAssignment;
|
||||
|
||||
pub(crate) mod gw_probe;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Gateway {
|
||||
pub gateway_identity_key: String,
|
||||
@@ -75,17 +83,6 @@ pub struct Location {
|
||||
pub asn: Option<Asn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, EnumString)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[derive(PartialEq)]
|
||||
pub enum ScoreValue {
|
||||
Offline,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DVpnGatewayPerformance {
|
||||
last_updated_utc: String,
|
||||
@@ -103,7 +100,7 @@ pub struct DVpnGateway {
|
||||
pub ip_packet_router: Option<IpPacketRouterDetailsV1>,
|
||||
pub authenticator: Option<AuthenticatorDetailsV1>,
|
||||
pub location: Location,
|
||||
pub last_probe: Option<DirectoryGwProbe>,
|
||||
pub last_probe: Option<DvpnGwProbe>,
|
||||
#[schema(value_type = Vec<String>)]
|
||||
pub ip_addresses: Vec<IpAddr>,
|
||||
pub mix_port: u16,
|
||||
@@ -126,124 +123,25 @@ impl DVpnGateway {
|
||||
pub fn can_route_entry(&self) -> bool {
|
||||
self.last_probe
|
||||
.as_ref()
|
||||
.map(|probe| match &probe.outcome.as_entry {
|
||||
directory_gw_probe_outcome::Entry::Tested(entry_test_result) => {
|
||||
entry_test_result.can_route
|
||||
}
|
||||
directory_gw_probe_outcome::Entry::NotTested
|
||||
| directory_gw_probe_outcome::Entry::EntryFailure => false,
|
||||
})
|
||||
.map(DvpnGwProbe::can_route_entry)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn can_route_exit(&self) -> bool {
|
||||
self.last_probe
|
||||
.as_ref()
|
||||
.map(|probe| {
|
||||
probe
|
||||
.outcome
|
||||
.as_exit
|
||||
.as_ref()
|
||||
.map(|outcome| {
|
||||
outcome.can_route_ip_external_v4 && outcome.can_route_ip_external_v6
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|probe| probe.can_route_exit().unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// based on
|
||||
/// https://github.com/nymtech/nym-vpn-client/blob/nym-vpn-core-v1.10.0/nym-vpn-core/crates/nym-gateway-probe/src/types.rs
|
||||
/// TODO: long term types should be moved into this repo because nym-vpn-client
|
||||
/// could pull it as a dependency and we'd have a single source of truth
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct LastProbeResult {
|
||||
node: String,
|
||||
used_entry: String,
|
||||
outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DirectoryGwProbe {
|
||||
last_updated_utc: String,
|
||||
outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct ProbeOutcome {
|
||||
as_entry: directory_gw_probe_outcome::Entry,
|
||||
as_exit: Option<directory_gw_probe_outcome::Exit>,
|
||||
wg: Option<wg_outcome_versions::ProbeOutcomeV1>,
|
||||
}
|
||||
|
||||
pub mod directory_gw_probe_outcome {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum Entry {
|
||||
Tested(EntryTestResult),
|
||||
NotTested,
|
||||
EntryFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EntryTestResult {
|
||||
pub can_connect: bool,
|
||||
pub can_route: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct Exit {
|
||||
pub can_connect: bool,
|
||||
pub can_route_ip_v4: bool,
|
||||
pub can_route_ip_external_v4: bool,
|
||||
pub can_route_ip_v6: bool,
|
||||
pub can_route_ip_external_v6: bool,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod wg_outcome_versions {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
pub struct ProbeOutcomeV1 {
|
||||
pub can_register: bool,
|
||||
pub can_handshake: Option<bool>,
|
||||
pub can_resolve_dns: Option<bool>,
|
||||
pub ping_hosts_performance: Option<f32>,
|
||||
pub ping_ips_performance: Option<f32>,
|
||||
|
||||
pub can_query_metadata_v4: Option<bool>,
|
||||
pub can_handshake_v4: bool,
|
||||
pub can_resolve_dns_v4: bool,
|
||||
pub ping_hosts_performance_v4: f32,
|
||||
pub ping_ips_performance_v4: f32,
|
||||
|
||||
pub can_handshake_v6: bool,
|
||||
pub can_resolve_dns_v6: bool,
|
||||
pub ping_hosts_performance_v6: f32,
|
||||
pub ping_ips_performance_v6: f32,
|
||||
|
||||
pub download_duration_sec_v4: u64,
|
||||
pub download_duration_milliseconds_v4: Option<u64>,
|
||||
pub downloaded_file_size_bytes_v4: Option<u64>,
|
||||
pub downloaded_file_v4: String,
|
||||
pub download_error_v4: String,
|
||||
|
||||
pub download_duration_sec_v6: u64,
|
||||
pub download_duration_milliseconds_v6: Option<u64>,
|
||||
pub downloaded_file_size_bytes_v6: Option<u64>,
|
||||
pub downloaded_file_v6: String,
|
||||
pub download_error_v6: String,
|
||||
}
|
||||
}
|
||||
|
||||
impl DVpnGateway {
|
||||
#[instrument(level = tracing::Level::INFO, name = "dvpn_gw_new", skip_all, fields(gateway_key = gateway.gateway_identity_key, node_id = skimmed_node.node_id))]
|
||||
pub(crate) fn new(gateway: Gateway, skimmed_node: &SkimmedNode) -> anyhow::Result<Self> {
|
||||
pub(crate) fn new(
|
||||
gateway: Gateway,
|
||||
skimmed_node: &SkimmedNode,
|
||||
socks5_score: Option<&ScoreValue>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let location = gateway
|
||||
.explorer_pretty_bond
|
||||
.clone()
|
||||
@@ -275,35 +173,22 @@ impl DVpnGateway {
|
||||
.ok()
|
||||
});
|
||||
|
||||
tracing::info!("🌈 gateway probe result: {:?}", gateway.last_probe_result);
|
||||
tracing::debug!("🌈 gateway probe result: {:?}", gateway.last_probe_result);
|
||||
|
||||
let (last_probe_result, performance_v2) = match gateway.last_probe_result {
|
||||
Some(ref value) => {
|
||||
let mut parsed = serde_json::from_value::<LastProbeResult>(value.clone())
|
||||
let parsed = LastProbeResult::deserialize_with_fallback(value.clone())
|
||||
.inspect_err(|err| {
|
||||
error!("Failed to deserialize probe result: {err}");
|
||||
})?;
|
||||
|
||||
parsed.outcome.wg = parsed.outcome.wg.clone().map(|mut wg| {
|
||||
if wg.can_handshake.is_none() {
|
||||
wg.can_handshake = Some(wg.can_handshake_v4);
|
||||
}
|
||||
if wg.can_resolve_dns.is_none() {
|
||||
wg.can_resolve_dns = Some(wg.can_resolve_dns_v4);
|
||||
}
|
||||
if wg.ping_hosts_performance.is_none() {
|
||||
wg.ping_hosts_performance = Some(wg.ping_hosts_performance_v4);
|
||||
}
|
||||
if wg.ping_ips_performance.is_none() {
|
||||
wg.ping_ips_performance = Some(wg.ping_ips_performance_v4);
|
||||
}
|
||||
wg
|
||||
});
|
||||
|
||||
tracing::info!("🌈 gateway probe parsed: {:?}", parsed);
|
||||
tracing::trace!("🌈 gateway probe parsed: {:?}", parsed);
|
||||
let mixnet_score = calculate_mixnet_score(&gateway);
|
||||
let score = calculate_score(&gateway, &parsed);
|
||||
let mut load = calculate_load(&parsed);
|
||||
let socks5_score = socks5_score.unwrap_or(&ScoreValue::Offline).to_owned();
|
||||
let dvpn_probe_result =
|
||||
DvpnProbeOutcome::from_raw_probe_outcome(parsed.outcome(), socks5_score);
|
||||
|
||||
// clamp the load value to offline, when the score is offline
|
||||
if score == ScoreValue::Offline {
|
||||
@@ -319,7 +204,7 @@ impl DVpnGateway {
|
||||
// the network monitor's measure is a good proxy for node uptime, it can be improved in the future
|
||||
uptime_percentage_last_24_hours: network_monitor_performance_mixnet_mode,
|
||||
};
|
||||
(Some(parsed), Some(performance_v2))
|
||||
(Some(dvpn_probe_result), Some(performance_v2))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
@@ -356,10 +241,8 @@ impl DVpnGateway {
|
||||
}
|
||||
}),
|
||||
},
|
||||
last_probe: last_probe_result.map(|res| DirectoryGwProbe {
|
||||
last_updated_utc: last_updated_utc.to_string(),
|
||||
outcome: res.outcome,
|
||||
}),
|
||||
last_probe: last_probe_result
|
||||
.map(|res| DvpnGwProbe::from_outcome(res, last_updated_utc)),
|
||||
ip_addresses: skimmed_node.ip_addresses.clone(),
|
||||
mix_port: skimmed_node.mix_port,
|
||||
role: skimmed_node.role.clone(),
|
||||
@@ -372,21 +255,6 @@ impl DVpnGateway {
|
||||
}
|
||||
}
|
||||
|
||||
struct NodeScore {
|
||||
download_speed_score: f64,
|
||||
ping_ips_score: f64,
|
||||
mixnet_performance: f64,
|
||||
}
|
||||
|
||||
impl NodeScore {
|
||||
// Weighted scoring: mixnet performance (40%), download speed (30%), ping performance (30%)
|
||||
fn calculate_weighted_score(&self) -> f64 {
|
||||
(self.mixnet_performance * 0.4)
|
||||
+ (self.download_speed_score * 0.3)
|
||||
+ (self.ping_ips_score * 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
/// calculates the gateway probe score for mixnet mode
|
||||
fn calculate_mixnet_score(gateway: &Gateway) -> ScoreValue {
|
||||
let mixnet_performance = gateway.performance as f64 / 100.0;
|
||||
@@ -402,82 +270,6 @@ fn calculate_mixnet_score(gateway: &Gateway) -> ScoreValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// calculates a visual score for the gateway using weighted metrics
|
||||
fn calculate_score(gateway: &Gateway, probe_outcome: &LastProbeResult) -> ScoreValue {
|
||||
let mixnet_performance = gateway.performance as f64 / 100.0;
|
||||
|
||||
let node_score = probe_outcome
|
||||
.outcome
|
||||
.wg
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let ping_ips_performance = p.ping_ips_performance_v4 as f64;
|
||||
|
||||
let duration_sec =
|
||||
p.download_duration_milliseconds_v4
|
||||
.unwrap_or_else(|| p.download_duration_sec_v4 * 1000) as f64
|
||||
/ 1000f64;
|
||||
|
||||
// get the file size downloaded in bytes and convert to MB, or default to 1MB
|
||||
let file_size_mb =
|
||||
p.downloaded_file_size_bytes_v4.unwrap_or(1048576) as f64 / 1024f64 / 1024f64;
|
||||
let speed_mbps = file_size_mb / duration_sec;
|
||||
|
||||
let file_download_score = if speed_mbps > 5.0 {
|
||||
1.0
|
||||
} else if speed_mbps > 2.0 {
|
||||
0.75
|
||||
} else if speed_mbps > 1.0 {
|
||||
0.5
|
||||
} else if speed_mbps > 0.5 {
|
||||
0.25
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
|
||||
NodeScore {
|
||||
download_speed_score: file_download_score,
|
||||
ping_ips_score: ping_ips_performance,
|
||||
mixnet_performance,
|
||||
}
|
||||
})
|
||||
.unwrap_or(NodeScore {
|
||||
download_speed_score: 0.0,
|
||||
ping_ips_score: 0.0,
|
||||
mixnet_performance,
|
||||
});
|
||||
|
||||
let weighted_score = node_score.calculate_weighted_score();
|
||||
|
||||
if weighted_score > 0.75 {
|
||||
ScoreValue::High
|
||||
} else if weighted_score > 0.5 {
|
||||
ScoreValue::Medium
|
||||
} else if weighted_score > 0.1 {
|
||||
ScoreValue::Low
|
||||
} else {
|
||||
ScoreValue::Offline
|
||||
}
|
||||
}
|
||||
|
||||
/// calculates a visual load score for the gateway
|
||||
fn calculate_load(probe_outcome: &LastProbeResult) -> ScoreValue {
|
||||
let score = probe_outcome
|
||||
.outcome
|
||||
.wg
|
||||
.clone()
|
||||
.map(|p| p.ping_ips_performance_v4 as f64)
|
||||
.unwrap_or(0f64);
|
||||
|
||||
if score > 0.8 {
|
||||
ScoreValue::Low
|
||||
} else if score > 0.4 {
|
||||
ScoreValue::Medium
|
||||
} else {
|
||||
ScoreValue::High
|
||||
}
|
||||
}
|
||||
|
||||
fn to_percent(performance: u8) -> String {
|
||||
let fraction = performance as f32 / 100.0;
|
||||
format!("{fraction:.2}")
|
||||
@@ -485,6 +277,7 @@ fn to_percent(performance: u8) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -756,130 +549,6 @@ mod test {
|
||||
assert!(service.mixnet_websockets.is_none());
|
||||
assert!(service.last_successful_ping_utc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weighted_score_calculation() {
|
||||
use crate::http::models::directory_gw_probe_outcome::EntryTestResult;
|
||||
use crate::http::models::wg_outcome_versions::ProbeOutcomeV1;
|
||||
|
||||
// Helper function to create a test gateway
|
||||
fn create_test_gateway(performance: u8) -> Gateway {
|
||||
Gateway {
|
||||
gateway_identity_key: "test_key".to_string(),
|
||||
bonded: true,
|
||||
performance,
|
||||
self_described: None,
|
||||
explorer_pretty_bond: None,
|
||||
description: nym_node_requests::api::v1::node::models::NodeDescription {
|
||||
moniker: "test".to_string(),
|
||||
details: "test".to_string(),
|
||||
security_contact: "test@example.com".to_string(),
|
||||
website: "https://example.com".to_string(),
|
||||
},
|
||||
last_probe_result: None,
|
||||
last_probe_log: None,
|
||||
last_testrun_utc: None,
|
||||
last_updated_utc: "2025-10-10T00:00:00Z".to_string(),
|
||||
routing_score: 0.0,
|
||||
config_score: 0,
|
||||
bridges: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a test probe outcome
|
||||
fn create_test_probe_outcome(
|
||||
download_speed_mbps: f64,
|
||||
ping_ips_performance: f32,
|
||||
) -> LastProbeResult {
|
||||
let duration_sec = 1.0;
|
||||
let file_size_mb = download_speed_mbps;
|
||||
|
||||
LastProbeResult {
|
||||
node: "test_node".to_string(),
|
||||
used_entry: "test_entry".to_string(),
|
||||
outcome: ProbeOutcome {
|
||||
as_entry: directory_gw_probe_outcome::Entry::Tested(EntryTestResult {
|
||||
can_connect: true,
|
||||
can_route: true,
|
||||
}),
|
||||
as_exit: None,
|
||||
wg: Some(ProbeOutcomeV1 {
|
||||
can_register: true,
|
||||
can_handshake: Some(true),
|
||||
can_resolve_dns: Some(true),
|
||||
ping_hosts_performance: Some(ping_ips_performance),
|
||||
ping_ips_performance: Some(ping_ips_performance),
|
||||
can_query_metadata_v4: Some(true),
|
||||
can_handshake_v4: true,
|
||||
can_resolve_dns_v4: true,
|
||||
ping_hosts_performance_v4: ping_ips_performance,
|
||||
ping_ips_performance_v4: ping_ips_performance,
|
||||
can_handshake_v6: true,
|
||||
can_resolve_dns_v6: true,
|
||||
ping_hosts_performance_v6: ping_ips_performance,
|
||||
ping_ips_performance_v6: ping_ips_performance,
|
||||
download_duration_sec_v4: (duration_sec * 1000.0) as u64,
|
||||
download_duration_milliseconds_v4: Some((duration_sec * 1000.0) as u64),
|
||||
downloaded_file_size_bytes_v4: Some(
|
||||
(file_size_mb * 1024.0 * 1024.0) as u64,
|
||||
),
|
||||
downloaded_file_v4: "test".to_string(),
|
||||
download_error_v4: "".to_string(),
|
||||
download_duration_sec_v6: 0,
|
||||
download_duration_milliseconds_v6: Some(0),
|
||||
downloaded_file_size_bytes_v6: Some(0),
|
||||
downloaded_file_v6: "".to_string(),
|
||||
download_error_v6: "".to_string(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Test case 1: Excellent node (should be High)
|
||||
let gateway = create_test_gateway(90); // 90% mixnet performance
|
||||
let probe = create_test_probe_outcome(6.0, 1.0); // 6 Mbps, 100% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::High, "Excellent node should be High");
|
||||
|
||||
// Test case 2: Good node (should be High with weighted scoring)
|
||||
let gateway = create_test_gateway(90); // 90% mixnet performance
|
||||
let probe = create_test_probe_outcome(3.0, 0.9); // 3 Mbps (0.75 score), 90% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(
|
||||
score,
|
||||
ScoreValue::High,
|
||||
"Good node should be High with weighted scoring"
|
||||
);
|
||||
|
||||
// Test case 3: Medium node
|
||||
let gateway = create_test_gateway(80); // 80% mixnet performance
|
||||
let probe = create_test_probe_outcome(1.5, 0.8); // 1.5 Mbps (0.5 score), 80% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Medium, "Medium node should be Medium");
|
||||
|
||||
// Test case 4: Poor node
|
||||
let gateway = create_test_gateway(60); // 60% mixnet performance
|
||||
let probe = create_test_probe_outcome(0.3, 0.3); // 0.3 Mbps (0.1 score), 30% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Low, "Poor node should be Low");
|
||||
|
||||
// Test case 5: Failed node
|
||||
let gateway = create_test_gateway(10); // 10% mixnet performance
|
||||
let probe = create_test_probe_outcome(0.1, 0.0); // 0.1 Mbps (0.1 score), 0% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
assert_eq!(score, ScoreValue::Offline, "Failed node should be Offline");
|
||||
|
||||
// Test case 6: Edge case - just above threshold
|
||||
let gateway = create_test_gateway(76); // 76% mixnet performance
|
||||
let probe = create_test_probe_outcome(2.1, 0.75); // 2.1 Mbps (0.75 score), 75% ping
|
||||
let score = calculate_score(&gateway, &probe);
|
||||
// Weighted: (0.76 * 0.4) + (0.75 * 0.3) + (0.75 * 0.3) = 0.304 + 0.225 + 0.225 = 0.754
|
||||
assert_eq!(
|
||||
score,
|
||||
ScoreValue::High,
|
||||
"Edge case just above 0.75 threshold should be High"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
|
||||
@@ -20,7 +20,10 @@ use crate::{
|
||||
db::{DbPool, queries},
|
||||
http::{
|
||||
error::{HttpError, HttpResult},
|
||||
models::{DVpnGateway, DailyStats, ExtendedNymNode, Gateway, NodeGeoData, SummaryHistory},
|
||||
models::{
|
||||
DVpnGateway, DailyStats, ExtendedNymNode, Gateway, NodeGeoData, SummaryHistory,
|
||||
gw_probe::socks5_calc::calculate_socks5_percentiles,
|
||||
},
|
||||
},
|
||||
monitor::{DelegationsCache, NodeGeoCache},
|
||||
};
|
||||
@@ -321,6 +324,8 @@ impl HttpCache {
|
||||
}
|
||||
};
|
||||
|
||||
let socks5_scores = calculate_socks5_percentiles(&gateways);
|
||||
|
||||
let res_gws = gateways
|
||||
.iter()
|
||||
.filter(|gw| gw.bonded)
|
||||
@@ -335,7 +340,7 @@ impl HttpCache {
|
||||
}
|
||||
})
|
||||
.filter_map(
|
||||
|(gw, skimmed_node)| match DVpnGateway::new(gw.clone(), skimmed_node) {
|
||||
|(gw, skimmed_node)| match DVpnGateway::new(gw.clone(), skimmed_node, socks5_scores.get(&gw.gateway_identity_key)) {
|
||||
Ok(gw) => Some(gw),
|
||||
Err(err) => {
|
||||
error!(
|
||||
|
||||
@@ -31,6 +31,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
if let Some(env_file) = &args.config_env_file {
|
||||
setup_env(Some(env_file));
|
||||
}
|
||||
let network = nym_network_defaults::NymNetworkDetails::new_from_env();
|
||||
|
||||
let mut shutdown_manager = ShutdownManager::build_new_default()?;
|
||||
|
||||
@@ -46,7 +47,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::debug!("Using config:\n{:#?}", args);
|
||||
}
|
||||
|
||||
let storage = db::Storage::init(connection_url, args.sqlx_busy_timeout_s).await?;
|
||||
let storage = db::Storage::init(
|
||||
connection_url,
|
||||
args.sqlx_busy_timeout_s,
|
||||
args.sqlx_max_connections,
|
||||
args.sqlx_min_connections,
|
||||
)
|
||||
.await?;
|
||||
let db_pool = storage.pool_owned();
|
||||
|
||||
// node geocache is shared between node monitor and HTTP server
|
||||
@@ -55,9 +62,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
.build();
|
||||
let delegations_cache = DelegationsCache::new();
|
||||
|
||||
let client_config = nym_validator_client::nyxd::Config::try_from_nym_network_details(
|
||||
&nym_network_defaults::NymNetworkDetails::new_from_env(),
|
||||
)?;
|
||||
let client_config = nym_validator_client::nyxd::Config::try_from_nym_network_details(&network)?;
|
||||
tracing::info!("Network: {}", network.network_name);
|
||||
|
||||
let nyxd_client = NyxdClient::connect(client_config.clone(), args.nyxd_addr.as_str())
|
||||
.map_err(|err| anyhow::anyhow!("Couldn't connect: {}", err))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user