Remove freshness check on testrun submit (#5977)

* Remove freshness check on testrun submit
- freshness is enforced by a background task
  that marks testruns as stale after a
  configured amount of time

* Move code around

* Add humantime

* Update launch script

* Fix typo

* Adjust agent run script

* Configure user agent

* Bump version
This commit is contained in:
dynco-nym
2025-08-26 12:26:13 +02:00
committed by GitHub
parent 18d9d807f2
commit 0a48fa6172
12 changed files with 130 additions and 69 deletions
Generated
+3 -2
View File
@@ -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",
@@ -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
@@ -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<String>,
@@ -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"] }
@@ -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() {
@@ -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<String>,
#[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<std::time::Duration, std::num::ParseIntError> {
fn parse_duration_humantime(arg: &str) -> Result<time::Duration, anyhow::Error> {
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<std::time::Duration, std::num::ParseIntError> {
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());
}
}
}
@@ -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<get_testrun::GetTestrunRequest>,
) -> HttpResult<Json<TestrunAssignment>> {
// 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<AppState>,
Json(submitted_result): Json<submit_results::SubmitResults>,
) -> HttpResult<StatusCode> {
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<AppState>,
Json(submission): Json<submit_results_v2::SubmitResultsV2>,
) -> HttpResult<StatusCode> {
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<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"\n\{\s").expect("Invalid regex pattern"));
@@ -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<PublicKey>,
agent_max_count: i64,
agent_request_freshness_requirement: time::Duration,
node_geocache: NodeGeoCache,
node_delegations: Arc<RwLock<DelegationsCache>>,
) -> anyhow::Result<ShutdownHandles> {
@@ -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,
)
@@ -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<PublicKey>,
agent_max_count: i64,
agent_request_freshness_requirement: time::Duration,
node_geocache: NodeGeoCache,
node_delegations: Arc<RwLock<DelegationsCache>>,
bin_info: BinaryInfo,
@@ -42,6 +47,7 @@ impl AppState {
cache_ttl: u64,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
agent_request_freshness_requirement: time::Duration,
node_geocache: NodeGeoCache,
node_delegations: Arc<RwLock<DelegationsCache>>,
) -> 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";
@@ -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,
)
@@ -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
@@ -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,