From f226d75657da61faf718493af5ff27eea886197a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 5 Nov 2024 16:59:35 +0000 Subject: [PATCH 1/5] introduced nym-api endpoint for force refreshing described node data --- nym-api/nym-api-requests/src/models.rs | 44 ++++++++++++++ nym-api/src/ecash/tests/mod.rs | 1 + nym-api/src/node_describe_cache/mod.rs | 67 ++++++++++++++------- nym-api/src/nym_contract_cache/cache/mod.rs | 47 +++++++++++++++ nym-api/src/nym_nodes/handlers/mod.rs | 67 +++++++++++++++++++-- nym-api/src/support/caching/cache.rs | 13 +++- nym-api/src/support/cli/run.rs | 1 + nym-api/src/support/http/openapi.rs | 1 + nym-api/src/support/http/state.rs | 23 ++++++- 9 files changed, 236 insertions(+), 28 deletions(-) diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 34c51dab91..8c4d36c3e0 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::helpers::unix_epoch; +use crate::helpers::PlaceholderJsonSchemaImpl; use crate::legacy::{ LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer, }; @@ -1143,6 +1144,49 @@ pub struct NoiseDetails { pub ip_addresses: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NodeRefreshBody { + #[schema(value_type = u32)] + pub node_id: NodeId, + + // a poor man's nonce + pub request_timestamp: i64, + + #[schemars(with = "PlaceholderJsonSchemaImpl")] + pub signature: ed25519::Signature, +} + +impl NodeRefreshBody { + pub fn plaintext(&self) -> Vec { + self.node_id + .to_be_bytes() + .into_iter() + .chain(self.request_timestamp.to_be_bytes()) + .collect() + } + + pub fn verify_signature(&self, public_key: &ed25519::PublicKey) -> bool { + public_key.verify(self.plaintext(), &self.signature).is_ok() + } + + pub fn is_stale(&self) -> bool { + let Ok(encoded) = OffsetDateTime::from_unix_timestamp(self.request_timestamp) else { + return false; + }; + let now = OffsetDateTime::now_utc(); + + if encoded > now { + return false; + } + + if (encoded + Duration::from_secs(30)) < now { + return false; + } + + true + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 123c0a7930..3e81637322 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -1261,6 +1261,7 @@ struct TestFixture { impl TestFixture { fn build_app_state(storage: NymApiStorage) -> AppState { AppState { + forced_refresh: Default::default(), nym_contract_cache: NymContractCache::new(), node_status_cache: NodeStatusCache::new(), circulating_supply_cache: CirculatingSupplyCache::new("unym".to_owned()), diff --git a/nym-api/src/node_describe_cache/mod.rs b/nym-api/src/node_describe_cache/mod.rs index 8368e58a97..b1efb2f645 100644 --- a/nym-api/src/node_describe_cache/mod.rs +++ b/nym-api/src/node_describe_cache/mod.rs @@ -9,9 +9,10 @@ use crate::support::config; use crate::support::config::DEFAULT_NODE_DESCRIBE_BATCH_SIZE; use async_trait::async_trait; use futures::{stream, StreamExt}; +use nym_api_requests::legacy::{LegacyGatewayBondWithId, LegacyMixNodeDetailsWithLayer}; use nym_api_requests::models::{DescribedNodeType, NymNodeData, NymNodeDescription}; use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT; -use nym_mixnet_contract_common::{LegacyMixLayer, NodeId}; +use nym_mixnet_contract_common::{LegacyMixLayer, NodeId, NymNodeDetails}; use nym_node_requests::api::client::{NymNodeApiClientError, NymNodeApiClientExt}; use nym_topology::gateway::GatewayConversionError; use nym_topology::mix::MixnodeConversionError; @@ -151,6 +152,10 @@ pub struct DescribedNodes { } impl DescribedNodes { + pub fn force_update(&mut self, node: NymNodeDescription) { + self.nodes.insert(node.node_id, node); + } + pub fn get_description(&self, node_id: &NodeId) -> Option<&NymNodeData> { self.nodes.get(node_id).map(|n| &n.description) } @@ -292,7 +297,7 @@ async fn try_get_description( } #[derive(Debug)] -struct RefreshData { +pub(crate) struct RefreshData { host: String, node_id: NodeId, node_type: DescribedNodeType, @@ -300,6 +305,39 @@ struct RefreshData { port: Option, } +impl<'a> From<&'a LegacyMixNodeDetailsWithLayer> for RefreshData { + fn from(node: &'a LegacyMixNodeDetailsWithLayer) -> Self { + RefreshData::new( + &node.bond_information.mix_node.host, + DescribedNodeType::LegacyMixnode, + node.mix_id(), + Some(node.bond_information.mix_node.http_api_port), + ) + } +} + +impl<'a> From<&'a LegacyGatewayBondWithId> for RefreshData { + fn from(node: &'a LegacyGatewayBondWithId) -> Self { + RefreshData::new( + &node.bond.gateway.host, + DescribedNodeType::LegacyGateway, + node.node_id, + None, + ) + } +} + +impl<'a> From<&'a NymNodeDetails> for RefreshData { + fn from(node: &'a NymNodeDetails) -> Self { + RefreshData::new( + &node.bond_information.node.host, + DescribedNodeType::NymNode, + node.node_id(), + node.bond_information.node.custom_http_port, + ) + } +} + impl RefreshData { pub fn new( host: impl Into, @@ -315,7 +353,7 @@ impl RefreshData { } } - async fn try_refresh(self) -> Option { + pub(crate) async fn try_refresh(self) -> Option { match try_get_description(self).await { Ok(description) => Some(description), Err(err) => { @@ -341,18 +379,13 @@ impl CacheItemProvider for NodeDescriptionProvider { // - legacy gateways (because they might already be running nym-nodes, but haven't updated contract info) // - nym-nodes - let mut nodes_to_query = Vec::new(); + let mut nodes_to_query: Vec = Vec::new(); match self.contract_cache.all_cached_legacy_mixnodes().await { None => error!("failed to obtain mixnodes information from the cache"), Some(legacy_mixnodes) => { for node in &**legacy_mixnodes { - nodes_to_query.push(RefreshData::new( - &node.bond_information.mix_node.host, - DescribedNodeType::LegacyMixnode, - node.mix_id(), - Some(node.bond_information.mix_node.http_api_port), - )) + nodes_to_query.push(node.into()) } } } @@ -361,12 +394,7 @@ impl CacheItemProvider for NodeDescriptionProvider { None => error!("failed to obtain gateways information from the cache"), Some(legacy_gateways) => { for node in &**legacy_gateways { - nodes_to_query.push(RefreshData::new( - &node.bond.gateway.host, - DescribedNodeType::LegacyGateway, - node.node_id, - None, - )) + nodes_to_query.push(node.into()) } } } @@ -375,12 +403,7 @@ impl CacheItemProvider for NodeDescriptionProvider { None => error!("failed to obtain nym-nodes information from the cache"), Some(nym_nodes) => { for node in &**nym_nodes { - nodes_to_query.push(RefreshData::new( - &node.bond_information.node.host, - DescribedNodeType::NymNode, - node.node_id(), - node.bond_information.node.custom_http_port, - )) + nodes_to_query.push(node.into()) } } } diff --git a/nym-api/src/nym_contract_cache/cache/mod.rs b/nym-api/src/nym_contract_cache/cache/mod.rs index ac7f89b0c5..a4824eaf52 100644 --- a/nym-api/src/nym_contract_cache/cache/mod.rs +++ b/nym-api/src/nym_contract_cache/cache/mod.rs @@ -1,6 +1,7 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::node_describe_cache::RefreshData; use crate::nym_contract_cache::cache::data::CachedContractsInfo; use crate::support::caching::Cache; use data::ValidatorCacheData; @@ -8,6 +9,7 @@ use nym_api_requests::legacy::{ LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer, }; use nym_api_requests::models::MixnodeStatus; +use nym_crypto::asymmetric::ed25519; use nym_mixnet_contract_common::{Interval, NodeId, NymNodeDetails, RewardedSet, RewardingParams}; use std::{ collections::HashSet, @@ -28,6 +30,11 @@ pub(crate) use self::data::CachedRewardedSet; const CACHE_TIMEOUT_MS: u64 = 100; +pub(crate) struct RefreshDataWithKey { + pub(crate) pubkey: ed25519::PublicKey, + pub(crate) refresh_data: RefreshData, +} + #[derive(Clone)] pub struct NymContractCache { pub(crate) initialised: Arc, @@ -352,6 +359,46 @@ impl NymContractCache { self.legacy_mixnode_details(mix_id).await.1 } + pub async fn get_public_key_with_refresh_data( + &self, + node_id: NodeId, + ) -> Option { + if !self.initialised() { + return None; + } + + let inner = self.inner.read().await; + + // 1. check nymnodes + if let Some(nym_node) = inner.nym_nodes.iter().find(|n| n.node_id() == node_id) { + let pubkey = nym_node.bond_information.identity().parse().ok()?; + return Some(RefreshDataWithKey { + pubkey, + refresh_data: nym_node.into(), + }); + } + + // 2. check legacy mixnodes + if let Some(mixnode) = inner.legacy_mixnodes.iter().find(|n| n.mix_id() == node_id) { + let pubkey = mixnode.bond_information.identity().parse().ok()?; + return Some(RefreshDataWithKey { + pubkey, + refresh_data: mixnode.into(), + }); + } + + // 3. check legacy gateways + if let Some(gateway) = inner.legacy_gateways.iter().find(|n| n.node_id == node_id) { + let pubkey = gateway.identity().parse().ok()?; + return Some(RefreshDataWithKey { + pubkey, + refresh_data: gateway.into(), + }); + } + + None + } + pub fn initialised(&self) -> bool { self.initialised.load(Ordering::Relaxed) } diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index 4ccb3c02de..972f477a95 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -5,11 +5,12 @@ use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; use crate::support::http::helpers::{NodeIdParam, PaginationRequest}; use crate::support::http::state::AppState; use axum::extract::{Path, Query, State}; -use axum::routing::get; +use axum::http::StatusCode; +use axum::routing::{get, post}; use axum::{Json, Router}; use nym_api_requests::models::{ - AnnotationResponse, NodeDatePerformanceResponse, NodePerformanceResponse, NoiseDetails, - NymNodeDescription, PerformanceHistoryResponse, UptimeHistoryResponse, + AnnotationResponse, NodeDatePerformanceResponse, NodePerformanceResponse, NodeRefreshBody, + NoiseDetails, NymNodeDescription, PerformanceHistoryResponse, UptimeHistoryResponse, }; use nym_api_requests::pagination::{PaginatedResponse, Pagination}; use nym_contracts_common::NaiveFloat; @@ -17,7 +18,8 @@ use nym_mixnet_contract_common::reward_params::Performance; use nym_mixnet_contract_common::NymNodeDetails; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use time::Date; +use std::time::Duration; +use time::{Date, OffsetDateTime}; use utoipa::{IntoParams, ToSchema}; pub(crate) mod legacy; @@ -25,6 +27,7 @@ pub(crate) mod unstable; pub(crate) fn nym_node_routes() -> Router { Router::new() + .route("/refresh-described", post(refresh_described)) .route("/noise", get(nodes_noise)) .route("/bonded", get(get_bonded_nodes)) .route("/described", get(get_described_nodes)) @@ -42,6 +45,62 @@ pub(crate) fn nym_node_routes() -> Router { .route("/uptime-history/:node_id", get(get_node_uptime_history)) } +#[utoipa::path( + tag = "Nym Nodes", + post, + request_body = NodeRefreshBody, + path = "/refresh-described", + context_path = "/v1/nym-nodes", +)] +async fn refresh_described( + State(state): State, + Json(request_body): Json, +) -> StatusCode { + let Some(refresh_data) = state + .nym_contract_cache() + .get_public_key_with_refresh_data(request_body.node_id) + .await + else { + return StatusCode::NOT_FOUND; + }; + + if !request_body.verify_signature(&refresh_data.pubkey) { + return StatusCode::UNAUTHORIZED; + } + + if request_body.is_stale() { + return StatusCode::BAD_REQUEST; + } + + if let Some(last) = state + .forced_refresh + .last_refreshed(request_body.node_id) + .await + { + // max 1 refresh a minute + let minute_ago = OffsetDateTime::now_utc() - Duration::from_secs(60); + if last > minute_ago { + return StatusCode::BAD_REQUEST; + } + } + // to make sure you can't ddos the endpoint while a request is in progress + state + .forced_refresh + .set_last_refreshed(request_body.node_id) + .await; + + if let Some(updated_data) = refresh_data.refresh_data.try_refresh().await { + let Ok(mut describe_cache) = state.described_nodes_cache.write().await else { + return StatusCode::SERVICE_UNAVAILABLE; + }; + describe_cache.get_mut().force_update(updated_data) + } else { + return StatusCode::UNPROCESSABLE_ENTITY; + } + + StatusCode::OK +} + #[utoipa::path( tag = "Nym Nodes", get, diff --git a/nym-api/src/support/caching/cache.rs b/nym-api/src/support/caching/cache.rs index 706d2b342b..405f6022a3 100644 --- a/nym-api/src/support/caching/cache.rs +++ b/nym-api/src/support/caching/cache.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::Duration; use thiserror::Error; use time::OffsetDateTime; -use tokio::sync::{RwLock, RwLockReadGuard}; +use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard}; #[derive(Debug, Error)] #[error("the cache item has not been initialised")] @@ -45,6 +45,13 @@ impl SharedCache { RwLockReadGuard::try_map(guard, |a| a.inner.as_ref()).map_err(|_| UninitialisedCache) } + pub(crate) async fn write( + &self, + ) -> Result>, UninitialisedCache> { + let guard = self.0.write().await; + RwLockWriteGuard::try_map(guard, |a| a.inner.as_mut()).map_err(|_| UninitialisedCache) + } + // ignores expiration data #[allow(dead_code)] pub(crate) async fn unchecked_get_inner( @@ -134,6 +141,10 @@ impl Cache { self.as_at = OffsetDateTime::now_utc() } + pub(crate) fn get_mut(&mut self) -> &mut T { + &mut self.value + } + #[allow(dead_code)] pub fn has_expired(&self, ttl: Duration, now: Option) -> bool { let now = now.unwrap_or(OffsetDateTime::now_utc()); diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 97faffa387..493def1c5a 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -188,6 +188,7 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result>; #[derive(Clone)] pub(crate) struct AppState { + pub(crate) forced_refresh: ForcedRefresh, pub(crate) nym_contract_cache: NymContractCache, pub(crate) node_status_cache: NodeStatusCache, pub(crate) circulating_supply_cache: CirculatingSupplyCache, @@ -79,6 +82,24 @@ pub(crate) struct AppState { pub(crate) node_info_cache: unstable::NodeInfoCache, } +#[derive(Clone, Default)] +pub(crate) struct ForcedRefresh { + pub(crate) refreshes: Arc>>, +} + +impl ForcedRefresh { + pub(crate) async fn last_refreshed(&self, node_id: NodeId) -> Option { + self.refreshes.read().await.get(&node_id).copied() + } + + pub(crate) async fn set_last_refreshed(&self, node_id: NodeId) { + self.refreshes + .write() + .await + .insert(node_id, OffsetDateTime::now_utc()); + } +} + impl AppState { pub(crate) fn nym_contract_cache(&self) -> &NymContractCache { &self.nym_contract_cache From 7ddab6ebd9dbdfcf165513b224f8eda9cc7647f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 5 Nov 2024 17:14:33 +0000 Subject: [PATCH 2/5] client code + updated return types --- .../validator-client/src/nym_api/mod.rs | 11 ++++++++- nym-api/src/node_status_api/models.rs | 14 +++++++++++ nym-api/src/nym_nodes/handlers/mod.rs | 24 ++++++++++++------- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index 13a1ca7671..1fc9e89f0b 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -12,7 +12,7 @@ use nym_api_requests::ecash::models::{ use nym_api_requests::ecash::VerificationKeyResponse; use nym_api_requests::models::{ AnnotationResponse, ApiHealthResponse, LegacyDescribedMixNode, NodePerformanceResponse, - NymNodeDescription, + NodeRefreshBody, NymNodeDescription, }; use nym_api_requests::nym_nodes::PaginatedCachedNodesResponse; use nym_api_requests::pagination::PaginatedResponse; @@ -934,6 +934,15 @@ pub trait NymApiClientExt: ApiClient { .await } + async fn force_refresh(&self, request: &NodeRefreshBody) -> Result<(), NymAPIError> { + self.post_json( + &[routes::API_VERSION, "nym-nodes", "refresh-described"], + NO_PARAMS, + request, + ) + .await + } + #[instrument(level = "debug", skip(self))] async fn epoch_credentials( &self, diff --git a/nym-api/src/node_status_api/models.rs b/nym-api/src/node_status_api/models.rs index bce5d6eea6..7fd879982b 100644 --- a/nym-api/src/node_status_api/models.rs +++ b/nym-api/src/node_status_api/models.rs @@ -355,6 +355,13 @@ impl AxumErrorResponse { } } + pub(crate) fn unauthorised(msg: impl Display) -> Self { + Self { + message: RequestError::new(msg.to_string()), + status: StatusCode::UNAUTHORIZED, + } + } + pub(crate) fn unprocessable_entity(msg: impl Display) -> Self { Self { message: RequestError::new(msg.to_string()), @@ -375,6 +382,13 @@ impl AxumErrorResponse { status: StatusCode::BAD_REQUEST, } } + + pub(crate) fn too_many(msg: impl Display) -> Self { + Self { + message: RequestError::new(msg.to_string()), + status: StatusCode::TOO_MANY_REQUESTS, + } + } } impl From for AxumErrorResponse { diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index 972f477a95..6c3c9d4369 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -5,7 +5,6 @@ use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; use crate::support::http::helpers::{NodeIdParam, PaginationRequest}; use crate::support::http::state::AppState; use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; use nym_api_requests::models::{ @@ -55,21 +54,24 @@ pub(crate) fn nym_node_routes() -> Router { async fn refresh_described( State(state): State, Json(request_body): Json, -) -> StatusCode { +) -> AxumResult> { let Some(refresh_data) = state .nym_contract_cache() .get_public_key_with_refresh_data(request_body.node_id) .await else { - return StatusCode::NOT_FOUND; + return Err(AxumErrorResponse::not_found(format!( + "node with id {} does not seem to exist", + request_body.node_id + ))); }; if !request_body.verify_signature(&refresh_data.pubkey) { - return StatusCode::UNAUTHORIZED; + return Err(AxumErrorResponse::unauthorised("invalid request signature")); } if request_body.is_stale() { - return StatusCode::BAD_REQUEST; + return Err(AxumErrorResponse::bad_request("the request is stale")); } if let Some(last) = state @@ -80,7 +82,9 @@ async fn refresh_described( // max 1 refresh a minute let minute_ago = OffsetDateTime::now_utc() - Duration::from_secs(60); if last > minute_ago { - return StatusCode::BAD_REQUEST; + return Err(AxumErrorResponse::too_many( + "already refreshed node in the last minute", + )); } } // to make sure you can't ddos the endpoint while a request is in progress @@ -91,14 +95,16 @@ async fn refresh_described( if let Some(updated_data) = refresh_data.refresh_data.try_refresh().await { let Ok(mut describe_cache) = state.described_nodes_cache.write().await else { - return StatusCode::SERVICE_UNAVAILABLE; + return Err(AxumErrorResponse::service_unavailable()); }; describe_cache.get_mut().force_update(updated_data) } else { - return StatusCode::UNPROCESSABLE_ENTITY; + return Err(AxumErrorResponse::unprocessable_entity( + "failed to refresh node description", + )); } - StatusCode::OK + Ok(Json(())) } #[utoipa::path( From bcc951027cd254f7e716691e33f40d2fa16fcc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 5 Nov 2024 17:50:33 +0000 Subject: [PATCH 3/5] nym-node to update self-described data cache on startup + change request type --- Cargo.lock | 1 + .../validator-client/src/client.rs | 6 +-- .../validator-client/src/nym_api/mod.rs | 5 +- nym-api/nym-api-requests/src/models.rs | 34 ++++++++++--- nym-api/src/node_describe_cache/mod.rs | 4 ++ nym-api/src/nym_contract_cache/cache/mod.rs | 49 +++++++++---------- nym-api/src/nym_nodes/handlers/mod.rs | 34 ++++++------- nym-node/Cargo.toml | 1 + nym-node/src/node/mod.rs | 41 +++++++++++++++- 9 files changed, 116 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65bf102040..1d8b09c03e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5906,6 +5906,7 @@ dependencies = [ "nym-sphinx-addressing", "nym-task", "nym-types", + "nym-validator-client", "nym-wireguard", "nym-wireguard-types", "rand", diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 071fb597bf..d0d64f4b93 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -25,12 +25,12 @@ use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated}; use nym_api_requests::nym_nodes::SkimmedNode; use nym_coconut_dkg_common::types::EpochId; use nym_http_api_client::UserAgent; +use nym_mixnet_contract_common::NymNodeDetails; use nym_network_defaults::NymNetworkDetails; use time::Date; use url::Url; pub use crate::nym_api::NymApiClientExt; -use nym_mixnet_contract_common::NymNodeDetails; pub use nym_mixnet_contract_common::{ mixnode::MixNodeDetails, GatewayBond, IdentityKey, IdentityKeyRef, NodeId, }; @@ -330,10 +330,10 @@ impl NymApiClient { NymApiClient { nym_api } } - pub fn new_with_user_agent(api_url: Url, user_agent: UserAgent) -> Self { + pub fn new_with_user_agent(api_url: Url, user_agent: impl Into) -> Self { let nym_api = nym_api::Client::builder::<_, ValidatorClientError>(api_url) .expect("invalid api url") - .with_user_agent(user_agent) + .with_user_agent(user_agent.into()) .build::() .expect("failed to build nym api client"); diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index 1fc9e89f0b..a3ea3aea8a 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -934,7 +934,10 @@ pub trait NymApiClientExt: ApiClient { .await } - async fn force_refresh(&self, request: &NodeRefreshBody) -> Result<(), NymAPIError> { + async fn force_refresh_describe_cache( + &self, + request: &NodeRefreshBody, + ) -> Result<(), NymAPIError> { self.post_json( &[routes::API_VERSION, "nym-nodes", "refresh-described"], NO_PARAMS, diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 8c4d36c3e0..1db3b3d62a 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -1146,8 +1146,9 @@ pub struct NoiseDetails { #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] pub struct NodeRefreshBody { - #[schema(value_type = u32)] - pub node_id: NodeId, + #[serde(with = "bs58_ed25519_pubkey")] + #[schemars(with = "String")] + pub node_identity: ed25519::PublicKey, // a poor man's nonce pub request_timestamp: i64, @@ -1157,16 +1158,33 @@ pub struct NodeRefreshBody { } impl NodeRefreshBody { - pub fn plaintext(&self) -> Vec { - self.node_id - .to_be_bytes() + pub fn plaintext(node_identity: ed25519::PublicKey, request_timestamp: i64) -> Vec { + node_identity + .to_bytes() .into_iter() - .chain(self.request_timestamp.to_be_bytes()) + .chain(request_timestamp.to_be_bytes()) + .chain(b"describe-cache-refresh-request".iter().copied()) .collect() } - pub fn verify_signature(&self, public_key: &ed25519::PublicKey) -> bool { - public_key.verify(self.plaintext(), &self.signature).is_ok() + pub fn new(private_key: &ed25519::PrivateKey) -> Self { + let node_identity = private_key.public_key(); + let request_timestamp = OffsetDateTime::now_utc().unix_timestamp(); + let signature = private_key.sign(Self::plaintext(node_identity, request_timestamp)); + NodeRefreshBody { + node_identity, + request_timestamp, + signature, + } + } + + pub fn verify_signature(&self) -> bool { + self.node_identity + .verify( + Self::plaintext(self.node_identity, self.request_timestamp), + &self.signature, + ) + .is_ok() } pub fn is_stale(&self) -> bool { diff --git a/nym-api/src/node_describe_cache/mod.rs b/nym-api/src/node_describe_cache/mod.rs index b1efb2f645..aa80d831ab 100644 --- a/nym-api/src/node_describe_cache/mod.rs +++ b/nym-api/src/node_describe_cache/mod.rs @@ -353,6 +353,10 @@ impl RefreshData { } } + pub(crate) fn node_id(&self) -> NodeId { + self.node_id + } + pub(crate) async fn try_refresh(self) -> Option { match try_get_description(self).await { Ok(description) => Some(description), diff --git a/nym-api/src/nym_contract_cache/cache/mod.rs b/nym-api/src/nym_contract_cache/cache/mod.rs index a4824eaf52..2f08c26914 100644 --- a/nym-api/src/nym_contract_cache/cache/mod.rs +++ b/nym-api/src/nym_contract_cache/cache/mod.rs @@ -30,11 +30,6 @@ pub(crate) use self::data::CachedRewardedSet; const CACHE_TIMEOUT_MS: u64 = 100; -pub(crate) struct RefreshDataWithKey { - pub(crate) pubkey: ed25519::PublicKey, - pub(crate) refresh_data: RefreshData, -} - #[derive(Clone)] pub struct NymContractCache { pub(crate) initialised: Arc, @@ -359,41 +354,43 @@ impl NymContractCache { self.legacy_mixnode_details(mix_id).await.1 } - pub async fn get_public_key_with_refresh_data( + pub async fn get_node_refresh_data( &self, - node_id: NodeId, - ) -> Option { + node_identity: ed25519::PublicKey, + ) -> Option { if !self.initialised() { return None; } let inner = self.inner.read().await; + let encoded_identity = node_identity.to_base58_string(); + // 1. check nymnodes - if let Some(nym_node) = inner.nym_nodes.iter().find(|n| n.node_id() == node_id) { - let pubkey = nym_node.bond_information.identity().parse().ok()?; - return Some(RefreshDataWithKey { - pubkey, - refresh_data: nym_node.into(), - }); + if let Some(nym_node) = inner + .nym_nodes + .iter() + .find(|n| n.bond_information.identity() == encoded_identity) + { + return Some(nym_node.into()); } // 2. check legacy mixnodes - if let Some(mixnode) = inner.legacy_mixnodes.iter().find(|n| n.mix_id() == node_id) { - let pubkey = mixnode.bond_information.identity().parse().ok()?; - return Some(RefreshDataWithKey { - pubkey, - refresh_data: mixnode.into(), - }); + if let Some(mixnode) = inner + .legacy_mixnodes + .iter() + .find(|n| n.bond_information.identity() == encoded_identity) + { + return Some(mixnode.into()); } // 3. check legacy gateways - if let Some(gateway) = inner.legacy_gateways.iter().find(|n| n.node_id == node_id) { - let pubkey = gateway.identity().parse().ok()?; - return Some(RefreshDataWithKey { - pubkey, - refresh_data: gateway.into(), - }); + if let Some(gateway) = inner + .legacy_gateways + .iter() + .find(|n| n.identity() == &encoded_identity) + { + return Some(gateway.into()); } None diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index 6c3c9d4369..658c290f2e 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -57,28 +57,25 @@ async fn refresh_described( ) -> AxumResult> { let Some(refresh_data) = state .nym_contract_cache() - .get_public_key_with_refresh_data(request_body.node_id) + .get_node_refresh_data(request_body.node_identity) .await else { return Err(AxumErrorResponse::not_found(format!( - "node with id {} does not seem to exist", - request_body.node_id + "node with identity {} does not seem to exist", + request_body.node_identity ))); }; - if !request_body.verify_signature(&refresh_data.pubkey) { - return Err(AxumErrorResponse::unauthorised("invalid request signature")); - } - - if request_body.is_stale() { - return Err(AxumErrorResponse::bad_request("the request is stale")); - } + // if !request_body.verify_signature() { + // return Err(AxumErrorResponse::unauthorised("invalid request signature")); + // } + // + // if request_body.is_stale() { + // return Err(AxumErrorResponse::bad_request("the request is stale")); + // } - if let Some(last) = state - .forced_refresh - .last_refreshed(request_body.node_id) - .await - { + let node_id = refresh_data.node_id(); + if let Some(last) = state.forced_refresh.last_refreshed(node_id).await { // max 1 refresh a minute let minute_ago = OffsetDateTime::now_utc() - Duration::from_secs(60); if last > minute_ago { @@ -88,12 +85,9 @@ async fn refresh_described( } } // to make sure you can't ddos the endpoint while a request is in progress - state - .forced_refresh - .set_last_refreshed(request_body.node_id) - .await; + state.forced_refresh.set_last_refreshed(node_id).await; - if let Some(updated_data) = refresh_data.refresh_data.try_refresh().await { + if let Some(updated_data) = refresh_data.try_refresh().await { let Ok(mut describe_cache) = state.described_nodes_cache.write().await else { return Err(AxumErrorResponse::service_unavailable()); }; diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index 442b62a3b9..affd10f753 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -52,6 +52,7 @@ nym-sphinx-acknowledgements = { path = "../common/nymsphinx/acknowledgements" } nym-sphinx-addressing = { path = "../common/nymsphinx/addressing" } nym-task = { path = "../common/task" } nym-types = { path = "../common/types" } +nym-validator-client = { path = "../common/client-libs/validator-client" } nym-wireguard = { path = "../common/wireguard" } nym-wireguard-types = { path = "../common/wireguard-types", default-features = false } diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index 5a7bc0c419..e751c770bd 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -32,13 +32,18 @@ use nym_node_http_api::{NymNodeHTTPServer, NymNodeRouter}; use nym_sphinx_acknowledgements::AckKey; use nym_sphinx_addressing::Recipient; use nym_task::{TaskClient, TaskManager}; +use nym_validator_client::client::NymApiClientExt; +use nym_validator_client::models::NodeRefreshBody; +use nym_validator_client::NymApiClient; use nym_wireguard::{peer_controller::PeerControlRequest, WireguardGatewayData}; use rand::rngs::OsRng; use rand::{CryptoRng, RngCore}; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc; -use tracing::{debug, error, info, trace}; +use tokio::time::timeout; +use tracing::{debug, error, info, trace, warn}; use zeroize::Zeroizing; use self::helpers::load_x25519_wireguard_keypair; @@ -740,6 +745,38 @@ impl NymNode { .await?) } + async fn try_refresh_remote_nym_api_cache(&self) { + info!("attempting to request described cache request from nym-api..."); + let Some(nym_api_url) = self.config.mixnet.nym_api_urls.get(0) else { + warn!("no nym-api urls available"); + return; + }; + + // let client = NymApiClient::new_with_user_agent(nym_api_url.clone(), bin_info_owned!()); + let client = NymApiClient::new_with_user_agent( + "http://localhost:8081".parse().unwrap(), + bin_info_owned!(), + ); + + let request = NodeRefreshBody::new(self.ed25519_identity_keys.private_key()); + match timeout( + Duration::from_secs(10), + client.nym_api.force_refresh_describe_cache(&request), + ) + .await + { + Ok(Ok(_)) => { + info!("managed to refresh own self-described data cache") + } + Ok(Err(request_failure)) => { + warn!("failed to resolve the refresh request: {request_failure}") + } + Err(_timeout) => { + warn!("timed out while attempting to resolve the request. the cache might be stale") + } + }; + } + pub(crate) async fn run(self) -> Result<(), NymNodeError> { let mut task_manager = TaskManager::default().named("NymNode"); let http_server = self @@ -754,6 +791,8 @@ impl NymNode { } }); + self.try_refresh_remote_nym_api_cache().await; + match self.config.mode { NodeMode::Mixnode => { self.start_mixnode(task_manager.subscribe_named("mixnode"))?; From 448b0412c1679819c55bdaf8db82fccf1bd99262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 5 Nov 2024 17:53:32 +0000 Subject: [PATCH 4/5] send request to all available nym-apis --- nym-api/src/nym_nodes/handlers/mod.rs | 14 ++++---- nym-node/src/node/mod.rs | 48 +++++++++++++-------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index 658c290f2e..47530a6c5c 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -66,13 +66,13 @@ async fn refresh_described( ))); }; - // if !request_body.verify_signature() { - // return Err(AxumErrorResponse::unauthorised("invalid request signature")); - // } - // - // if request_body.is_stale() { - // return Err(AxumErrorResponse::bad_request("the request is stale")); - // } + if !request_body.verify_signature() { + return Err(AxumErrorResponse::unauthorised("invalid request signature")); + } + + if request_body.is_stale() { + return Err(AxumErrorResponse::bad_request("the request is stale")); + } let node_id = refresh_data.node_id(); if let Some(last) = state.forced_refresh.last_refreshed(node_id).await { diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index e751c770bd..bd687ff0c3 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -747,34 +747,34 @@ impl NymNode { async fn try_refresh_remote_nym_api_cache(&self) { info!("attempting to request described cache request from nym-api..."); - let Some(nym_api_url) = self.config.mixnet.nym_api_urls.get(0) else { + if self.config.mixnet.nym_api_urls.is_empty() { warn!("no nym-api urls available"); return; - }; + } - // let client = NymApiClient::new_with_user_agent(nym_api_url.clone(), bin_info_owned!()); - let client = NymApiClient::new_with_user_agent( - "http://localhost:8081".parse().unwrap(), - bin_info_owned!(), - ); + for nym_api in &self.config.mixnet.nym_api_urls { + info!("trying {nym_api}..."); + let client = NymApiClient::new_with_user_agent(nym_api.clone(), bin_info_owned!()); - let request = NodeRefreshBody::new(self.ed25519_identity_keys.private_key()); - match timeout( - Duration::from_secs(10), - client.nym_api.force_refresh_describe_cache(&request), - ) - .await - { - Ok(Ok(_)) => { - info!("managed to refresh own self-described data cache") - } - Ok(Err(request_failure)) => { - warn!("failed to resolve the refresh request: {request_failure}") - } - Err(_timeout) => { - warn!("timed out while attempting to resolve the request. the cache might be stale") - } - }; + // make new request every time in case previous one takes longer and invalidates the signature + let request = NodeRefreshBody::new(self.ed25519_identity_keys.private_key()); + match timeout( + Duration::from_secs(10), + client.nym_api.force_refresh_describe_cache(&request), + ) + .await + { + Ok(Ok(_)) => { + info!("managed to refresh own self-described data cache") + } + Ok(Err(request_failure)) => { + warn!("failed to resolve the refresh request: {request_failure}") + } + Err(_timeout) => { + warn!("timed out while attempting to resolve the request. the cache might be stale") + } + }; + } } pub(crate) async fn run(self) -> Result<(), NymNodeError> { From 97bc80e4418949a0468d32f7211f4f81e3c1ed7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 6 Nov 2024 08:48:28 +0000 Subject: [PATCH 5/5] fixed 'is_stale' check --- nym-api/nym-api-requests/src/models.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 1db3b3d62a..c21fe448c7 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -1189,19 +1189,19 @@ impl NodeRefreshBody { pub fn is_stale(&self) -> bool { let Ok(encoded) = OffsetDateTime::from_unix_timestamp(self.request_timestamp) else { - return false; + return true; }; let now = OffsetDateTime::now_utc(); if encoded > now { - return false; + return true; } if (encoded + Duration::from_secs(30)) < now { - return false; + return true; } - true + false } }