diff --git a/Cargo.lock b/Cargo.lock index 4896aab406..bbd6905ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3822,9 +3822,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.155" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libm" @@ -5010,6 +5010,7 @@ dependencies = [ name = "nym-common-models" version = "0.1.0" dependencies = [ + "nym-crypto", "serde", ] @@ -6055,8 +6056,11 @@ dependencies = [ "clap 4.5.20", "nym-bin-common", "nym-common-models", + "nym-crypto", + "rand", "reqwest 0.12.4", "serde_json", + "tempfile", "tokio", "tokio-util", "tracing", @@ -6077,6 +6081,7 @@ dependencies = [ "moka", "nym-bin-common", "nym-common-models", + "nym-crypto", "nym-explorer-client", "nym-network-defaults", "nym-node-requests", @@ -8277,9 +8282,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" dependencies = [ "bitflags 2.5.0", "errno", @@ -9514,12 +9519,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand 2.1.1", + "once_cell", "rustix", "windows-sys 0.52.0", ] diff --git a/Cargo.toml b/Cargo.toml index 668450aaf9..7735cb8f59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -329,7 +329,7 @@ syn = "1" sysinfo = "0.30.13" tap = "1.0.1" tar = "0.4.42" -tempfile = "3.5.0" +tempfile = "3.14" thiserror = "1.0.64" time = "0.3.30" tokio = "1.39" diff --git a/common/models/Cargo.toml b/common/models/Cargo.toml index acb6e35682..123f3f7fd2 100644 --- a/common/models/Cargo.toml +++ b/common/models/Cargo.toml @@ -12,3 +12,4 @@ readme.workspace = true [dependencies] serde = { workspace = true, features = ["derive"] } +nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"] } diff --git a/common/models/src/ns_api.rs b/common/models/src/ns_api.rs index 5d875420e2..263628eb40 100644 --- a/common/models/src/ns_api.rs +++ b/common/models/src/ns_api.rs @@ -1,3 +1,4 @@ +use nym_crypto::asymmetric::ed25519::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -5,3 +6,10 @@ pub struct TestrunAssignment { pub testrun_id: i64, pub gateway_identity_key: String, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubmitResults { + pub message: String, + pub signature: Signature, + pub public_key: PublicKey, +} diff --git a/nym-node-status-agent/.gitignore b/nym-node-status-agent/.gitignore index bf462f3e05..4bb044ec5e 100644 --- a/nym-node-status-agent/.gitignore +++ b/nym-node-status-agent/.gitignore @@ -1 +1,2 @@ nym-gateway-probe +keys/ diff --git a/nym-node-status-agent/Cargo.toml b/nym-node-status-agent/Cargo.toml index f7d8543a1f..9a61cd5084 100644 --- a/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-agent/Cargo.toml @@ -19,9 +19,14 @@ anyhow = { workspace = true} clap = { workspace = true, features = ["derive", "env"] } nym-bin-common = { path = "../common/bin-common", features = ["models"]} nym-common-models = { path = "../common/models" } +nym-crypto = { path = "../common/crypto", features = ["asymmetric", "rand"] } +rand = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process"] } tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } reqwest = { workspace = true, features = ["json"] } serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/nym-node-status-agent/gen_keypair.sh b/nym-node-status-agent/gen_keypair.sh new file mode 100755 index 0000000000..b93bd26931 --- /dev/null +++ b/nym-node-status-agent/gen_keypair.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +mkdir -p keys +cargo run --package nym-node-status-agent -- generate-keypair --path keys/private diff --git a/nym-node-status-agent/run.sh b/nym-node-status-agent/run.sh index 675e39c109..46b997d3c0 100755 --- a/nym-node-status-agent/run.sh +++ b/nym-node-status-agent/run.sh @@ -4,7 +4,7 @@ set -eu environment="qa" -source ../envs/${environment}.env +probe_git_ref="0dd5dacdda92b1ddd51cd30a3399515e45613371" export RUST_LOG="debug" @@ -13,13 +13,17 @@ gateway_probe_src=$(dirname $(dirname "$crate_root"))/nym-vpn-client/nym-vpn-cor echo "gateway_probe_src=$gateway_probe_src" echo "crate_root=$crate_root" +export RUST_LOG="debug" +export NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1" +export NODE_STATUS_AGENT_SERVER_PORT="8000" export NODE_STATUS_AGENT_PROBE_PATH="$crate_root/nym-gateway-probe" +export NODE_STATUS_AGENT_AUTH_KEY="BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT" # build & copy over GW probe function copy_gw_probe() { pushd $gateway_probe_src - git switch main - git pull + git fetch -a + git checkout $probe_git_ref cargo build --release --package nym-gateway-probe cp target/release/nym-gateway-probe "$crate_root" $crate_root/nym-gateway-probe --version @@ -45,9 +49,6 @@ function swarm() { echo "All agents completed" } -export NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1" -export NODE_STATUS_AGENT_SERVER_PORT="8000" - copy_gw_probe swarm 8 diff --git a/nym-node-status-agent/src/cli.rs b/nym-node-status-agent/src/cli.rs deleted file mode 100644 index a7bbef5ebe..0000000000 --- a/nym-node-status-agent/src/cli.rs +++ /dev/null @@ -1,118 +0,0 @@ -use anyhow::bail; -use clap::{Parser, Subcommand}; -use nym_bin_common::bin_info; -use nym_common_models::ns_api::TestrunAssignment; -use std::sync::OnceLock; -use tracing::instrument; - -use crate::probe::GwProbe; - -// Helper for passing LONG_VERSION to clap -fn pretty_build_info_static() -> &'static str { - static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); - PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) -} - -#[derive(Parser, Debug)] -#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] -pub(crate) struct Args { - #[command(subcommand)] - pub(crate) command: Command, - #[arg(short, long, env = "NODE_STATUS_AGENT_SERVER_ADDRESS")] - pub(crate) server_address: String, - - #[arg(short = 'p', long, env = "NODE_STATUS_AGENT_SERVER_PORT")] - pub(crate) server_port: u16, - // TODO dz accept keypair for identification / auth -} - -#[derive(Subcommand, Debug)] -pub(crate) enum Command { - RunProbe { - /// path of binary to run - #[arg(long, env = "NODE_STATUS_AGENT_PROBE_PATH")] - probe_path: String, - }, -} - -impl Args { - pub(crate) async fn execute(&self) -> anyhow::Result<()> { - match &self.command { - Command::RunProbe { probe_path } => self.run_probe(probe_path).await?, - } - - Ok(()) - } - - async fn run_probe(&self, probe_path: &str) -> anyhow::Result<()> { - let server_address = format!("{}:{}", &self.server_address, self.server_port); - - let probe = GwProbe::new(probe_path.to_string()); - - let version = probe.version().await; - tracing::info!("Probe version:\n{}", version); - - if let Some(testrun) = request_testrun(&server_address).await? { - let log = probe.run_and_get_log(&Some(testrun.gateway_identity_key)); - - submit_results(&server_address, testrun.testrun_id, log).await?; - } else { - tracing::info!("No testruns available, exiting") - } - - Ok(()) - } -} - -const URL_BASE: &str = "internal/testruns"; - -#[instrument(level = "debug", skip_all)] -async fn request_testrun(server_addr: &str) -> anyhow::Result> { - let target_url = format!("{}/{}", server_addr, URL_BASE); - let client = reqwest::Client::new(); - let res = client.get(target_url).send().await?; - let status = res.status(); - let response_text = res.text().await?; - - if status.is_client_error() { - bail!("{}: {}", status, response_text); - } else if status.is_server_error() { - if matches!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE) - && response_text.contains("No testruns available") - { - return Ok(None); - } else { - bail!("{}: {}", status, response_text); - } - } - - serde_json::from_str(&response_text) - .map(|testrun| { - tracing::info!("Received testrun assignment: {:?}", testrun); - testrun - }) - .map_err(|err| { - tracing::error!("err"); - err.into() - }) -} - -#[instrument(level = "debug", skip(probe_outcome))] -async fn submit_results( - server_addr: &str, - testrun_id: i64, - probe_outcome: String, -) -> anyhow::Result<()> { - let target_url = format!("{}/{}/{}", server_addr, URL_BASE, testrun_id); - let client = reqwest::Client::new(); - - let res = client - .post(target_url) - .body(probe_outcome) - .send() - .await - .and_then(|response| response.error_for_status())?; - - tracing::debug!("Submitted results: {})", res.status()); - Ok(()) -} diff --git a/nym-node-status-agent/src/cli/generate_keypair.rs b/nym-node-status-agent/src/cli/generate_keypair.rs new file mode 100644 index 0000000000..ba86db838d --- /dev/null +++ b/nym-node-status-agent/src/cli/generate_keypair.rs @@ -0,0 +1,59 @@ +use std::{fs::File, io::Write, path::Path}; + +use tracing::info; + +pub(crate) fn generate_key_pair(path: impl AsRef) -> anyhow::Result<()> { + let priv_key_path = path.as_ref(); + let mut rng = rand::thread_rng(); + let keypair = nym_crypto::asymmetric::identity::KeyPair::new(&mut rng); + info!("Generated keypair as Base58-encoded string"); + + let mut private_key_file = File::create(priv_key_path)?; + private_key_file.write_all(keypair.private_key().to_base58_string().as_bytes())?; + + let pub_key_path = priv_key_path.with_extension("public"); + let mut public_key_file = File::create(&pub_key_path)?; + public_key_file.write_all(keypair.public_key().to_base58_string().as_bytes())?; + + info!( + "Saved Base58-encoded keypair, private key to {}, public key to {}", + priv_key_path.display(), + pub_key_path.display() + ); + info!("Public key should be whitelisted with NS API"); + + Ok(()) +} + +#[cfg(test)] +mod test { + use nym_crypto::asymmetric::ed25519::PrivateKey; + use tempfile::TempDir; + + use super::*; + + use std::{ + fs::{self}, + path::PathBuf, + }; + + #[test] + fn can_generate_valid_keypair() { + let tmp_dir = TempDir::new().unwrap(); + let pkey_file = PathBuf::from_iter(&[ + tmp_dir.path().to_path_buf(), + PathBuf::from("agent-key-private"), + ]); + generate_key_pair(&pkey_file).expect("Failed to generate keypair"); + + let pkey_raw = fs::read_to_string(&pkey_file).expect("Failed to read file"); + let key = PrivateKey::from_base58_string(pkey_raw).expect("Failed to load key"); + + let msg = "hello, world"; + + let signature = key.sign(msg); + key.public_key() + .verify(msg, &signature) + .expect("Failed to verify signature"); + } +} diff --git a/nym-node-status-agent/src/cli/mod.rs b/nym-node-status-agent/src/cli/mod.rs new file mode 100644 index 0000000000..5b7b7beadb --- /dev/null +++ b/nym-node-status-agent/src/cli/mod.rs @@ -0,0 +1,74 @@ +use crate::probe::GwProbe; +use clap::{Parser, Subcommand}; +use nym_bin_common::bin_info; +use std::sync::OnceLock; + +pub(crate) mod generate_keypair; +pub(crate) mod run_probe; + +// Helper for passing LONG_VERSION to clap +fn pretty_build_info_static() -> &'static str { + static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); + PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) +} + +#[derive(Parser, Debug)] +#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] +pub(crate) struct Args { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + RunProbe { + #[arg(short, long, env = "NODE_STATUS_AGENT_SERVER_ADDRESS")] + server_address: String, + + #[arg(short = 'p', long, env = "NODE_STATUS_AGENT_SERVER_PORT")] + server_port: u16, + + /// base58-encoded private key + #[arg(long, env = "NODE_STATUS_AGENT_AUTH_KEY")] + ns_api_auth_key: String, + + /// path of binary to run + #[arg(long, env = "NODE_STATUS_AGENT_PROBE_PATH")] + probe_path: String, + }, + + GenerateKeypair { + #[arg(long)] + path: Option, + }, +} + +impl Args { + pub(crate) async fn execute(&self) -> anyhow::Result<()> { + match &self.command { + Command::RunProbe { + server_address, + server_port, + ns_api_auth_key, + probe_path, + } => run_probe::run_probe( + server_address, + server_port.to_owned(), + ns_api_auth_key, + probe_path, + ) + .await + .inspect_err(|err| { + tracing::error!("{err}"); + })?, + Command::GenerateKeypair { path } => { + let path = path + .to_owned() + .unwrap_or_else(|| String::from("private-key")); + generate_keypair::generate_key_pair(path)? + } + } + + Ok(()) + } +} diff --git a/nym-node-status-agent/src/cli/run_probe.rs b/nym-node-status-agent/src/cli/run_probe.rs new file mode 100644 index 0000000000..3c8ce13509 --- /dev/null +++ b/nym-node-status-agent/src/cli/run_probe.rs @@ -0,0 +1,119 @@ +use anyhow::{bail, Context}; +use nym_common_models::ns_api::{SubmitResults, TestrunAssignment}; +use nym_crypto::asymmetric::ed25519::{PrivateKey, PublicKey}; +use tracing::instrument; + +use crate::cli::GwProbe; + +const URL_BASE: &str = "internal/testruns"; + +pub(crate) async fn run_probe( + server_address: &str, + server_port: u16, + ns_api_auth_key: &str, + probe_path: &str, +) -> anyhow::Result<()> { + let server_address = format!("{}:{}", server_address, server_port); + test_ns_api_conn(&server_address).await?; + + let auth_key = PrivateKey::from_base58_string(ns_api_auth_key) + .context("Couldn't parse auth key, exiting")?; + + let probe = GwProbe::new(probe_path.to_string()); + + let version = probe.version().await; + tracing::info!("Probe version:\n{}", version); + + if let Some(testrun) = request_testrun(auth_key.public_key(), &server_address).await? { + let log = probe.run_and_get_log(&Some(testrun.gateway_identity_key)); + + submit_results(auth_key, &server_address, testrun.testrun_id, log).await?; + } else { + tracing::info!("No testruns available, exiting") + } + + Ok(()) +} + +async fn test_ns_api_conn(server_addr: &str) -> anyhow::Result<()> { + reqwest::get(server_addr) + .await + .map(|res| { + tracing::info!( + "Testing connection to NS API at {server_addr}: {}", + res.status() + ); + }) + .map_err(|err| anyhow::anyhow!("Couldn't connect to server on {}: {}", server_addr, err)) +} + +#[instrument(level = "debug", skip_all)] +pub(crate) async fn request_testrun( + auth_key: PublicKey, + server_addr: &str, +) -> anyhow::Result> { + let target_url = format!("{}/{}", server_addr, URL_BASE); + let client = reqwest::Client::new(); + let res = client + .get(target_url) + .body(auth_key.to_base58_string()) + .send() + .await?; + let status = res.status(); + let response_text = res.text().await?; + + if status.is_client_error() { + bail!("{}: {}", status, response_text); + } else if status.is_server_error() { + if matches!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE) + && response_text.contains("No testruns available") + { + return Ok(None); + } else { + bail!("{}: {}", status, response_text); + } + } + + serde_json::from_str(&response_text) + .map(|testrun| { + tracing::info!("Received testrun assignment: {:?}", testrun); + testrun + }) + .map_err(|err| { + tracing::error!("err"); + err.into() + }) +} + +#[instrument(level = "debug", skip(auth_key, probe_outcome))] +pub(crate) async fn submit_results( + auth_key: PrivateKey, + server_addr: &str, + testrun_id: i64, + probe_outcome: String, +) -> anyhow::Result<()> { + let target_url = format!("{}/{}/{}", server_addr, URL_BASE, testrun_id); + + let results = sign_message(auth_key, probe_outcome); + + let client = reqwest::Client::new(); + let res = client + .post(target_url) + .json(&results) + .send() + .await + .and_then(|response| response.error_for_status())?; + + tracing::debug!("Submitted results: {})", res.status()); + Ok(()) +} + +fn sign_message(key: PrivateKey, probe_outcome: String) -> SubmitResults { + let signature = key.sign(&probe_outcome); + + SubmitResults { + message: probe_outcome, + signature, + public_key: key.public_key(), + } +} diff --git a/nym-node-status-agent/src/main.rs b/nym-node-status-agent/src/main.rs index 133828b7fa..d3078753fa 100644 --- a/nym-node-status-agent/src/main.rs +++ b/nym-node-status-agent/src/main.rs @@ -11,26 +11,11 @@ async fn main() -> anyhow::Result<()> { setup_tracing(); let args = Args::parse(); - let server_addr = format!("{}:{}", args.server_address, args.server_port); - test_ns_api_conn(&server_addr).await?; - args.execute().await?; Ok(()) } -async fn test_ns_api_conn(server_addr: &str) -> anyhow::Result<()> { - reqwest::get(server_addr) - .await - .map(|res| { - tracing::info!( - "Testing connection to NS API at {server_addr}: {}", - res.status() - ); - }) - .map_err(|err| anyhow::anyhow!("Couldn't connect to server on {}: {}", server_addr, err)) -} - pub(crate) fn setup_tracing() { fn directive_checked(directive: impl Into) -> Directive { directive diff --git a/nym-node-status-api/Cargo.toml b/nym-node-status-api/Cargo.toml index 511ab91b59..102f06c3bb 100644 --- a/nym-node-status-api/Cargo.toml +++ b/nym-node-status-api/Cargo.toml @@ -23,6 +23,7 @@ futures-util = { workspace = true } moka = { workspace = true, features = ["future"] } nym-bin-common = { path = "../common/bin-common", features = ["models"]} nym-common-models = { path = "../common/models" } +nym-crypto = { path = "../common/crypto", features = ["asymmetric", "serde"] } nym-explorer-client = { path = "../explorer-api/explorer-client" } nym-network-defaults = { path = "../common/network-defaults" } nym-validator-client = { path = "../common/client-libs/validator-client" } diff --git a/nym-node-status-api/launch_node_status_api.sh b/nym-node-status-api/launch_node_status_api.sh index 5d92675412..20c1516749 100755 --- a/nym-node-status-api/launch_node_status_api.sh +++ b/nym-node-status-api/launch_node_status_api.sh @@ -7,6 +7,7 @@ export RUST_LOG=${RUST_LOG:-debug} export NYM_API_CLIENT_TIMEOUT=60 export EXPLORER_CLIENT_TIMEOUT=60 export NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL=60 +export NODE_STATUS_API_AGENT_KEY_LIST="H4z8kx5Kkf5JMQHhxaW1MwYndjKCDHC7HsVhHTFfBZ4J,AKYZeKaJTzJrWtidk3BZz7wsm4GapaTY4GwQYVL43cmc" export ENVIRONMENT="qa.env" diff --git a/nym-node-status-api/src/cli/mod.rs b/nym-node-status-api/src/cli/mod.rs index 84ee86577f..0ca2b9bf48 100644 --- a/nym-node-status-api/src/cli/mod.rs +++ b/nym-node-status-api/src/cli/mod.rs @@ -1,5 +1,6 @@ use clap::Parser; use nym_bin_common::bin_info; +use nym_crypto::asymmetric::ed25519::PublicKey; use reqwest::Url; use std::{sync::OnceLock, time::Duration}; @@ -56,7 +57,7 @@ pub(crate) struct Cli { #[clap( long, - default_value = "600", + default_value = "300", env = "NODE_STATUS_API_MONITOR_REFRESH_INTERVAL" )] #[arg(value_parser = parse_duration)] @@ -64,11 +65,35 @@ pub(crate) struct Cli { #[clap( long, - default_value = "600", + default_value = "300", env = "NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL" )] #[arg(value_parser = parse_duration)] pub(crate) testruns_refresh_interval: Duration, + + #[clap(long, env = "NODE_STATUS_API_AGENT_KEY_LIST")] + #[arg(value_parser = parse_key_list)] + agent_key_list: KeyList, +} + +impl Cli { + pub(crate) fn agent_key_list(&self) -> &Vec { + &self.agent_key_list.0 + } +} + +// We need a list of keys from clap. But if we define CLI argument as Vec, +// clap interprets that as type T which can be given as a CLI argument multiple +// times (so all of them are stored in a Vec). Thus we wrap Vec in a newtype +// pattern to have a list of keys and make clap happy. +#[derive(Debug, Clone)] +struct KeyList(Vec); + +fn parse_key_list(arg: &str) -> anyhow::Result { + arg.split(',') + .map(|value| PublicKey::from_base58_string(value.trim()).map_err(anyhow::Error::from)) + .collect::>>() + .map(KeyList) } fn parse_duration(arg: &str) -> Result { diff --git a/nym-node-status-api/src/http/api/testruns.rs b/nym-node-status-api/src/http/api/testruns.rs index b13d62ba88..02074f4f3b 100644 --- a/nym-node-status-api/src/http/api/testruns.rs +++ b/nym-node-status-api/src/http/api/testruns.rs @@ -4,6 +4,8 @@ use axum::{ extract::{Path, State}, Router, }; +use nym_common_models::ns_api::SubmitResults; +use nym_crypto::asymmetric::ed25519::PublicKey; use reqwest::StatusCode; use crate::db::models::TestRunStatus; @@ -28,10 +30,14 @@ pub(crate) fn routes() -> Router { } #[tracing::instrument(level = "debug", skip_all)] -async fn request_testrun(State(state): State) -> HttpResult> { - // TODO dz log agent's key +async fn request_testrun( + State(state): State, + body: String, +) -> HttpResult> { // TODO dz log agent's network probe version - tracing::debug!("Agent requested testrun"); + let agent_pubkey = authenticate_agent(&body, &state)?; + + tracing::debug!("Agent {} requested testrun", agent_pubkey); let db = state.db_pool(); let mut conn = db @@ -42,10 +48,11 @@ async fn request_testrun(State(state): State) -> HttpResult { if let Some(testrun) = res { - tracing::debug!( - "🏃‍ Assigned testrun row_id {} gateway {} to agent", + tracing::info!( + "🏃‍ Assigned testrun row_id {} gateway {} to agent {}", &testrun.testrun_id, - testrun.gateway_identity_key + testrun.gateway_identity_key, + agent_pubkey ); Ok(Json(testrun)) } else { @@ -57,13 +64,20 @@ async fn request_testrun(State(state): State) -> HttpResult, State(state): State, - body: String, + Json(probe_results): Json, ) -> HttpResult { + let agent_pubkey = authenticate_agent(&probe_results.public_key.to_base58_string(), &state)?; + agent_pubkey + .verify(&probe_results.message, &probe_results.signature) + .map_err(|_| { + tracing::warn!("Message verification failed, rejecting"); + HttpError::unauthorized() + })?; + let db = state.db_pool(); let mut conn = db .acquire() @@ -84,27 +98,31 @@ async fn submit_testrun( HttpError::internal_with_logging("No gateway found for testrun") })?; tracing::debug!( - "Agent submitted testrun {} for gateway {} ({} bytes)", + "Agent {} submitted testrun {} for gateway {} ({} bytes)", + agent_pubkey, testrun_id, gw_identity, - body.len(), + &probe_results.message.len(), ); // TODO dz this should be part of a single transaction: commit after everything is done queries::testruns::update_testrun_status(&mut conn, testrun_id, TestRunStatus::Complete) .await .map_err(HttpError::internal_with_logging)?; - queries::testruns::update_gateway_last_probe_log(&mut conn, testrun.gateway_id, &body) - .await - .map_err(HttpError::internal_with_logging)?; - let result = get_result_from_log(&body); + queries::testruns::update_gateway_last_probe_log( + &mut conn, + testrun.gateway_id, + &probe_results.message, + ) + .await + .map_err(HttpError::internal_with_logging)?; + let result = get_result_from_log(&probe_results.message); queries::testruns::update_gateway_last_probe_result(&mut conn, testrun.gateway_id, &result) .await .map_err(HttpError::internal_with_logging)?; queries::testruns::update_gateway_score(&mut conn, testrun.gateway_id) .await .map_err(HttpError::internal_with_logging)?; - // TODO dz log gw identity key tracing::info!( "✅ Testrun row_id {} for gateway {} complete", @@ -115,6 +133,23 @@ async fn submit_testrun( Ok(StatusCode::CREATED) } +fn authenticate_agent(base58_pubkey: &str, state: &AppState) -> HttpResult { + let agent_pubkey = PublicKey::from_base58_string(base58_pubkey).map_err(|_| { + if base58_pubkey.is_empty() { + tracing::warn!("Auth key missing from request body, rejecting"); + } else { + tracing::warn!("Failed to deserialize key from request body, rejecting"); + } + HttpError::unauthorized() + })?; + if !state.is_registered(&agent_pubkey) { + tracing::warn!("Public key {} not registered, rejecting", agent_pubkey); + return Err(HttpError::unauthorized()); + } + + Ok(agent_pubkey) +} + fn get_result_from_log(log: &str) -> String { let re = regex::Regex::new(r"\n\{\s").unwrap(); let result: Vec<_> = re.splitn(log, 2).collect(); diff --git a/nym-node-status-api/src/http/error.rs b/nym-node-status-api/src/http/error.rs index a7fe9d98ab..021c993827 100644 --- a/nym-node-status-api/src/http/error.rs +++ b/nym-node-status-api/src/http/error.rs @@ -15,6 +15,13 @@ impl HttpError { } } + pub(crate) fn unauthorized() -> Self { + Self { + message: serde_json::json!({"message": "Make sure your public key si registered with NS API"}).to_string(), + status: axum::http::StatusCode::UNAUTHORIZED, + } + } + pub(crate) fn internal_with_logging(msg: impl Display) -> Self { tracing::error!("{}", msg.to_string()); Self::internal() diff --git a/nym-node-status-api/src/http/server.rs b/nym-node-status-api/src/http/server.rs index 17d5d64ab0..714a4836e4 100644 --- a/nym-node-status-api/src/http/server.rs +++ b/nym-node-status-api/src/http/server.rs @@ -1,5 +1,6 @@ use axum::Router; use core::net::SocketAddr; +use nym_crypto::asymmetric::ed25519::PublicKey; use tokio::{net::TcpListener, task::JoinHandle}; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; @@ -14,10 +15,11 @@ pub(crate) async fn start_http_api( db_pool: DbPool, http_port: u16, nym_http_cache_ttl: u64, + agent_key_list: Vec, ) -> anyhow::Result { let router_builder = RouterBuilder::with_default_routes(); - let state = AppState::new(db_pool, nym_http_cache_ttl); + let state = AppState::new(db_pool, nym_http_cache_ttl, agent_key_list); let router = router_builder.with_state(state); // TODO dz do we need this to be configurable? diff --git a/nym-node-status-api/src/http/state.rs b/nym-node-status-api/src/http/state.rs index 6bccea39a1..2001244ef4 100644 --- a/nym-node-status-api/src/http/state.rs +++ b/nym-node-status-api/src/http/state.rs @@ -1,6 +1,7 @@ use std::{sync::Arc, time::Duration}; use moka::{future::Cache, Entry}; +use nym_crypto::asymmetric::ed25519::PublicKey; use tokio::sync::RwLock; use crate::{ @@ -12,13 +13,15 @@ use crate::{ pub(crate) struct AppState { db_pool: DbPool, cache: HttpCache, + agent_key_list: Vec, } impl AppState { - pub(crate) fn new(db_pool: DbPool, cache_ttl: u64) -> Self { + pub(crate) fn new(db_pool: DbPool, cache_ttl: u64, agent_key_list: Vec) -> Self { Self { db_pool, cache: HttpCache::new(cache_ttl), + agent_key_list, } } @@ -29,6 +32,10 @@ impl AppState { pub(crate) fn cache(&self) -> &HttpCache { &self.cache } + + pub(crate) fn is_registered(&self, agent_pubkey: &PublicKey) -> bool { + self.agent_key_list.contains(agent_pubkey) + } } static GATEWAYS_LIST_KEY: &str = "gateways"; diff --git a/nym-node-status-api/src/main.rs b/nym-node-status-api/src/main.rs index b2c68b391b..facb4485b9 100644 --- a/nym-node-status-api/src/main.rs +++ b/nym-node-status-api/src/main.rs @@ -14,6 +14,9 @@ async fn main() -> anyhow::Result<()> { let args = cli::Cli::parse(); + let agent_key_list = args.agent_key_list(); + tracing::info!("Registered {} agent keys", agent_key_list.len()); + let connection_url = args.database_url.clone(); tracing::debug!("Using config:\n{:#?}", args); @@ -31,12 +34,14 @@ async fn main() -> anyhow::Result<()> { .await; tracing::info!("Started monitor task"); }); + testruns::spawn(storage.pool_owned(), args.testruns_refresh_interval).await; let shutdown_handles = http::server::start_http_api( storage.pool_owned(), args.http_port, args.nym_http_cache_ttl, + agent_key_list.to_owned(), ) .await .expect("Failed to start server"); diff --git a/nym-node-status-api/src/testruns/mod.rs b/nym-node-status-api/src/testruns/mod.rs index f487523f36..7ac916fc8e 100644 --- a/nym-node-status-api/src/testruns/mod.rs +++ b/nym-node-status-api/src/testruns/mod.rs @@ -1,4 +1,5 @@ use crate::db::models::GatewayIdentityDto; + use crate::db::DbPool; use futures_util::TryStreamExt; use std::time::Duration; @@ -24,8 +25,6 @@ pub(crate) async fn spawn(pool: DbPool, refresh_interval: Duration) { }); } -// TODO dz make number of max agents configurable - #[instrument(level = "debug", name = "testrun_queue", skip_all)] async fn run(pool: &DbPool) -> anyhow::Result<()> { tracing::info!("Spawning testruns...");