diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 063c6e242c..1546b9d4dc 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -90,7 +90,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: --workspace --all-targets --exclude nym-gateway-probe --exclude nym-node-status-api -- -D warnings + args: --workspace --all-targets --exclude nym-gateway-probe --exclude nym-node-status-api --exclude nym-node-status-agent --exclude nym-node-status-client -- -D warnings - name: Clippy (non-macos) if: contains(matrix.os, 'linux') || contains(matrix.os, 'windows') @@ -104,14 +104,15 @@ jobs: uses: actions-rs/cargo@v1 with: command: build + args: --workspace --exclude nym-gateway-probe --exclude nym-node-status-api --exclude nym-node-status-agent --exclude nym-node-status-client - # only build on linux because of wg FFI bindings of its dependency (network probe) - - name: Build nym-node-status-api (linux only) + # Build Go FFI-dependent crates separately (requires Go, only available on Linux CI) + - name: Build nym-node-status-api and nym-node-status-agent (linux only) if: runner.os == 'Linux' uses: actions-rs/cargo@v1 with: command: build - args: -p nym-node-status-api + args: -p nym-node-status-api -p nym-node-status-agent - name: Build all examples if: contains(matrix.os, 'linux') diff --git a/Cargo.lock b/Cargo.lock index 59c16f3898..883f876ab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6687,6 +6687,7 @@ version = "1.20.4" dependencies = [ "anyhow", "base64 0.22.1", + "bs58", "bytes", "clap", "futures", @@ -6711,7 +6712,6 @@ dependencies = [ "nym-lp", "nym-network-defaults", "nym-node-requests", - "nym-node-status-client", "nym-registration-client", "nym-registration-common", "nym-sdk", @@ -7471,14 +7471,16 @@ dependencies = [ [[package]] name = "nym-node-status-agent" -version = "1.1.5" +version = "2.0.0" dependencies = [ "anyhow", "clap", "futures", "nym-bin-common", "nym-crypto", + "nym-gateway-probe", "nym-node-status-client", + "nym-sdk", "rand 0.8.5", "regex", "serde_json", @@ -7490,7 +7492,7 @@ dependencies = [ [[package]] name = "nym-node-status-api" -version = "4.3.1" +version = "4.4.0" dependencies = [ "ammonia", "anyhow", @@ -7555,9 +7557,9 @@ version = "0.3.0" dependencies = [ "anyhow", "bincode", - "bs58", "nym-credentials", "nym-crypto", + "nym-gateway-probe", "reqwest 0.13.1", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index df0e4b6153..f559bae048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,7 +184,6 @@ default-members = [ "nym-api", "nym-credential-proxy/nym-credential-proxy", "nym-node", - "nym-node-status-api/nym-node-status-agent", "nym-statistics-api", "nym-validator-rewarder", "nyx-chain-watcher", diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 565c6ad6c3..684f54a288 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -13,20 +13,21 @@ publish = false workspace = true [dependencies] -anyhow.workspace = true -base64.workspace = true -bytes.workspace = true +anyhow = { workspace = true } +base64 = { workspace = true } +bs58 = { workspace = true } +bytes = { workspace = true } clap = { workspace = true, features = ["cargo", "derive"] } -futures.workspace = true -hex.workspace = true -tracing.workspace = true -pnet_packet.workspace = true -rand.workspace = true -rand09.workspace = true +futures = { workspace = true } +hex = { workspace = true } +tracing = { workspace = true } +pnet_packet = { workspace = true } +rand = { workspace = true } +rand09 = { workspace = true } reqwest = { workspace = true, features = ["socks"] } -serde.workspace = true -serde_json.workspace = true -semver.workspace = true +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = [ "process", @@ -61,7 +62,6 @@ nym-kkt-ciphersuite = { workspace = true } nym-lp = { path = "../common/nym-lp" } nym-network-defaults = { path = "../common/network-defaults" } nym-node-requests = { path = "../nym-node/nym-node-requests" } -nym-node-status-client = { path = "../nym-node-status-api/nym-node-status-client" } nym-registration-client = { path = "../nym-registration-client" } nym-registration-common = { path = "../common/registration" } nym-sdk = { workspace = true } diff --git a/nym-gateway-probe/src/common/bandwidth_helpers.rs b/nym-gateway-probe/src/common/bandwidth_helpers.rs index ee4bb8583e..10962a1eb6 100644 --- a/nym-gateway-probe/src/common/bandwidth_helpers.rs +++ b/nym-gateway-probe/src/common/bandwidth_helpers.rs @@ -6,15 +6,22 @@ use nym_bandwidth_controller::BandwidthTicketProvider; use nym_bandwidth_controller::error::BandwidthControllerError; use nym_bandwidth_controller::mock::MockBandwidthController; use nym_client_core::client::base_client::storage::OnDiskPersistent; +use nym_credentials::{ + AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey, + Error as CredentialsError, IssuedTicketBook, +}; use nym_credentials_interface::TicketType; -use nym_node_status_client::models::AttachedTicketMaterials; use nym_sdk::NymNetworkDetails; use nym_sdk::bandwidth::BandwidthImporter; use nym_sdk::mixnet::{CredentialStorage, DisconnectedMixnetClient, EphemeralCredentialStorage}; use nym_validator_client::nyxd::error::NyxdError; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::{error, info}; +pub use nym_credentials::ecash::bandwidth::serialiser::{VersionSerialised, VersionedSerialise}; + pub(crate) fn build_bandwidth_controller( network: &NymNetworkDetails, storage: S, @@ -186,3 +193,56 @@ pub(crate) async fn acquire_bandwidth( bail!("failed to acquire bandwidth after {MAX_RETRIES} attempts") } + +#[derive(Serialize, Deserialize)] +pub struct AttachedTicket { + pub ticketbook: VersionSerialised, + pub usable_index: u32, +} + +#[derive(Deserialize, Serialize)] +pub struct AttachedTicketMaterials { + pub coin_indices_signatures: Vec>, + + pub expiration_date_signatures: Vec>, + + pub master_verification_keys: Vec>, + + // two V1WireguardEntry tickets needed: one for WG registration, one for LP registration + pub attached_tickets: Vec, +} + +impl AttachedTicketMaterials { + pub fn to_serialised_string(&self) -> String { + // TODO: we're losing revision here, but given we control both ends of the pipeline, + // that's fine. we can just pass it as a separate argument + let serialised = self.pack(); + bs58::encode(serialised.data).into_string() + } + + pub fn from_serialised_string(raw: String, revision: u8) -> Result { + let bytes = bs58::decode(raw) + .into_vec() + .inspect_err(|err| error!("malformed bytes encoding: {err}")) + .unwrap_or_default(); + Self::try_unpack(&bytes, revision) + } +} + +impl VersionedSerialise for AttachedTicketMaterials { + const CURRENT_SERIALISATION_REVISION: u8 = 1; + + fn try_unpack(b: &[u8], revision: impl Into>) -> Result + where + Self: DeserializeOwned, + { + let revision = revision + .into() + .unwrap_or(::CURRENT_SERIALISATION_REVISION); + + match revision { + 1 => Self::try_unpack_current(b), + _ => Err(CredentialsError::UnknownSerializationRevision { revision }), + } + } +} diff --git a/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs b/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs index 1ccd1cb653..4346756dea 100644 --- a/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs +++ b/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs @@ -122,7 +122,6 @@ impl JsonRpcClient { .status() .map(|s| s.to_string()) .unwrap_or_else(|| "no HTTP status".to_string()); - error!("HTTPS request failed: {}", e); bail!("HTTPS request failed: {} ({})", e, status); } } diff --git a/nym-gateway-probe/src/common/types.rs b/nym-gateway-probe/src/common/types.rs index 32b72486eb..8541a57421 100644 --- a/nym-gateway-probe/src/common/types.rs +++ b/nym-gateway-probe/src/common/types.rs @@ -1,7 +1,9 @@ use nym_connection_monitor::ConnectionStatusEvent; use serde::{Deserialize, Serialize}; +pub use super::bandwidth_helpers::{AttachedTicket, AttachedTicketMaterials}; pub use super::socks5_test::HttpsConnectivityResult; +pub use nym_credentials::ecash::bandwidth::serialiser::VersionedSerialise; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProbeResult { diff --git a/nym-gateway-probe/src/config/credentials.rs b/nym-gateway-probe/src/config/credentials.rs index 71bc92b56f..838c5bb385 100644 --- a/nym-gateway-probe/src/config/credentials.rs +++ b/nym-gateway-probe/src/config/credentials.rs @@ -3,12 +3,14 @@ use clap::{ArgGroup, Args}; use nym_credentials_interface::TicketType; -use nym_node_status_client::models::AttachedTicketMaterials; use nym_sdk::mixnet::{ CredentialStorage, DisconnectedMixnetClient, Ephemeral, MixnetClientStorage, OnDiskPersistent, }; -use crate::common::bandwidth_helpers::{acquire_bandwidth, import_bandwidth}; +use crate::{ + common::bandwidth_helpers::{acquire_bandwidth, import_bandwidth}, + types::AttachedTicketMaterials, +}; #[derive(Debug, Args)] pub struct CredentialArgs { diff --git a/nym-gateway-probe/src/config/mod.rs b/nym-gateway-probe/src/config/mod.rs index 6ad78c942f..e110823cbf 100644 --- a/nym-gateway-probe/src/config/mod.rs +++ b/nym-gateway-probe/src/config/mod.rs @@ -13,10 +13,10 @@ pub use netstack::NetstackArgs; pub use socks5::Socks5Args; pub use test_mode::TestMode; -#[derive(Args, Debug)] +#[derive(Args, Debug, Default)] pub struct ProbeConfig { /// Only choose gateway with that minimum performance - #[arg(long)] + #[arg(long, env = "PROBE_MIN_GATEWAY_MIXNET_PERFORMANCE")] pub min_gateway_mixnet_performance: Option, /// Test mode - explicitly specify which tests to run diff --git a/nym-gateway-probe/src/config/netstack.rs b/nym-gateway-probe/src/config/netstack.rs index 1e704bcf55..50251e835e 100644 --- a/nym-gateway-probe/src/config/netstack.rs +++ b/nym-gateway-probe/src/config/netstack.rs @@ -5,40 +5,62 @@ use clap::Args; #[derive(Args, Clone, Debug)] pub struct NetstackArgs { - #[arg(long, hide = true, default_value_t = 180)] + #[arg(long, hide = true, env = "PROBE_NETSTACK_DOWNLOAD_TIMEOUT_SEC", default_value_t = NetstackArgs::default().netstack_download_timeout_sec)] pub netstack_download_timeout_sec: u64, - #[arg(long, hide = true, default_value_t = 30)] + #[arg(long, hide = true, env = "PROBE_METADATA_TIMEOUT_SEC", default_value_t = NetstackArgs::default().metadata_timeout_sec)] pub metadata_timeout_sec: u64, - #[arg(long, hide = true, default_value = "1.1.1.1")] + #[arg(long, hide = true, env = "PROBE_NETSTACK_V4_DNS", default_value_t = NetstackArgs::default().netstack_v4_dns)] pub netstack_v4_dns: String, - #[arg(long, hide = true, default_value = "2606:4700:4700::1111")] + #[arg(long, hide = true, env = "PROBE_NETSTACK_V6_DNS", default_value_t = NetstackArgs::default().netstack_v6_dns)] pub netstack_v6_dns: String, - #[arg(long, hide = true, default_value_t = 5)] + #[arg(long, hide = true, env = "PROBE_NETSTACK_NUM_PING", default_value_t = NetstackArgs::default().netstack_num_ping)] pub netstack_num_ping: u8, - #[arg(long, hide = true, default_value_t = 3)] + #[arg(long, hide = true, env = "PROBE_NETSTACK_SEND_TIMEOUT_SEC", default_value_t = NetstackArgs::default().netstack_send_timeout_sec)] pub netstack_send_timeout_sec: u64, - #[arg(long, hide = true, default_value_t = 3)] + #[arg(long, hide = true, env = "PROBE_NETSTACK_RECV_TIMEOUT_SEC", default_value_t = NetstackArgs::default().netstack_recv_timeout_sec)] pub netstack_recv_timeout_sec: u64, - #[arg(long, hide= true, default_values_t = vec!["nym.com".to_string()])] + #[arg(long, hide = true, env = "PROBE_NETSTACK_PING_HOSTS_V4", default_values_t = NetstackArgs::default().netstack_ping_hosts_v4)] pub netstack_ping_hosts_v4: Vec, - #[arg(long, hide= true, default_values_t = vec!["1.1.1.1".to_string()])] + #[arg(long, hide = true, env = "PROBE_NETSTACK_PING_IPS_V4", default_values_t = NetstackArgs::default().netstack_ping_ips_v4)] pub netstack_ping_ips_v4: Vec, - #[arg(long, hide= true, default_values_t = vec!["cloudflare.com".to_string()])] + #[arg(long, hide = true, env = "PROBE_NETSTACK_PING_HOSTS_V6", default_values_t = NetstackArgs::default().netstack_ping_hosts_v6)] pub netstack_ping_hosts_v6: Vec, - #[arg(long, hide= true, default_values_t = vec!["2001:4860:4860::8888".to_string(), "2606:4700:4700::1111".to_string(), "2620:fe::fe".to_string()])] + #[arg(long, hide = true, env = "PROBE_NETSTACK_PING_IPS_V6", default_values_t = NetstackArgs::default().netstack_ping_ips_v6)] pub netstack_ping_ips_v6: Vec, } +impl Default for NetstackArgs { + fn default() -> Self { + Self { + netstack_download_timeout_sec: 180, + metadata_timeout_sec: 30, + netstack_v4_dns: String::from("1.1.1.1"), + netstack_v6_dns: String::from("2606:4700:4700::1111"), + netstack_num_ping: 5, + netstack_send_timeout_sec: 3, + netstack_recv_timeout_sec: 3, + netstack_ping_hosts_v4: vec!["nym.com".to_string()], + netstack_ping_ips_v4: vec!["1.1.1.1".to_string()], + netstack_ping_hosts_v6: vec!["cloudflare.com".to_string()], + netstack_ping_ips_v6: vec![ + "2001:4860:4860::8888".to_string(), + "2606:4700:4700::1111".to_string(), + "2620:fe::fe".to_string(), + ], + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/nym-gateway-probe/src/config/socks5.rs b/nym-gateway-probe/src/config/socks5.rs index 3832dd4320..04045560ef 100644 --- a/nym-gateway-probe/src/config/socks5.rs +++ b/nym-gateway-probe/src/config/socks5.rs @@ -2,21 +2,19 @@ use clap::Args; use crate::common::socks5_test::JsonRpcClient; -const DEFAULT_RPC_ENDPOINT: &str = "https://cloudflare-eth.com"; - -#[derive(Args, Debug)] +#[derive(Args, Clone, Debug)] pub struct Socks5Args { - #[arg(long, hide = true, value_delimiter = ';', default_value = DEFAULT_RPC_ENDPOINT)] + #[arg(long, hide = true, env = "PROBE_SOCKS5_JSON_RPC_URL_LIST", default_values_t = Socks5Args::default().socks5_json_rpc_url_list, value_delimiter = ';')] pub socks5_json_rpc_url_list: Vec, - #[arg(long, hide = true, default_value_t = 30)] + #[arg(long, hide = true, env = "PROBE_SOCKS5_MIXNET_CLIENT_TIMEOUT_SEC", default_value_t = Socks5Args::default().mixnet_client_timeout_sec)] pub mixnet_client_timeout_sec: u64, - #[arg(long, hide = true, default_value_t = 10)] + #[arg(long, hide = true, env = "PROBE_SOCKS5_TEST_COUNT", default_value_t = Socks5Args::default().test_count)] pub test_count: u64, /// stops socks5 test early after this many failed attempts - #[arg(long, hide = true, default_value_t = 3)] + #[arg(long, hide = true, env = "PROBE_SOCKS5_FAILURE_COUNT_CUTOFF", default_value_t = Socks5Args::default().failure_count_cutoff)] pub failure_count_cutoff: usize, } @@ -32,3 +30,17 @@ impl Socks5Args { Ok(()) } } + +impl Default for Socks5Args { + fn default() -> Self { + Self { + socks5_json_rpc_url_list: vec![ + "https://cloudflare-eth.com".to_string(), + "https://ethereum-rpc.publicnode.com".to_string(), + ], + mixnet_client_timeout_sec: 30, + test_count: 10, + failure_count_cutoff: 3, + } + } +} diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 68bb3edd85..582c80f89e 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -62,7 +62,35 @@ impl Probe { } } - /// Run a probe as an NS agent, i.e. a bonded node on a known network + pub async fn new_for_agent( + entry_gateway: nym_sdk::mixnet::ed25519::PublicKey, + network: NymNetworkDetails, + mut config: ProbeConfig, + ) -> anyhow::Result { + let api_url = network + .endpoints + .first() + .and_then(|ep| ep.api_url()) + .ok_or(anyhow::anyhow!("missing api url"))?; + + let directory = NymApiDirectory::new(api_url).await?; + let entry_details = directory + .entry_gateway(&entry_gateway)? + .to_testable_node()?; + + // Agents run everything + config.test_mode = config::TestMode::All; + + Ok(Self { + entry_node: entry_details, + exit_node: None, + network, + config, + topology: None, + }) + } + + /// Run a probe as an NS agent (orchestrator for multiple probe runs for NS API) pub async fn probe_run_agent( mut self, credential_args: CredentialArgs, @@ -423,4 +451,8 @@ impl Probe { Ok(probe_result) } + + pub fn config(&self) -> &ProbeConfig { + &self.config + } } diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 391f952838..d1c15e22b2 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -214,23 +214,11 @@ pub(crate) async fn run() -> anyhow::Result { Commands::RunAgent { entry_gateway, credential_args, - mut probe_config, + probe_config, } => { - let api_url = network - .endpoints - .first() - .and_then(|ep| ep.api_url()) - .ok_or(anyhow::anyhow!("missing api url"))?; - - let directory = NymApiDirectory::new(api_url).await?; - let entry_details = directory - .entry_gateway(&entry_gateway)? - .to_testable_node()?; - - // Agents run everything - probe_config.test_mode = nym_gateway_probe::config::TestMode::All; - - let trial = nym_gateway_probe::Probe::new(entry_details, None, network, probe_config); + let trial = + nym_gateway_probe::Probe::new_for_agent(entry_gateway, network, probe_config) + .await?; Box::pin(trial.probe_run_agent(credential_args)).await } } 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 dbc7e877a5..653bda4b95 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.5" +version = "2.0.0" authors.workspace = true repository.workspace = true homepage.workspace = true @@ -20,6 +20,8 @@ clap = { workspace = true, features = ["derive", "env"] } futures = { workspace = true } nym-bin-common = { workspace = true, features = ["models"] } nym-crypto = { workspace = true, features = ["asymmetric", "rand"] } +nym-gateway-probe = { workspace = true } +nym-sdk = { workspace = true } nym-node-status-client = { path = "../nym-node-status-client" } rand = { workspace = true } diff --git a/nym-node-status-api/nym-node-status-agent/Dockerfile b/nym-node-status-api/nym-node-status-agent/Dockerfile index c8afb98eb3..e60b75b100 100644 --- a/nym-node-status-api/nym-node-status-agent/Dockerfile +++ b/nym-node-status-api/nym-node-status-agent/Dockerfile @@ -3,15 +3,12 @@ FROM harbor.nymte.ch/dockerhub/rust:latest AS builder RUN apt update && apt install -yy libdbus-1-dev pkg-config libclang-dev -# Install go +# Install go (required for netstack FFI linked into the agent) 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 -RUN cargo build --release --package nym-gateway-probe - WORKDIR /usr/src/nym/nym-node-status-api/nym-node-status-agent RUN cargo build --release @@ -31,10 +28,8 @@ RUN apt-get update && apt-get install -y ca-certificates WORKDIR /nym COPY --from=builder /usr/src/nym/target/release/nym-node-status-agent ./ -COPY --from=builder /usr/src/nym/target/release/nym-gateway-probe ./ COPY --from=builder /usr/src/nym/nym-node-status-api/nym-node-status-agent/entrypoint.sh ./ RUN chmod +x /nym/entrypoint.sh ENV SLEEP_TIME=5 -ENV NODE_STATUS_AGENT_PROBE_PATH=/nym/nym-gateway-probe ENTRYPOINT [ "/nym/entrypoint.sh" ] 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 98f684ea41..0ab67b2b6a 100755 --- a/nym-node-status-api/nym-node-status-agent/run.sh +++ b/nym-node-status-api/nym-node-status-agent/run.sh @@ -10,8 +10,6 @@ echo crate_root=${crate_root} monorepo_root=$(realpath "${crate_root}/../..") echo monorepo_root=${monorepo_root} -gateway_probe_src="${monorepo_root}/nym-gateway-probe" -echo "gateway_probe_src=$gateway_probe_src" set -a source "${monorepo_root}/envs/${ENVIRONMENT}.env" @@ -24,22 +22,10 @@ NODE_STATUS_AGENT_SERVER_PORT="8000" 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,socks5-json-rpc-url-list=https://cloudflare-eth.com;https://ethereum-rpc.publicnode.com" workers=${1:-1} echo "Running $workers workers in parallel" -# build & copy over GW probe -function copy_gw_probe() { - pushd $gateway_probe_src - - cargo build --release --package nym-gateway-probe - cp "${monorepo_root}/target/release/nym-gateway-probe" "$crate_root" - $crate_root/nym-gateway-probe --version - - popd -} function build_agent() { cargo build --package nym-node-status-agent --release @@ -49,7 +35,12 @@ function swarm() { local workers=$1 for ((i = 1; i <= workers; i++)); do - ${monorepo_root}/target/release/nym-node-status-agent run-probe --server ${SERVER} & + ${monorepo_root}/target/release/nym-node-status-agent run-probe --server ${SERVER} \ + --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-rpc.publicnode.com" & done wait @@ -57,7 +48,6 @@ function swarm() { echo "All agents completed" } -copy_gw_probe build_agent swarm $workers diff --git a/nym-node-status-api/nym-node-status-agent/src/cli/mod.rs b/nym-node-status-api/nym-node-status-agent/src/cli/mod.rs index 0eaa68cbb4..a0cc67ccfb 100644 --- a/nym-node-status-api/nym-node-status-agent/src/cli/mod.rs +++ b/nym-node-status-api/nym-node-status-agent/src/cli/mod.rs @@ -1,4 +1,4 @@ -use crate::probe::GwProbe; +use crate::log_capture::LogCapture; use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_crypto::asymmetric::ed25519::PrivateKey; @@ -47,25 +47,10 @@ pub(crate) struct Args { pub(crate) command: Command, } +#[allow(clippy::large_enum_variant)] #[derive(Subcommand, Debug)] pub(crate) enum Command { - RunProbe { - /// Server configurations in format "address|port" - /// Can be specified multiple times for multiple servers - #[arg(short, long, required = true)] - server: Vec, - - /// path of binary to run - #[arg(long, env = "NODE_STATUS_AGENT_PROBE_PATH")] - probe_path: String, - - #[arg( - long, - env = "NODE_STATUS_AGENT_PROBE_EXTRA_ARGS", - value_delimiter = ',' - )] - probe_extra_args: Vec, - }, + RunProbe(RunProbeArgs), GenerateKeypair { #[arg(long)] @@ -73,17 +58,26 @@ pub(crate) enum Command { }, } +#[derive(clap::Args, Debug)] +pub(crate) struct RunProbeArgs { + /// Server configurations in format "address|port" + /// Can be specified multiple times for multiple servers + #[arg(short, long, required = true)] + pub server: Vec, + + /// Probe configuration overrides (netstack, socks5, etc.) + /// Can also be set via PROBE_* environment variables. + #[command(flatten)] + pub probe_config: nym_gateway_probe::config::ProbeConfig, +} + impl Args { - pub(crate) async fn execute(&self) -> anyhow::Result<()> { - match &self.command { - Command::RunProbe { - server, - probe_path, - probe_extra_args, - } => { + pub(crate) async fn execute(self, log_capture: LogCapture) -> anyhow::Result<()> { + match self.command { + Command::RunProbe(args) => { // Parse server configs let mut servers = Vec::new(); - for s in server { + for s in &args.server { match parse_server_config(s) { Ok(config) => servers.push(config), Err(e) => { @@ -93,7 +87,7 @@ impl Args { } } - run_probe::run_probe(&servers, probe_path, probe_extra_args) + run_probe::run_probe(&servers, args.probe_config, log_capture) .await .inspect_err(|err| { tracing::error!("{err}"); 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 889b0b4f08..216329fa78 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 @@ -1,21 +1,20 @@ +use crate::cli::ServerConfig; +use crate::log_capture::LogCapture; +use anyhow::anyhow; +use nym_gateway_probe::config::CredentialArgs; +use nym_gateway_probe::types::{AttachedTicketMaterials, VersionedSerialise}; +use nym_sdk::mixnet::ed25519::PublicKey; use tracing::instrument; -use crate::cli::{GwProbe, ServerConfig}; - pub(crate) async fn run_probe( servers: &[ServerConfig], - probe_path: &str, - probe_extra_args: &Vec, + probe_config: nym_gateway_probe::config::ProbeConfig, + log_capture: LogCapture, ) -> anyhow::Result<()> { if servers.is_empty() { anyhow::bail!("No servers configured"); } - let probe = GwProbe::new(probe_path.to_string()); - - let version = probe.version().await; - tracing::info!("Probe version:\n{}", version); - // Always use first server as primary let primary_server = &servers[0]; tracing::info!( @@ -49,44 +48,35 @@ pub(crate) async fn run_probe( let testrun_id = testrun.assignment.testrun_id; let testrun_assigned_at = testrun.assignment.assigned_at_utc; let gateway_identity_key = testrun.assignment.gateway_identity_key.clone(); + let gateway_identity_pubkey = PublicKey::from_base58_string(gateway_identity_key.clone()) + .map_err(|e| anyhow!("Failed to parse GW identity key: {e}"))?; tracing::info!("Received testrun {testrun_id} for gateway {gateway_identity_key} from primary",); - // Run the probe - let log = probe.run_and_get_log( - gateway_identity_key.clone(), - probe_extra_args, - testrun.ticket_materials, - ); + let network = nym_sdk::NymNetworkDetails::new_from_env(); + let probe = + nym_gateway_probe::Probe::new_for_agent(gateway_identity_pubkey, network, probe_config) + .await?; + + // probe constructor might modify config to suit the testing mode, so log afterwards + tracing::info!("Using probe config:\n{:#?}", &probe.config()); + + let serialized_ticket_materials = testrun.ticket_materials.to_serialised_string(); + let credentials_args = CredentialArgs { + ticket_materials: serialized_ticket_materials, + ticket_materials_revision: + ::CURRENT_SERIALISATION_REVISION, + }; + + // Run the probe, capturing all tracing output + log_capture.start(); + let probe_result = Box::pin(probe.probe_run_agent(credentials_args)).await?; + let probe_log = log_capture.stop_and_drain(); // 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::warn!("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}"); - } - } + match probe_result.outcome.socks5.as_ref() { + Some(socks5) => tracing::info!("🌐 socks5 field present: {:#?}", socks5), + None => tracing::warn!("🌐⚠️ socks5 field is MISSING from probe output"), } submit_results_to_servers( @@ -94,7 +84,8 @@ pub(crate) async fn run_probe( testrun_id, testrun_assigned_at, &gateway_identity_key, - log, + probe_result, + probe_log, ) .await; @@ -107,13 +98,15 @@ async fn submit_results_to_servers( testrun_id: i32, testrun_assigned_at: i64, gateway_identity_key: &str, - log: String, + probe_result: nym_gateway_probe::ProbeResult, + probe_log: String, ) { let handles = servers .iter() .enumerate() .map(|(idx, server)| { - let log = log.clone(); + let probe_result = probe_result.clone(); + let probe_log = probe_log.clone(); let gateway_identity_key = gateway_identity_key.to_string(); async move { @@ -130,14 +123,20 @@ async fn submit_results_to_servers( let result = if idx == 0 { // Primary server: submit regular results without context client - .submit_results(testrun_id as i64, log, testrun_assigned_at) + .submit_results( + testrun_id as i64, + probe_result, + probe_log, + testrun_assigned_at, + ) .await } else { // Other servers: submit results with context client .submit_results_with_context( testrun_id, - log, + probe_result, + probe_log, testrun_assigned_at, gateway_identity_key, ) @@ -171,17 +170,3 @@ async fn submit_results_to_servers( } } } - -/// 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-agent/src/log_capture.rs b/nym-node-status-api/nym-node-status-agent/src/log_capture.rs new file mode 100644 index 0000000000..e9c137fb2a --- /dev/null +++ b/nym-node-status-api/nym-node-status-agent/src/log_capture.rs @@ -0,0 +1,61 @@ +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone)] +pub(crate) struct LogCapture { + buffer: Arc>>, + capturing: Arc, +} + +impl LogCapture { + pub(crate) fn new() -> Self { + Self { + buffer: Arc::new(Mutex::new(Vec::new())), + capturing: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn start(&self) { + self.buffer.lock().unwrap().clear(); + self.capturing.store(true, Ordering::Release); + } + + pub(crate) fn stop_and_drain(&self) -> String { + self.capturing.store(false, Ordering::Release); + let buf = std::mem::take(&mut *self.buffer.lock().unwrap()); + String::from_utf8_lossy(&buf).into_owned() + } +} + +pub(crate) struct LogCaptureWriter { + buffer: Option>>>, +} + +impl Write for LogCaptureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if let Some(buffer) = &self.buffer { + buffer.lock().unwrap().extend_from_slice(buf); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for LogCapture { + type Writer = LogCaptureWriter; + + fn make_writer(&'a self) -> Self::Writer { + LogCaptureWriter { + buffer: if self.capturing.load(Ordering::Acquire) { + Some(self.buffer.clone()) + } else { + None + }, + } + } +} diff --git a/nym-node-status-api/nym-node-status-agent/src/main.rs b/nym-node-status-api/nym-node-status-agent/src/main.rs index 9cdb340faa..d8f654fc0b 100644 --- a/nym-node-status-api/nym-node-status-agent/src/main.rs +++ b/nym-node-status-api/nym-node-status-agent/src/main.rs @@ -1,22 +1,23 @@ use crate::cli::Args; +use crate::log_capture::LogCapture; use clap::Parser; use tracing::level_filters::LevelFilter; -use tracing_subscriber::{EnvFilter, filter::Directive}; +use tracing_subscriber::{EnvFilter, filter::Directive, prelude::*}; mod cli; -mod probe; +mod log_capture; #[tokio::main] async fn main() -> anyhow::Result<()> { - setup_tracing(); + let log_capture = setup_tracing(); let args = Args::parse(); - args.execute().await?; + args.execute(log_capture).await?; Ok(()) } -pub(crate) fn setup_tracing() { +pub(crate) fn setup_tracing() -> LogCapture { fn directive_checked(directive: impl Into) -> Directive { directive .into() @@ -24,17 +25,6 @@ pub(crate) fn setup_tracing() { .expect("Failed to parse log directive") } - let log_builder = tracing_subscriber::fmt() - // Use a more compact, abbreviated log format - .compact() - // Display source code file paths - .with_file(true) - // Display source code line numbers - .with_line_number(true) - .with_thread_ids(true) - // Don't display the event's target (module path) - .with_target(false); - let mut filter = EnvFilter::builder() // if RUST_LOG isn't set, set default level .with_default_directive(LevelFilter::INFO.into()) @@ -59,5 +49,22 @@ pub(crate) fn setup_tracing() { filter = filter.add_directive(directive_checked("nym_network_defaults=debug")); filter = filter.add_directive(directive_checked("nym_validator_client=debug")); - log_builder.with_env_filter(filter).init(); + let stderr_layer = tracing_subscriber::fmt::layer() + .compact() + .with_file(true) + .with_line_number(true) + .with_thread_ids(true) + .with_target(false); + + let log_capture = LogCapture::new(); + let capture_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(log_capture.clone()); + + tracing_subscriber::registry() + .with(stderr_layer.with_filter(filter)) + .with(capture_layer.with_filter(LevelFilter::INFO)) + .init(); + + log_capture } diff --git a/nym-node-status-api/nym-node-status-agent/src/probe.rs b/nym-node-status-api/nym-node-status-agent/src/probe.rs deleted file mode 100644 index 610c07c4bd..0000000000 --- a/nym-node-status-api/nym-node-status-agent/src/probe.rs +++ /dev/null @@ -1,124 +0,0 @@ -use nym_node_status_client::models::{AttachedTicketMaterials, VersionedSerialise}; -use tracing::{debug, error, info}; - -pub(crate) struct GwProbe { - path: String, -} - -impl GwProbe { - pub(crate) fn new(probe_path: String) -> Self { - Self { path: probe_path } - } - - pub(crate) async fn version(&self) -> String { - debug!("Attempting to execute binary at: {}", &self.path); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - match tokio::fs::metadata(&self.path).await { - Ok(metadata) => { - let perms = metadata.permissions(); - let mode = perms.mode(); - if mode & 0o111 == 0 { - error!( - "Binary is not executable: {} (mode: {:o})", - &self.path, mode - ); - return "Binary is not executable".to_string(); - } - debug!("Binary exists with permissions: {:o}", mode); - } - Err(e) => { - error!("Failed to stat binary at {}: {}", &self.path, e); - return format!("Failed to access binary: {e}"); - } - } - } - - let mut command = tokio::process::Command::new(&self.path); - command.stdout(std::process::Stdio::piped()); - command.arg("--version"); - - info!("Executing command: {:?} --version", &self.path); - - match command.spawn() { - Ok(child) => match child.wait_with_output().await { - Ok(output) => { - if output.status.success() { - String::from_utf8(output.stdout) - .unwrap_or_else(|_| "Unable to parse version output".to_string()) - } else { - let stderr = String::from_utf8(output.stderr) - .unwrap_or_else(|_| "Unable to parse error output".to_string()); - error!( - "Command failed with exit code {}: {}", - output.status.code().unwrap_or(-1), - stderr - ); - format!("Command failed: {stderr}") - } - } - Err(e) => { - error!("Failed to get command output: {}", e); - format!("Failed to get command output: {e}") - } - }, - Err(e) => { - error!("Failed to spawn process: {}", e); - format!("Failed to spawn process: {e}") - } - } - } - - pub(crate) fn run_and_get_log( - &self, - gateway_key: String, - probe_extra_args: &Vec, - ticket_materials: AttachedTicketMaterials, - ) -> String { - let mut command = std::process::Command::new(&self.path); - command.stdout(std::process::Stdio::piped()); - - command.arg("run-agent"); - command.arg("--gateway").arg(gateway_key); - - tracing::info!("Extra args for the probe:"); - for arg in probe_extra_args { - let mut split = arg.splitn(2, '='); - let name = split.next().unwrap_or_default(); - let value = split.next().unwrap_or_default(); - tracing::info!("{} {}", name, value); - - command.arg(format!("--{name}")).arg(value); - } - - info!("attaching ticket materials to the probe"); - let serialised = ticket_materials.to_serialised_string(); - command.arg("--ticket-materials").arg(serialised); - command.arg("--ticket-materials-revision").arg( - ::CURRENT_SERIALISATION_REVISION - .to_string(), - ); - - match command.spawn() { - Ok(child) => { - if let Ok(output) = child.wait_with_output() { - if !output.status.success() { - let out = String::from_utf8_lossy(&output.stdout); - let err = String::from_utf8_lossy(&output.stderr); - tracing::error!("Probe exited with {:?}:\n{}\n{}", output.status, out, err); - } - - return String::from_utf8(output.stdout) - .unwrap_or("Unable to get log from test run".to_string()); - } - "Unable to get log from test run".to_string() - } - Err(e) => { - error!("Failed to spawn test: {}", e); - "Failed to spawn test run task".to_string() - } - } - } -} 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 47a214d768..911d5abcfc 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.3.1" +version = "4.4.0" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/nym-node-status-api/nym-node-status-api/src/http/api/testruns.rs b/nym-node-status-api/nym-node-status-api/src/http/api/testruns.rs index 1b6c63de3f..d26db1d7df 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/api/testruns.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/api/testruns.rs @@ -147,9 +147,18 @@ async fn submit_testrun( "Agent submitted testrun {} for gateway {} ({} bytes)", submitted_testrun_id, gw_identity, - &submitted_result.payload.probe_result.len(), + &submitted_result.payload.probe_log.len(), ); + // Need serialized result for DB. + // In general, this should NEVER fail because Serialize for this type is derived. + // But just in case, do this BEFORE marking the testrun as complete. + let serialized_probe_res = serde_json::to_string(&submitted_result.payload.probe_result) + .map_err(|e| { + tracing::error!("Failed to serialize probe result into DB: {e}"); + HttpError::invalid_input("Invalid probe_result") + })?; + queries::testruns::update_testrun_status( &mut conn, submitted_testrun_id, @@ -160,15 +169,15 @@ async fn submit_testrun( queries::testruns::update_gateway_last_probe_log( &mut conn, assigned_testrun.gateway_id, - &submitted_result.payload.probe_result.clone(), + &submitted_result.payload.probe_log.clone(), ) .await .map_err(HttpError::internal_with_logging)?; - let result = get_result_from_log(&submitted_result.payload.probe_result); + queries::testruns::update_gateway_last_probe_result( &mut conn, assigned_testrun.gateway_id, - &result, + &serialized_probe_res, ) .await .map_err(HttpError::internal_with_logging)?; @@ -257,18 +266,6 @@ async fn submit_testrun_v2( } } -fn get_result_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 { - let res = format!("{} {}", "{", result[1]).to_string(); - return res; - } - "".to_string() -} - async fn process_testrun_submission( testrun: TestRunDto, payload: submit_results_v2::Payload, @@ -301,25 +298,28 @@ async fn process_testrun_submission_by_gateway( tracing::debug!( "Processing testrun submission for gateway {} ({} bytes)", gw_identity, - payload.probe_result.len(), + payload.probe_log.len(), ); + // Need serialized result for DB. + // In general, this should NEVER fail because Serialize for this type is derived. + // But just in case, do this BEFORE marking the testrun as complete. + let serialized_probe_res = serde_json::to_string(&payload.probe_result).map_err(|e| { + tracing::error!("Failed to serialize probe result into DB: {e}"); + HttpError::invalid_input("Invalid probe_result") + })?; + // Update testrun status to complete queries::testruns::update_testrun_status_by_gateway(conn, gateway_id, TestRunStatus::Complete) .await .map_err(HttpError::internal_with_logging)?; // Update gateway with results - queries::testruns::update_gateway_last_probe_log( - conn, - gateway_id, - &payload.probe_result.clone(), - ) - .await - .map_err(HttpError::internal_with_logging)?; + queries::testruns::update_gateway_last_probe_log(conn, gateway_id, &payload.probe_log.clone()) + .await + .map_err(HttpError::internal_with_logging)?; - let result = get_result_from_log(&payload.probe_result); - queries::testruns::update_gateway_last_probe_result(conn, gateway_id, &result) + queries::testruns::update_gateway_last_probe_result(conn, gateway_id, &serialized_probe_res) .await .map_err(HttpError::internal_with_logging)?; diff --git a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs index 288f14486b..bf4ad1e161 100644 --- a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs +++ b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/state.rs @@ -16,7 +16,7 @@ use nym_credential_proxy_lib::storage::traits::{ use nym_credentials::EpochVerificationKey; use nym_credentials::ecash::bandwidth::serialiser::VersionedSerialise; use nym_ecash_time::ecash_default_expiration_date; -use nym_node_status_client::models::AttachedTicketMaterials; +use nym_gateway_probe::types::AttachedTicketMaterials; use nym_validator_client::EcashApiClient; use nym_validator_client::nym_api::EpochId; use nym_validator_client::nyxd::Coin; diff --git a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs index cdc757d637..904bb7eb57 100644 --- a/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs +++ b/nym-node-status-api/nym-node-status-api/src/ticketbook_manager/storage/auxiliary_models.rs @@ -3,7 +3,7 @@ use nym_credentials::IssuedTicketBook; use nym_credentials::ecash::bandwidth::serialiser::VersionedSerialise; -use nym_node_status_client::models::AttachedTicket; +use nym_gateway_probe::types::AttachedTicket; use sqlx::FromRow; use time::Date; use zeroize::{Zeroize, ZeroizeOnDrop}; diff --git a/nym-node-status-api/nym-node-status-client/Cargo.toml b/nym-node-status-api/nym-node-status-client/Cargo.toml index a4e3fa2dc3..8bd8541f55 100644 --- a/nym-node-status-api/nym-node-status-client/Cargo.toml +++ b/nym-node-status-api/nym-node-status-client/Cargo.toml @@ -16,10 +16,10 @@ publish = false [dependencies] anyhow = { workspace = true } -bs58 = { workspace = true } bincode = { workspace = true } nym-crypto = { workspace = true, features = ["asymmetric", "serde", ] } nym-credentials = { workspace = true } +nym-gateway-probe = { workspace = true } reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/nym-node-status-api/nym-node-status-client/src/lib.rs b/nym-node-status-api/nym-node-status-client/src/lib.rs index 0dc9a058ce..182fa8786d 100644 --- a/nym-node-status-api/nym-node-status-client/src/lib.rs +++ b/nym-node-status-api/nym-node-status-client/src/lib.rs @@ -38,7 +38,7 @@ impl NsApiClient { } } - #[instrument(level = "debug", skip_all)] + #[instrument(level = "info", skip_all)] pub async fn request_testrun(&self) -> anyhow::Result> { let target_url = self.api.request_testrun(); @@ -76,17 +76,19 @@ impl NsApiClient { }) } - #[instrument(level = "debug", skip(self, probe_result))] + #[instrument(level = "info", fields(testrun_id, assigned_at_utc), skip_all)] pub async fn submit_results( &self, testrun_id: i64, - probe_result: String, + probe_result: nym_gateway_probe::ProbeResult, + probe_log: String, assigned_at_utc: i64, ) -> anyhow::Result<()> { let target_url = self.api.submit_results(testrun_id); let payload = submit_results::Payload { probe_result, + probe_log, agent_public_key: self.auth_key.public_key(), assigned_at_utc, }; @@ -105,11 +107,12 @@ impl NsApiClient { Ok(()) } - #[instrument(level = "debug", skip(self, probe_result))] + #[instrument(level = "info", skip(self, probe_result))] pub async fn submit_results_with_context( &self, testrun_id: i32, - probe_result: String, + probe_result: nym_gateway_probe::ProbeResult, + probe_log: String, assigned_at_utc: i64, gateway_identity_key: String, ) -> anyhow::Result<()> { @@ -117,6 +120,7 @@ impl NsApiClient { let payload = submit_results_v2::Payload { probe_result, + probe_log, agent_public_key: self.auth_key.public_key(), assigned_at_utc, gateway_identity_key, diff --git a/nym-node-status-api/nym-node-status-client/src/models.rs b/nym-node-status-api/nym-node-status-client/src/models.rs index 7775beb1eb..4b362f24fc 100644 --- a/nym-node-status-api/nym-node-status-client/src/models.rs +++ b/nym-node-status-api/nym-node-status-client/src/models.rs @@ -1,13 +1,7 @@ -pub use nym_credentials::ecash::bandwidth::serialiser::{VersionSerialised, VersionedSerialise}; -use nym_credentials::{ - AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey, - Error, IssuedTicketBook, -}; use nym_crypto::asymmetric::ed25519::{PublicKey, Signature}; -use serde::de::DeserializeOwned; +use nym_gateway_probe::types::AttachedTicketMaterials; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -use tracing::error; pub mod get_testrun { use crate::auth::SignedRequest; @@ -42,59 +36,6 @@ pub mod get_testrun { } } -#[derive(Serialize, Deserialize)] -pub struct AttachedTicket { - pub ticketbook: VersionSerialised, - pub usable_index: u32, -} - -#[derive(Deserialize, Serialize)] -pub struct AttachedTicketMaterials { - pub coin_indices_signatures: Vec>, - - pub expiration_date_signatures: Vec>, - - pub master_verification_keys: Vec>, - - // two V1WireguardEntry tickets needed: one for WG registration, one for LP registration - pub attached_tickets: Vec, -} - -impl AttachedTicketMaterials { - pub fn to_serialised_string(&self) -> String { - // TODO: we're losing revision here, but given we control both ends of the pipeline, - // that's fine. we can just pass it as a separate argument - let serialised = self.pack(); - bs58::encode(serialised.data).into_string() - } - - pub fn from_serialised_string(raw: String, revision: u8) -> Result { - let bytes = bs58::decode(raw) - .into_vec() - .inspect_err(|err| error!("malformed bytes encoding: {err}")) - .unwrap_or_default(); - Self::try_unpack(&bytes, revision) - } -} - -impl VersionedSerialise for AttachedTicketMaterials { - const CURRENT_SERIALISATION_REVISION: u8 = 1; - - fn try_unpack(b: &[u8], revision: impl Into>) -> Result - where - Self: DeserializeOwned, - { - let revision = revision - .into() - .unwrap_or(::CURRENT_SERIALISATION_REVISION); - - match revision { - 1 => Self::try_unpack_current(b), - _ => Err(Error::UnknownSerializationRevision { revision }), - } - } -} - #[derive(Deserialize, Serialize, Debug)] pub struct TestrunAssignment { pub testrun_id: i32, @@ -138,7 +79,8 @@ pub mod submit_results { use super::*; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Payload { - pub probe_result: String, + pub probe_result: nym_gateway_probe::ProbeResult, + pub probe_log: String, pub agent_public_key: PublicKey, pub assigned_at_utc: i64, } @@ -172,7 +114,8 @@ pub mod submit_results_v2 { use super::*; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Payload { - pub probe_result: String, + pub probe_result: nym_gateway_probe::ProbeResult, + pub probe_log: String, pub agent_public_key: PublicKey, pub assigned_at_utc: i64, pub gateway_identity_key: String, diff --git a/sdk/rust/nym-sdk/examples/shared_identity.rs b/sdk/rust/nym-sdk/examples/shared_identity.rs new file mode 100644 index 0000000000..ee4e2d77e4 --- /dev/null +++ b/sdk/rust/nym-sdk/examples/shared_identity.rs @@ -0,0 +1,230 @@ +/// Proof-of-concept: two mixnet clients sharing the same identity, +/// where the second client reuses the first client's gateway registration +/// (shared key) without re-registering. +/// +/// Could be used in the NS API optimization where +/// multiple gateway probes share a single identity and registration cache. +/// +/// Run from the sdk/rust/nym-sdk directory: +/// cargo run --example shared_identity +/// +/// Flow: +/// - Client-A registers with a gateway (DH handshake → shared key) +/// - Client-B uses the SAME identity and the SAME shared key +/// - Client-B authenticates (no DH) and gets connected +/// - Both clients get the same Nym address (proves shared identity works) +use nym_client_core::client::base_client::storage::gateways_storage::{ + GatewayDetails, GatewaysDetailsStore, InMemGatewaysDetails, +}; +use nym_client_core::client::base_client::storage::{Ephemeral, MixnetClientStorage}; +use nym_client_core::client::key_manager::persistence::{InMemEphemeralKeys, KeyStore}; +use nym_client_core::client::replies::reply_storage; +use nym_credential_storage::ephemeral_storage::EphemeralStorage as EphemeralCredentialStorage; +use nym_credentials_interface::TicketType; +use nym_gateway_requests::shared_key::SharedSymmetricKey; +use nym_sdk::mixnet; +use nym_sdk::mixnet::MixnetMessageSender; +use rand::rngs::OsRng; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nym_bin_common::logging::setup_tracing_logger(); + + // export ENVIRONMENT=sandbox + let sandbox_network = mixnet::NymNetworkDetails::new_from_env(); + + println!("=== Phase 1: Client-A registers with a gateway ===\n"); + + // create client-A with ephemeral storage (will register fresh) + let storage_a = Ephemeral::default(); + + // save a handle to the stores so we can extract data after connection + let keys_store_a = storage_a.key_store().clone(); + let gw_store_a = storage_a.gateway_details_store().clone(); + + let client_a_builder = mixnet::MixnetClientBuilder::new_with_storage(storage_a) + .network_details(sandbox_network.clone()) + .enable_credentials_mode() + .build()?; + + let mnemonic = std::env::var("SANDBOX_MNEMONIC").expect("Set mnemonic in the env variable"); + let bandwidth_client_a = client_a_builder + .create_bandwidth_client(mnemonic, TicketType::V1MixnetEntry) + .await?; + + // get a bandwidth credential for the mixnet_client + bandwidth_client_a.acquire().await?; + + // connect: this triggers gateway registration (DH handshake) + let mut client_a = client_a_builder.connect_to_mixnet().await?; + let address_a = *client_a.nym_address(); + println!("Client-A address: {address_a}"); + + // extract the identity keys and gateway registration from client-A + let client_keys = keys_store_a.load_keys().await?; + let active_gw = gw_store_a.active_gateway().await?; + let registration = active_gw + .registration + .expect("client-A should have an active gateway registration"); + + // extract the shared key and gateway info + let shared_key_bytes = registration + .details + .shared_key() + .expect("remote gateway should have a shared key") + .to_bytes(); + let gateway_id = registration.details.gateway_id(); + let published_data = match ®istration.details { + GatewayDetails::Remote(r) => r.published_data.clone(), + _ => panic!("expected remote gateway"), + }; + + println!("Gateway ID: {}", gateway_id.to_base58_string()); + println!( + "Shared key (first 8 bytes): {:02x?}", + &shared_key_bytes[..8] + ); + println!("Gateway listener: {}", published_data.listeners.primary); + + // send a self-ping to prove client-A works + client_a + .send_plain_message(address_a, "hello from client-A") + .await?; + println!("\nClient-A sent self-ping..."); + if let Some(msgs) = client_a.wait_for_messages().await { + for m in &msgs { + println!("Client-A received: {}", String::from_utf8_lossy(&m.message)); + } + } + + // disconnect client-A (don't ForgetMe: keep registration alive) + client_a.disconnect().await; + println!("\nClient-A disconnected (gateway keeps registration)\n"); + + // ================================================================ + println!("=== Phase 2: Client-B reuses Client-A's identity + registration ===\n"); + + // reconstruct the shared key from raw bytes + let shared_key = SharedSymmetricKey::try_from_bytes(&shared_key_bytes)?; + + // build a pre-populated gateway registration + let gw_details = GatewayDetails::new_remote(gateway_id, Arc::new(shared_key), published_data); + let gw_registration = gw_details.into(); + + // create a new ephemeral storage and pre-populate it + let keys_store_b = InMemEphemeralKeys::new(&mut OsRng); + // inject the SAME identity keys from client-A + keys_store_b.store_keys(&client_keys).await?; + + let gw_store_b = InMemGatewaysDetails::default(); + // inject the gateway registration (shared key) from client-A + gw_store_b.store_gateway_details(&gw_registration).await?; + gw_store_b + .set_active_gateway(&gateway_id.to_base58_string()) + .await?; + + // wrap into a custom storage that the SDK can use + let storage_b = PrePopulatedStorage { + key_store: keys_store_b, + reply_store: reply_storage::Empty::default(), + credential_store: EphemeralCredentialStorage::default(), + gateway_details_store: gw_store_b, + }; + + // Build client-B with the pre-populated storage + // request_gateway forces the builder to use our pre-registered gateway + let client_b_builder = mixnet::MixnetClientBuilder::new_with_storage(storage_b) + .request_gateway(gateway_id.to_base58_string()) + .network_details(sandbox_network) + .enable_credentials_mode() + .build()?; + + // === what we'd do when claiming bandwidth: === + // but we do NOT do this because we want to reuse registration from client A + // let bandwidth_client_b = client_b_builder + // .create_bandwidth_client(mnemonic, TicketType::V1MixnetEntry) + // .await?; + // bandwidth_client_b.acquire().await?; + + // Connect: this should AUTHENTICATE (not register) because the + // gateway details store already has a registration for this gateway + println!("Client-B connecting (should authenticate, not register)..."); + let mut client_b = client_b_builder.connect_to_mixnet().await?; + let address_b = *client_b.nym_address(); + println!("Client-B address: {address_b}"); + + // Verify: both clients get the same Nym address + if address_a == address_b { + println!("\n*** SUCCESS: Both clients have the SAME Nym address ***"); + println!("*** Client-B authenticated using Client-A's shared key ***"); + } else { + println!("\n*** UNEXPECTED: Addresses differ ***"); + println!(" A: {address_a}"); + println!(" B: {address_b}"); + } + + // send a self-ping to prove client-B works + client_b + .send_plain_message(address_b, "hello from client-B") + .await?; + println!("\nClient-B sent self-ping..."); + if let Some(msgs) = client_b.wait_for_messages().await { + for m in &msgs { + println!("Client-B received: {}", String::from_utf8_lossy(&m.message)); + } + } + + client_b.disconnect().await; + println!("\nClient-B disconnected. Done!"); + + Ok(()) +} + +// A wrapper around in-memory stores to implement MixnetClientStorage. +// This is the pattern an NS Agent would use to create probes with +// pre-populated identity and gateway registration. +#[derive(Clone)] +struct PrePopulatedStorage { + key_store: InMemEphemeralKeys, + reply_store: reply_storage::Empty, + credential_store: EphemeralCredentialStorage, + gateway_details_store: InMemGatewaysDetails, +} + +impl MixnetClientStorage for PrePopulatedStorage { + type KeyStore = InMemEphemeralKeys; + type ReplyStore = reply_storage::Empty; + type CredentialStore = EphemeralCredentialStorage; + type GatewaysDetailsStore = InMemGatewaysDetails; + + fn into_runtime_stores( + self, + ) -> ( + Self::ReplyStore, + Self::CredentialStore, + Self::GatewaysDetailsStore, + ) { + ( + self.reply_store, + self.credential_store, + self.gateway_details_store, + ) + } + + fn key_store(&self) -> &Self::KeyStore { + &self.key_store + } + + fn reply_store(&self) -> &Self::ReplyStore { + &self.reply_store + } + + fn credential_store(&self) -> &Self::CredentialStore { + &self.credential_store + } + + fn gateway_details_store(&self) -> &Self::GatewaysDetailsStore { + &self.gateway_details_store + } +}