diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index a5b069dccb..5ba0612c73 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 91a632d24f..845cb75272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index b52bcac5df..a9a240b504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 536412f580..72336bb532 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -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 = [ diff --git a/nym-gateway-probe/src/common/mod.rs b/nym-gateway-probe/src/common/mod.rs index 6276ec032c..718ab6d8f3 100644 --- a/nym-gateway-probe/src/common/mod.rs +++ b/nym-gateway-probe/src/common/mod.rs @@ -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; diff --git a/nym-gateway-probe/src/common/socks5_test/mod.rs b/nym-gateway-probe/src/common/socks5_test/mod.rs index 5ea4e361d1..61372f7c71 100644 --- a/nym-gateway-probe/src/common/socks5_test/mod.rs +++ b/nym-gateway-probe/src/common/socks5_test/mod.rs @@ -73,6 +73,7 @@ struct SingleHttpsTestResult { error: Option, } +#[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, /// error message(s) (if any) - error: Option>, + errors: Option>, } impl HttpsConnectivityResult { - pub fn with_errors(error: Vec) -> Self { + pub fn with_errors(errors: Vec) -> 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> { + self.errors.as_ref() + } } diff --git a/nym-gateway-probe/src/common/types.rs b/nym-gateway-probe/src/common/types.rs index f2027ec78f..34edadabfb 100644 --- a/nym-gateway-probe/src/common/types.rs +++ b/nym-gateway-probe/src/common/types.rs @@ -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, } +#[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, } +#[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)] diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index cbd388efeb..a694d6de86 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -27,6 +27,7 @@ use tracing::*; use url::Url; mod common; +pub use common::types; pub mod config; use crate::common::bandwidth_helpers::{ diff --git a/nym-node-status-api/nym-node-status-agent/Cargo.toml b/nym-node-status-api/nym-node-status-agent/Cargo.toml index 23817c5eb2..9c8daf7007 100644 --- a/nym-node-status-api/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-api/nym-node-status-agent/Cargo.toml @@ -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", diff --git a/nym-node-status-api/nym-node-status-agent/README.md b/nym-node-status-api/nym-node-status-agent/README.md index bf75649c25..c70732570e 100644 --- a/nym-node-status-api/nym-node-status-agent/README.md +++ b/nym-node-status-api/nym-node-status-agent/README.md @@ -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 diff --git a/nym-node-status-api/nym-node-status-agent/run.sh b/nym-node-status-api/nym-node-status-agent/run.sh index 2a5a0b5e41..99ebb1867a 100755 --- a/nym-node-status-api/nym-node-status-agent/run.sh +++ b/nym-node-status-api/nym-node-status-agent/run.sh @@ -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" diff --git a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs index 7d0e106b76..8f82428e75 100644 --- a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs +++ b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs @@ -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::(&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 = + 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() + } +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json deleted file mode 100644 index 068bc54b4b..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json deleted file mode 100644 index cdc057df57..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json deleted file mode 100644 index 32f3f9958c..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n DELETE FROM blinded_shares WHERE created < ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json deleted file mode 100644 index 9ef7d96cca..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json deleted file mode 100644 index 944c86e33e..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json deleted file mode 100644 index 9d8cff1326..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json deleted file mode 100644 index 77a99816d4..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json deleted file mode 100644 index 2f22ed93fa..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json deleted file mode 100644 index 84e8ba9f16..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json deleted file mode 100644 index 6cb8138cc5..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json deleted file mode 100644 index 03efdc4734..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json deleted file mode 100644 index 6b70481c75..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json deleted file mode 100644 index 9de4c7f230..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json deleted file mode 100644 index e5dd7c8413..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json deleted file mode 100644 index 340f83a280..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json deleted file mode 100644 index 449bc6869f..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json deleted file mode 100644 index c78ff30f50..0000000000 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json +++ /dev/null @@ -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" -} diff --git a/nym-node-status-api/nym-node-status-api/Cargo.toml b/nym-node-status-api/nym-node-status-api/Cargo.toml index 28eb90c2c7..8d414c8817 100644 --- a/nym-node-status-api/nym-node-status-api/Cargo.toml +++ b/nym-node-status-api/nym-node-status-api/Cargo.toml @@ -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 } diff --git a/nym-node-status-api/nym-node-status-api/Dockerfile b/nym-node-status-api/nym-node-status-api/Dockerfile index 0c31f97e71..0b022bd350 100644 --- a/nym-node-status-api/nym-node-status-api/Dockerfile +++ b/nym-node-status-api/nym-node-status-api/Dockerfile @@ -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/ diff --git a/nym-node-status-api/nym-node-status-api/Makefile b/nym-node-status-api/nym-node-status-api/Makefile index 410e39661e..65e699d116 100644 --- a/nym-node-status-api/nym-node-status-api/Makefile +++ b/nym-node-status-api/nym-node-status-api/Makefile @@ -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 diff --git a/nym-node-status-api/nym-node-status-api/src/cli/mod.rs b/nym-node-status-api/nym-node-status-api/src/cli/mod.rs index 4883e95139..812f24dbf7 100644 --- a/nym-node-status-api/nym-node-status-api/src/cli/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/cli/mod.rs @@ -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", diff --git a/nym-node-status-api/nym-node-status-api/src/db/mod.rs b/nym-node-status-api/nym-node-status-api/src/db/mod.rs index 0205b4b64e..03fe202e3a 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/mod.rs @@ -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 { + pub async fn init( + connection_url: String, + acquire_timeout: Duration, + max_connections: u32, + min_connections: u32, + ) -> Result { 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))?; diff --git a/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/mod.rs b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/mod.rs new file mode 100644 index 0000000000..dbce0bdce6 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/mod.rs @@ -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 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 { + // first try matching latest struct from GW probe crate + let mut probe_result = match serde_json::from_value::(value.clone()) { + Ok(probe_result) => probe_result.into(), + // as a fallback, try parsing struct from this crate + Err(_) => match serde_json::from_value::(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 { + 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, + pub wg: Option, + pub socks5: Option, +} + +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, + pub wg: Option, + pub socks5: Option, +} + +use nym_gateway_probe::types::ProbeOutcome as ProbeOutcomeLatest; + +impl From 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 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 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 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, + pub can_resolve_dns: Option, + pub ping_hosts_performance: Option, + pub ping_ips_performance: Option, + + pub can_query_metadata_v4: Option, + 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, + pub downloaded_file_size_bytes_v4: Option, + pub downloaded_file_v4: String, + pub download_error_v4: String, + + pub download_duration_sec_v6: u64, + pub download_duration_milliseconds_v6: Option, + pub downloaded_file_size_bytes_v6: Option, + 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 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>, +} + +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 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, + + /// average HTTPS request latency in milliseconds + https_latency_ms: Option, + + /// among multiple endpoints available, list the one actually used + endpoint_used: Option, + + /// error message(s) (if any) + errors: Option>, +} + +use nym_gateway_probe::types::HttpsConnectivityResult as HttpsConnectivityResultLatest; + +impl From 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(), + } + } +} diff --git a/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/socks5_calc.rs b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/socks5_calc.rs new file mode 100644 index 0000000000..beb31a4ca7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/socks5_calc.rs @@ -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 { + 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::>(); + + 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 { + // 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)); + } +} diff --git a/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/test.rs b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/test.rs new file mode 100644 index 0000000000..66bae5605b --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/src/http/models/gw_probe/test.rs @@ -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); +} diff --git a/nym-node-status-api/nym-node-status-api/src/http/models.rs b/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs similarity index 61% rename from nym-node-status-api/nym-node-status-api/src/http/models.rs rename to nym-node-status-api/nym-node-status-api/src/http/models/mod.rs index cce5f0cf8b..6fd599ba6e 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/models.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs @@ -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, } -#[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, pub authenticator: Option, pub location: Location, - pub last_probe: Option, + pub last_probe: Option, #[schema(value_type = Vec)] pub ip_addresses: Vec, 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, - wg: Option, -} - -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, - pub can_resolve_dns: Option, - pub ping_hosts_performance: Option, - pub ping_ips_performance: Option, - - pub can_query_metadata_v4: Option, - 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, - pub downloaded_file_size_bytes_v4: Option, - pub downloaded_file_v4: String, - pub download_error_v4: String, - - pub download_duration_sec_v6: u64, - pub download_duration_milliseconds_v6: Option, - pub downloaded_file_size_bytes_v6: Option, - 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 { + pub(crate) fn new( + gateway: Gateway, + skimmed_node: &SkimmedNode, + socks5_score: Option<&ScoreValue>, + ) -> anyhow::Result { 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::(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)] diff --git a/nym-node-status-api/nym-node-status-api/src/http/state.rs b/nym-node-status-api/nym-node-status-api/src/http/state.rs index f03bfe0398..14bfc99c8d 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/state.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/state.rs @@ -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!( diff --git a/nym-node-status-api/nym-node-status-api/src/main.rs b/nym-node-status-api/nym-node-status-api/src/main.rs index ebb76373ac..352f310c01 100644 --- a/nym-node-status-api/nym-node-status-api/src/main.rs +++ b/nym-node-status-api/nym-node-status-api/src/main.rs @@ -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))?;