Optimize GW probe in NS agent (#6636)

* WIP

* NS agent calls probe as lib

* Clippy: ns agent

* Fix submit_v2 on API

* Adjust dockerfile, deployment details for the new flow

* Bump package versions

* PR feedback

* Fix CI

* Final version
This commit is contained in:
dynco-nym
2026-04-07 16:35:50 +02:00
committed by GitHub
parent c07ef0253d
commit 89de989ad1
29 changed files with 618 additions and 412 deletions
+5 -4
View File
@@ -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')
Generated
+6 -4
View File
@@ -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",
-1
View File
@@ -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",
+13 -13
View File
@@ -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 }
@@ -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<S>(
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<IssuedTicketBook>,
pub usable_index: u32,
}
#[derive(Deserialize, Serialize)]
pub struct AttachedTicketMaterials {
pub coin_indices_signatures: Vec<VersionSerialised<AggregatedCoinIndicesSignatures>>,
pub expiration_date_signatures: Vec<VersionSerialised<AggregatedExpirationDateSignatures>>,
pub master_verification_keys: Vec<VersionSerialised<EpochVerificationKey>>,
// two V1WireguardEntry tickets needed: one for WG registration, one for LP registration
pub attached_tickets: Vec<AttachedTicket>,
}
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<Self, CredentialsError> {
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<Option<u8>>) -> Result<Self, CredentialsError>
where
Self: DeserializeOwned,
{
let revision = revision
.into()
.unwrap_or(<Self as VersionedSerialise>::CURRENT_SERIALISATION_REVISION);
match revision {
1 => Self::try_unpack_current(b),
_ => Err(CredentialsError::UnknownSerializationRevision { revision }),
}
}
}
@@ -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);
}
}
+2
View File
@@ -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 {
+4 -2
View File
@@ -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 {
+2 -2
View File
@@ -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<u8>,
/// Test mode - explicitly specify which tests to run
+33 -11
View File
@@ -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<String>,
#[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<String>,
#[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<String>,
#[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<String>,
}
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::*;
+19 -7
View File
@@ -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<String>,
#[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,
}
}
}
+33 -1
View File
@@ -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<Self> {
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
}
}
+4 -16
View File
@@ -214,23 +214,11 @@ pub(crate) async fn run() -> anyhow::Result<ProbeResult> {
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
}
}
@@ -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 }
@@ -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" ]
@@ -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
@@ -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<String>,
/// 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<String>,
},
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<String>,
/// 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}");
@@ -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<String>,
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:
<AttachedTicketMaterials as VersionedSerialise>::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::<serde_json::Value>(&json_str) {
Ok(json) => {
if let Some(outcome) = json.get("outcome") {
match outcome.get("socks5") {
Some(socks5) if socks5.is_null() => {
tracing::warn!("🌐⚠️ socks5 field is NULL in probe output");
}
Some(socks5) => {
tracing::info!("🌐 socks5 field present: {}", socks5);
}
None => {
tracing::warn!("🌐⚠️ socks5 field is MISSING from probe output");
}
}
} else {
tracing::warn!("🌐⚠️ outcome field is MISSING from probe output");
}
}
Err(e) => {
tracing::error!("Failed to parse probe output as JSON: {e}");
}
}
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<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"\n\{\s").expect("Invalid regex pattern"));
let result: Vec<_> = RE.splitn(log, 2).collect();
if result.len() == 2 {
format!("{{ {}", result[1])
} else {
String::new()
}
}
@@ -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<Mutex<Vec<u8>>>,
capturing: Arc<AtomicBool>,
}
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<Arc<Mutex<Vec<u8>>>>,
}
impl Write for LogCaptureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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
},
}
}
}
@@ -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<String>) -> 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
}
@@ -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<String>,
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(
<AttachedTicketMaterials as VersionedSerialise>::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()
}
}
}
}
@@ -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
@@ -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<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"\n\{\s").expect("Invalid regex pattern"));
let result: Vec<_> = RE.splitn(log, 2).collect();
if result.len() == 2 {
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)?;
@@ -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;
@@ -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};
@@ -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 }
@@ -38,7 +38,7 @@ impl NsApiClient {
}
}
#[instrument(level = "debug", skip_all)]
#[instrument(level = "info", skip_all)]
pub async fn request_testrun(&self) -> anyhow::Result<Option<TestrunAssignmentWithTickets>> {
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,
@@ -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<IssuedTicketBook>,
pub usable_index: u32,
}
#[derive(Deserialize, Serialize)]
pub struct AttachedTicketMaterials {
pub coin_indices_signatures: Vec<VersionSerialised<AggregatedCoinIndicesSignatures>>,
pub expiration_date_signatures: Vec<VersionSerialised<AggregatedExpirationDateSignatures>>,
pub master_verification_keys: Vec<VersionSerialised<EpochVerificationKey>>,
// two V1WireguardEntry tickets needed: one for WG registration, one for LP registration
pub attached_tickets: Vec<AttachedTicket>,
}
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<Self, Error> {
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<Option<u8>>) -> Result<Self, Error>
where
Self: DeserializeOwned,
{
let revision = revision
.into()
.unwrap_or(<Self as VersionedSerialise>::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,
@@ -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 &registration.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
}
}