diff --git a/Cargo.lock b/Cargo.lock index bf12ab61cf6..1fe9392469c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "nym-node-status-api" -version = "3.2.2" +version = "3.3.0" dependencies = [ "ammonia", "anyhow", @@ -6401,6 +6401,7 @@ dependencies = [ "clap", "cosmwasm-std", "futures-util", + "humantime", "itertools 0.14.0", "moka", "nym-bin-common", @@ -6443,7 +6444,7 @@ dependencies = [ [[package]] name = "nym-node-status-client" -version = "0.1.2" +version = "0.2.0" dependencies = [ "anyhow", "bincode", 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 d0f3018fddf..1061ad5f95c 100755 --- a/nym-node-status-api/nym-node-status-agent/run.sh +++ b/nym-node-status-api/nym-node-status-agent/run.sh @@ -16,12 +16,17 @@ set -a source "${monorepo_root}/envs/${ENVIRONMENT}.env" set +a +if [ -z "$NYM_NODE_MNEMONICS" ]; then + echo "NYM_NODE_MNEMONICS is required to run an agent" + exit 1 +fi + export RUST_LOG="info" -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" +NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1" +NODE_STATUS_AGENT_SERVER_PORT="8000" +SERVER="${NODE_STATUS_AGENT_SERVER_ADDRESS}|${NODE_STATUS_AGENT_SERVER_PORT}" export NODE_STATUS_AGENT_AUTH_KEY="BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT" -export NODE_STATUS_AGENT_PROBE_MNEMONIC="$MNEMONIC" +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" workers=${1:-1} @@ -48,7 +53,7 @@ function swarm() { local workers=$1 for ((i = 1; i <= workers; i++)); do - ${monorepo_root}/target/release/nym-node-status-agent run-probe & + ${monorepo_root}/target/release/nym-node-status-agent run-probe --server ${SERVER} & done wait 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 24283b081d3..bc5c055c76b 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 @@ -50,7 +50,7 @@ pub(crate) struct Args { #[derive(Subcommand, Debug)] pub(crate) enum Command { RunProbe { - /// Server configurations in format "address:port:auth_key" + /// Server configurations in format "address|port" /// Can be specified multiple times for multiple servers #[arg(short, long, required = true)] server: Vec, 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 95514c4259b..60c998d5617 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 = "3.2.2" +version = "3.3.0" authors.workspace = true repository.workspace = true homepage.workspace = true @@ -21,6 +21,7 @@ celes = { workspace = true } clap = { workspace = true, features = ["cargo", "derive", "env", "string"] } cosmwasm-std = { workspace = true } futures-util = { workspace = true } +humantime = { workspace = true } itertools = { workspace = true } moka = { workspace = true, features = ["future"] } diff --git a/nym-node-status-api/nym-node-status-api/launch_node_status_api.sh b/nym-node-status-api/nym-node-status-api/launch_node_status_api.sh index bd21bee53d9..9f49c1e8954 100755 --- a/nym-node-status-api/nym-node-status-api/launch_node_status_api.sh +++ b/nym-node-status-api/nym-node-status-api/launch_node_status_api.sh @@ -23,7 +23,7 @@ function run_bare() { echo "RUST_LOG=${RUST_LOG}" # --conection-url is provided in build.rs - cargo run --package nym-node-status-api + cargo run --package nym-node-status-api --no-default-features --features sqlite } function run_docker() { diff --git a/nym-node-status-api/nym-node-status-api/src/cli/mod.rs b/nym-node-status-api/nym-node-status-api/src/cli/mod.rs index 0adf49af402..d3cb0f9c15e 100644 --- a/nym-node-status-api/nym-node-status-api/src/cli/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/cli/mod.rs @@ -38,7 +38,7 @@ pub(crate) struct Cli { /// Nym api client timeout. #[clap(long, default_value = "15", env = "NYM_API_CLIENT_TIMEOUT")] - #[arg(value_parser = parse_duration)] + #[arg(value_parser = parse_duration_std)] pub(crate) nym_api_client_timeout: Duration, /// Connection url for the database. @@ -46,7 +46,7 @@ pub(crate) struct Cli { pub(crate) database_url: String, #[clap(long, default_value = "5", env = "SQLX_BUSY_TIMEOUT_S")] - #[arg(value_parser = parse_duration)] + #[arg(value_parser = parse_duration_std)] pub(crate) sqlx_busy_timeout_s: Duration, #[clap( @@ -54,7 +54,7 @@ pub(crate) struct Cli { default_value = "300", env = "NODE_STATUS_API_MONITOR_REFRESH_INTERVAL" )] - #[arg(value_parser = parse_duration)] + #[arg(value_parser = parse_duration_std)] pub(crate) monitor_refresh_interval: Duration, #[clap( @@ -62,17 +62,21 @@ pub(crate) struct Cli { default_value = "300", env = "NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL" )] - #[arg(value_parser = parse_duration)] + #[arg(value_parser = parse_duration_std)] pub(crate) testruns_refresh_interval: Duration, #[clap(long, default_value = "86400", env = "NODE_STATUS_API_GEODATA_TTL")] - #[arg(value_parser = parse_duration)] + #[arg(value_parser = parse_duration_std)] pub(crate) geodata_ttl: Duration, #[clap(env = "NODE_STATUS_API_AGENT_KEY_LIST")] #[arg(value_delimiter = ',')] pub(crate) agent_key_list: Vec, + #[clap(long, default_value = "120s", env = "AGENT_REQUEST_FRESHNESS")] + #[arg(value_parser = parse_duration_humantime)] + pub(crate) agent_request_freshness: time::Duration, + #[clap( long, default_value_t = 10, @@ -92,7 +96,40 @@ pub(crate) struct Cli { pub(crate) max_agent_count: i64, } -fn parse_duration(arg: &str) -> Result { +fn parse_duration_humantime(arg: &str) -> Result { + let std_duration = match humantime::parse_duration(arg) { + Ok(duration) => duration, + // assume old format (seconds) as a fallback + Err(_) => parse_duration_std(arg)?, + }; + + Ok(time::Duration::seconds(std_duration.as_secs() as i64)) +} + +fn parse_duration_std(arg: &str) -> Result { let seconds = arg.parse()?; Ok(std::time::Duration::from_secs(seconds)) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn humantime_should_work() { + let should_parse = [("120s", 120), ("120", 120), ("0s", 0), ("0", 0)]; + + for (raw, expected) in should_parse { + if let Ok(parsed) = parse_duration_humantime(raw) { + assert_eq!(parsed.whole_seconds(), expected); + } else { + panic!("Failed to parse {raw}") + } + } + + let should_not_parse = ["0.1s", "-15s"]; + for raw in should_not_parse { + assert!(parse_duration_humantime(raw).is_err()); + } + } +} 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 d557bcb854a..3292de1ad8e 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 @@ -16,12 +16,8 @@ use axum::{ extract::{Path, State}, Router, }; -use nym_node_status_client::{ - auth::VerifiableRequest, - models::{get_testrun, submit_results, submit_results_v2}, -}; +use nym_node_status_client::models::{get_testrun, submit_results, submit_results_v2}; use reqwest::StatusCode; -use tracing::warn; // TODO dz consider adding endpoint to trigger testrun scan for a given gateway_id // like in H< src/http/testruns.rs @@ -40,8 +36,8 @@ async fn request_testrun( Json(request): Json, ) -> HttpResult> { // TODO dz log agent's network probe version - authenticate(&request, &state)?; - is_fresh(&request.payload.timestamp)?; + state.authenticate_agent_submission(&request)?; + state.is_fresh(&request.payload.timestamp)?; tracing::debug!("Agent requested testrun"); @@ -87,7 +83,7 @@ async fn submit_testrun( State(state): State, Json(submitted_result): Json, ) -> HttpResult { - authenticate(&submitted_result, &state)?; + state.authenticate_agent_submission(&submitted_result)?; let db = state.db_pool(); let mut conn = db @@ -189,8 +185,7 @@ async fn submit_testrun_v2( State(state): State, Json(submission): Json, ) -> HttpResult { - authenticate(&submission, &state)?; - is_fresh(&submission.payload.assigned_at_utc)?; + state.authenticate_agent_submission(&submission)?; let db = state.db_pool(); let mut conn = db @@ -249,44 +244,6 @@ async fn submit_testrun_v2( } } -// TODO dz this should be middleware -#[tracing::instrument(level = "debug", skip_all)] -fn authenticate(request: &impl VerifiableRequest, state: &AppState) -> HttpResult<()> { - if !state.is_registered(request.public_key()) { - tracing::warn!("Public key not registered with NS API, rejecting"); - return Err(HttpError::unauthorized()); - }; - - request.verify_signature().map_err(|_| { - tracing::warn!("Signature verification failed, rejecting"); - HttpError::unauthorized() - })?; - - Ok(()) -} - -static FRESHNESS_CUTOFF: time::Duration = time::Duration::minutes(2); - -fn is_fresh(request_time: &i64) -> HttpResult<()> { - // if a request took longer than N minutes to reach NS API, something is very wrong - let request_time = time::UtcDateTime::from_unix_timestamp(*request_time).map_err(|e| { - warn!("Failed to parse request time: {e}"); - HttpError::unauthorized() - })?; - - let cutoff_timestamp = now_utc() - FRESHNESS_CUTOFF; - if request_time < cutoff_timestamp { - warn!( - "Request time {} is older than cutoff {} ({}s ago), rejecting", - request_time, - cutoff_timestamp, - FRESHNESS_CUTOFF.whole_seconds() - ); - return Err(HttpError::unauthorized()); - } - Ok(()) -} - 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")); diff --git a/nym-node-status-api/nym-node-status-api/src/http/server.rs b/nym-node-status-api/nym-node-status-api/src/http/server.rs index 548fc6206dd..f197929d9f9 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/server.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/server.rs @@ -13,12 +13,14 @@ use crate::{ /// Return handles that allow for graceful shutdown of server + awaiting its /// background tokio task +#[allow(clippy::too_many_arguments)] pub(crate) async fn start_http_api( db_pool: DbPool, http_port: u16, nym_http_cache_ttl: u64, agent_key_list: Vec, agent_max_count: i64, + agent_request_freshness_requirement: time::Duration, node_geocache: NodeGeoCache, node_delegations: Arc>, ) -> anyhow::Result { @@ -29,6 +31,7 @@ pub(crate) async fn start_http_api( nym_http_cache_ttl, agent_key_list, agent_max_count, + agent_request_freshness_requirement, node_geocache, node_delegations, ) diff --git a/nym-node-status-api/nym-node-status-api/src/http/state.rs b/nym-node-status-api/nym-node-status-api/src/http/state.rs index 03f48cb0d62..35a37992021 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/state.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/state.rs @@ -5,6 +5,7 @@ use nym_bin_common::bin_info_owned; use nym_contracts_common::NaiveFloat; use nym_crypto::asymmetric::ed25519::PublicKey; use nym_mixnet_contract_common::NodeId; +use nym_node_status_client::auth::VerifiableRequest; use nym_validator_client::nym_api::SkimmedNode; use semver::Version; use serde::{Deserialize, Serialize}; @@ -17,8 +18,11 @@ use utoipa::ToSchema; use super::models::SessionStats; use crate::{ db::{queries, DbPool}, - http::models::{ - DVpnGateway, DailyStats, ExtendedNymNode, Gateway, Mixnode, NodeGeoData, SummaryHistory, + http::{ + error::{HttpError, HttpResult}, + models::{ + DVpnGateway, DailyStats, ExtendedNymNode, Gateway, Mixnode, NodeGeoData, SummaryHistory, + }, }, monitor::{DelegationsCache, NodeGeoCache}, }; @@ -31,6 +35,7 @@ pub(crate) struct AppState { cache: HttpCache, agent_key_list: Vec, agent_max_count: i64, + agent_request_freshness_requirement: time::Duration, node_geocache: NodeGeoCache, node_delegations: Arc>, bin_info: BinaryInfo, @@ -42,6 +47,7 @@ impl AppState { cache_ttl: u64, agent_key_list: Vec, agent_max_count: i64, + agent_request_freshness_requirement: time::Duration, node_geocache: NodeGeoCache, node_delegations: Arc>, ) -> Self { @@ -50,6 +56,7 @@ impl AppState { cache: HttpCache::new(cache_ttl).await, agent_key_list, agent_max_count, + agent_request_freshness_requirement, node_geocache, node_delegations, bin_info: BinaryInfo::new(), @@ -94,6 +101,44 @@ impl AppState { pub(crate) fn build_information(&self) -> &BinaryBuildInformationOwned { &self.bin_info.build_info } + + #[tracing::instrument(level = "debug", skip_all)] + pub(crate) fn authenticate_agent_submission( + &self, + request: &impl VerifiableRequest, + ) -> HttpResult<()> { + if !self.is_registered(request.public_key()) { + tracing::warn!("Public key not registered with NS API, rejecting"); + return Err(HttpError::unauthorized()); + }; + + request.verify_signature().map_err(|_| { + tracing::warn!("Signature verification failed, rejecting"); + HttpError::unauthorized() + })?; + + Ok(()) + } + + pub(crate) fn is_fresh(&self, request_time: &i64) -> HttpResult<()> { + // if a request took longer than N minutes to reach NS API, something is very wrong + let request_time = time::UtcDateTime::from_unix_timestamp(*request_time).map_err(|e| { + warn!("Failed to parse request time: {e}"); + HttpError::unauthorized() + })?; + + let cutoff_timestamp = crate::utils::now_utc() - self.agent_request_freshness_requirement; + if request_time < cutoff_timestamp { + warn!( + "Request time {} is older than cutoff {} ({}s ago), rejecting", + request_time, + cutoff_timestamp, + self.agent_request_freshness_requirement.whole_seconds() + ); + return Err(HttpError::unauthorized()); + } + Ok(()) + } } static GATEWAYS_LIST_KEY: &str = "gateways"; diff --git a/nym-node-status-api/nym-node-status-api/src/main.rs b/nym-node-status-api/nym-node-status-api/src/main.rs index 60ebb3bb068..2723222415d 100644 --- a/nym-node-status-api/nym-node-status-api/src/main.rs +++ b/nym-node-status-api/nym-node-status-api/src/main.rs @@ -98,6 +98,7 @@ async fn main() -> anyhow::Result<()> { args.nym_http_cache_ttl, agent_key_list.to_owned(), args.max_agent_count, + args.agent_request_freshness, geocache, delegations_cache, ) 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 35443e45dc5..c5ee68d5018 100644 --- a/nym-node-status-api/nym-node-status-client/Cargo.toml +++ b/nym-node-status-api/nym-node-status-client/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-client" -version = "0.1.2" +version = "0.2.0" authors.workspace = true repository.workspace = true homepage.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 6451b88c4ae..e46897d8e42 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 @@ -2,7 +2,7 @@ use crate::models::{get_testrun, submit_results, submit_results_v2, TestrunAssig use anyhow::bail; use api::ApiPaths; use nym_crypto::asymmetric::ed25519::{PrivateKey, Signature}; -use tracing::instrument; +use tracing::{instrument, warn}; mod api; pub mod auth; @@ -18,7 +18,18 @@ impl NsApiClient { pub fn new(server_ip: &str, server_port: u16, auth_key: PrivateKey) -> Self { let server_address = format!("{server_ip}:{server_port}"); let api = ApiPaths::new(server_address); - let client = reqwest::Client::new(); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + let client = reqwest::Client::builder() + .user_agent(user_agent) + .build() + .inspect_err(|err| { + warn!( + "Failed to create client with user agent, falling back to default ({})", + err + ) + }) + // failing to set user agent shouldn't be a critical error + .unwrap_or_default(); Self { api,