Expand /v3/nym-nodes with geodata (#5686)

* Expand /v3/nym-nodes
- includes node description and geodata
- expanded scope of included geodata

* Fetch geodata for all nodes

* Bump package version
This commit is contained in:
dynco-nym
2025-04-10 21:12:33 +02:00
committed by GitHub
11 changed files with 189 additions and 54 deletions
Generated
+1 -1
View File
@@ -6221,7 +6221,7 @@ dependencies = [
[[package]]
name = "nym-node-status-api"
version = "2.0.0"
version = "2.1.0"
dependencies = [
"ammonia",
"anyhow",
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-api"
version = "2.0.0"
version = "2.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -15,8 +15,8 @@ pub(crate) use gateways_stats::{delete_old_records, get_sessions_stats, insert_s
pub(crate) use misc::insert_summaries;
pub(crate) use mixnodes::{get_all_mixnodes, get_bonded_mix_ids, get_daily_stats, update_mixnodes};
pub(crate) use nym_nodes::{
get_all_nym_nodes, get_described_bonded_nym_nodes, get_described_node_bond_info,
get_node_descriptions, update_nym_nodes,
get_all_nym_nodes, get_bonded_node_description, get_described_bonded_nym_nodes,
get_described_node_bond_info, get_node_self_description, update_nym_nodes,
};
pub(crate) use packet_stats::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
@@ -1,4 +1,5 @@
use futures_util::TryStreamExt;
use nym_node_requests::api::v1::node::models::NodeDescription;
use nym_validator_client::{
client::{NodeId, NymNodeDetails},
models::NymNodeDescription,
@@ -180,7 +181,7 @@ pub(crate) async fn get_described_node_bond_info(
.map_err(From::from)
}
pub(crate) async fn get_node_descriptions(
pub(crate) async fn get_node_self_description(
pool: &DbPool,
) -> anyhow::Result<HashMap<NodeId, NymNodeDescription>> {
let mut conn = pool.acquire().await?;
@@ -213,3 +214,45 @@ pub(crate) async fn get_node_descriptions(
})
.map_err(From::from)
}
pub(crate) async fn get_bonded_node_description(
pool: &DbPool,
) -> anyhow::Result<HashMap<NodeId, NodeDescription>> {
let mut conn = pool.acquire().await?;
sqlx::query!(
r#"SELECT
nd.node_id,
moniker,
website,
security_contact,
details
FROM
nym_node_descriptions nd
INNER JOIN
nym_nodes
WHERE
bond_info IS NOT NULL
"#,
)
.fetch_all(&mut *conn)
.await
.map(|records| {
records
.into_iter()
.map(|elem| {
let node_id: NodeId = elem.node_id.try_into().unwrap_or_default();
(
node_id,
NodeDescription {
moniker: elem.moniker.unwrap_or_default(),
website: elem.website.unwrap_or_default(),
security_contact: elem.security_contact.unwrap_or_default(),
details: elem.details.unwrap_or_default(),
},
)
})
.collect::<HashMap<NodeId, NodeDescription>>()
})
.map_err(From::from)
}
@@ -32,11 +32,16 @@ async fn nym_nodes(
State(state): State<AppState>,
) -> HttpResult<Json<PagedResult<ExtendedNymNode>>> {
let db = state.db_pool();
let node_geocache = state.node_geocache();
let nodes = state.cache().get_nym_nodes_list(db).await.map_err(|e| {
tracing::error!("{e}");
HttpError::internal()
})?;
let nodes = state
.cache()
.get_nym_nodes_list(db, node_geocache)
.await
.map_err(|e| {
tracing::error!("{e}");
HttpError::internal()
})?;
Ok(Json(PagedResult::paginate(pagination, nodes)))
}
@@ -60,8 +60,23 @@ pub(crate) struct ExtendedNymNode {
pub(crate) node_type: String,
pub(crate) ip_address: String,
pub(crate) accepted_tnc: bool,
pub(crate) description: serde_json::Value,
pub(crate) self_description: serde_json::Value,
pub(crate) rewarding_details: serde_json::Value,
pub(crate) description: NodeDescription,
pub(crate) geoip: Option<NodeGeoData>,
}
#[derive(Clone, Debug, utoipa::ToSchema, Deserialize, Serialize)]
pub(crate) struct NodeGeoData {
pub(crate) city: String,
pub(crate) country: String,
pub(crate) ip_address: String,
pub(crate) latitude: String,
pub(crate) longitude: String,
pub(crate) org: String,
pub(crate) postal: String,
pub(crate) region: String,
pub(crate) timezone: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
@@ -7,6 +7,7 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned};
use crate::{
db::DbPool,
http::{api::RouterBuilder, state::AppState},
monitor::NodeGeoCache,
};
/// Return handles that allow for graceful shutdown of server + awaiting its
@@ -17,10 +18,18 @@ pub(crate) async fn start_http_api(
nym_http_cache_ttl: u64,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
node_geocache: NodeGeoCache,
) -> anyhow::Result<ShutdownHandles> {
let router_builder = RouterBuilder::with_default_routes();
let state = AppState::new(db_pool, nym_http_cache_ttl, agent_key_list, agent_max_count).await;
let state = AppState::new(
db_pool,
nym_http_cache_ttl,
agent_key_list,
agent_max_count,
node_geocache,
)
.await;
let router = router_builder.with_state(state);
let bind_addr = format!("0.0.0.0:{}", http_port);
@@ -10,7 +10,8 @@ use tracing::instrument;
use crate::{
db::{queries, DbPool},
http::models::{DailyStats, ExtendedNymNode, Gateway, Mixnode, SummaryHistory},
http::models::{DailyStats, ExtendedNymNode, Gateway, Mixnode, NodeGeoData, SummaryHistory},
monitor::NodeGeoCache,
};
use super::models::SessionStats;
@@ -21,6 +22,7 @@ pub(crate) struct AppState {
cache: HttpCache,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
node_geocache: NodeGeoCache,
}
impl AppState {
@@ -29,12 +31,14 @@ impl AppState {
cache_ttl: u64,
agent_key_list: Vec<PublicKey>,
agent_max_count: i64,
node_geocache: NodeGeoCache,
) -> Self {
Self {
db_pool,
cache: HttpCache::new(cache_ttl).await,
agent_key_list,
agent_max_count,
node_geocache,
}
}
@@ -53,6 +57,10 @@ impl AppState {
pub(crate) fn agent_max_count(&self) -> i64 {
self.agent_max_count
}
pub(crate) fn node_geocache(&self) -> NodeGeoCache {
self.node_geocache.clone()
}
}
static GATEWAYS_LIST_KEY: &str = "gateways";
@@ -210,7 +218,11 @@ impl HttpCache {
.await
}
pub async fn get_nym_nodes_list(&self, db: &DbPool) -> anyhow::Result<Vec<ExtendedNymNode>> {
pub async fn get_nym_nodes_list(
&self,
db: &DbPool,
node_geocache: NodeGeoCache,
) -> anyhow::Result<Vec<ExtendedNymNode>> {
match self.nym_nodes.get(NYM_NODES_LIST_KEY).await {
Some(guard) => {
tracing::trace!("Fetching from cache...");
@@ -220,11 +232,13 @@ impl HttpCache {
None => {
tracing::trace!("No nym nodes in cache, refreshing cache from DB...");
let nym_nodes = aggregate_node_info_from_db(db).await.inspect(|nym_nodes| {
if nym_nodes.is_empty() {
tracing::warn!("Database contains 0 nym nodes");
}
})?;
let nym_nodes = aggregate_node_info_from_db(db, node_geocache)
.await
.inspect(|nym_nodes| {
if nym_nodes.is_empty() {
tracing::warn!("Database contains 0 nym nodes");
}
})?;
self.upsert_nym_node_list(nym_nodes.clone()).await;
Ok(nym_nodes)
@@ -346,7 +360,10 @@ impl HttpCache {
}
#[instrument(level = "info", skip_all)]
async fn aggregate_node_info_from_db(pool: &DbPool) -> anyhow::Result<Vec<ExtendedNymNode>> {
async fn aggregate_node_info_from_db(
pool: &DbPool,
node_geocache: NodeGeoCache,
) -> anyhow::Result<Vec<ExtendedNymNode>> {
let node_bond_info = queries::get_described_node_bond_info(pool).await?;
tracing::debug!("Described nodes with bond info: {}", node_bond_info.len());
@@ -359,9 +376,11 @@ async fn aggregate_node_info_from_db(pool: &DbPool) -> anyhow::Result<Vec<Extend
})?;
tracing::debug!("Skimmed nodes: {}", skimmed_nodes.len());
let described_nodes = queries::get_node_descriptions(pool).await?;
let described_nodes = queries::get_node_self_description(pool).await?;
tracing::debug!("Described nodes: {}", described_nodes.len());
let node_descriptions = queries::get_bonded_node_description(pool).await?;
let mut parsed_nym_nodes = Vec::new();
for (node_id, described_node) in described_nodes {
let bond_details = node_bond_info.get(&node_id);
@@ -404,6 +423,21 @@ async fn aggregate_node_info_from_db(pool: &DbPool) -> anyhow::Result<Vec<Extend
.map(|details| details.bond_information.owner.to_string())
.unwrap_or_default();
let node_description = node_descriptions.get(&node_id).cloned().unwrap_or_default();
let geoip = {
node_geocache.get(&node_id).await.map(|data| NodeGeoData {
city: data.city,
country: data.two_letter_iso_country_code,
ip_address: data.ip_address,
latitude: data.location.latitude.to_string(),
longitude: data.location.longitude.to_string(),
org: data.org,
postal: data.postal,
region: data.region,
timezone: data.timezone,
})
};
parsed_nym_nodes.push(ExtendedNymNode {
node_id,
identity_key,
@@ -415,8 +449,10 @@ async fn aggregate_node_info_from_db(pool: &DbPool) -> anyhow::Result<Vec<Extend
bonded,
node_type,
accepted_tnc,
description: serde_json::to_value(description).unwrap_or_default(),
self_description: serde_json::to_value(description).unwrap_or_default(),
rewarding_details: serde_json::to_value(rewarding_details).unwrap_or_default(),
description: node_description,
geoip,
});
}
@@ -37,8 +37,14 @@ async fn main() -> anyhow::Result<()> {
scraper.start().await;
});
// node geocache is shared between node monitor and HTTP server
let geocache = moka::future::Cache::builder()
.time_to_live(args.geodata_ttl)
.build();
// Start the monitor
let args_clone = args.clone();
let geocache_clone = geocache.clone();
tokio::spawn(async move {
monitor::spawn_in_background(
@@ -47,7 +53,7 @@ async fn main() -> anyhow::Result<()> {
args_clone.nyxd_addr,
args_clone.monitor_refresh_interval,
args_clone.ipinfo_api_token,
args_clone.geodata_ttl,
geocache_clone,
)
.await;
tracing::info!("Started monitor task");
@@ -67,6 +73,7 @@ async fn main() -> anyhow::Result<()> {
args.nym_http_cache_ttl,
agent_key_list.to_owned(),
args.max_agent_count,
geocache,
)
.await
.expect("Failed to start server");
@@ -29,6 +29,8 @@ impl IpInfoClient {
}
anyhow::Error::from(err)
})?;
// extracting text, then deserializing produces better error messages than response.json()
let raw_response = response.text().await?;
let response: LocationResponse =
serde_json::from_str(&raw_response).inspect_err(|e| tracing::error!("{e}"))?;
@@ -62,7 +64,7 @@ impl IpInfoClient {
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct NodeGeoData {
pub(crate) struct ExplorerPrettyBond {
pub(crate) identity_key: String,
pub(crate) owner: Addr,
pub(crate) pledge_amount: Coin,
@@ -74,6 +76,12 @@ pub(crate) struct Location {
pub(crate) two_letter_iso_country_code: String,
#[serde(flatten)]
pub(crate) location: Coordinates,
pub(crate) ip_address: String,
pub(crate) city: String,
pub(crate) region: String,
pub(crate) org: String,
pub(crate) postal: String,
pub(crate) timezone: String,
}
impl From<LocationResponse> for Location {
@@ -81,6 +89,12 @@ impl From<LocationResponse> for Location {
Self {
two_letter_iso_country_code: value.two_letter_iso_country_code,
location: value.loc,
ip_address: value.ip,
city: value.city,
region: value.region,
org: value.org,
postal: value.postal,
timezone: value.timezone,
}
}
}
@@ -91,6 +105,18 @@ pub(crate) struct LocationResponse {
pub(crate) two_letter_iso_country_code: String,
#[serde(deserialize_with = "deserialize_loc")]
pub(crate) loc: Coordinates,
#[serde(default = "String::default")]
pub(crate) ip: String,
#[serde(default = "String::default")]
pub(crate) city: String,
#[serde(default = "String::default")]
pub(crate) region: String,
#[serde(default = "String::default")]
pub(crate) org: String,
#[serde(default = "String::default")]
pub(crate) postal: String,
#[serde(default = "String::default")]
pub(crate) timezone: String,
}
fn deserialize_loc<'de, D>(deserializer: D) -> Result<Coordinates, D::Error>
@@ -115,10 +141,7 @@ pub(crate) struct Coordinates {
impl Location {
pub(crate) fn empty() -> Self {
Self {
two_letter_iso_country_code: String::new(),
location: Coordinates::default(),
}
Self::default()
}
}
@@ -7,7 +7,7 @@ use crate::db::models::{
NYMNODES_DESCRIBED_COUNT, NYMNODE_COUNT,
};
use crate::db::{queries, DbPool};
use crate::monitor::geodata::{Location, NodeGeoData};
use crate::monitor::geodata::{ExplorerPrettyBond, Location};
use crate::utils::{decimal_to_i64, LogError, NumericalCheckedCast};
use anyhow::anyhow;
use moka::future::Cache;
@@ -31,8 +31,8 @@ pub(crate) use geodata::IpInfoClient;
mod geodata;
const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60);
static DELEGATION_PROGRAM_WALLET: &str = "n1rnxpdpx3kldygsklfft0gech7fhfcux4zst5lw";
pub(crate) type NodeGeoCache = Cache<NodeId, Location>;
struct Monitor {
db_pool: DbPool,
@@ -40,7 +40,7 @@ struct Monitor {
nym_api_client_timeout: Duration,
nyxd_addr: Url,
ipinfo: IpInfoClient,
geocache: Cache<NodeId, Location>,
geocache: NodeGeoCache,
}
// TODO dz: query many NYM APIs:
@@ -52,9 +52,8 @@ pub(crate) async fn spawn_in_background(
nyxd_addr: Url,
refresh_interval: Duration,
ipinfo_api_token: String,
geodata_ttl: Duration,
geocache: NodeGeoCache,
) {
let geocache = Cache::builder().time_to_live(geodata_ttl).build();
let ipinfo = IpInfoClient::new(ipinfo_api_token.clone());
let mut monitor = Monitor {
db_pool,
@@ -151,18 +150,9 @@ impl Monitor {
tracing::debug!("{} nym nodes written to DB!", inserted);
})?;
let mut gateway_geodata = Vec::new();
for gateway in gateways.iter() {
if let Some(node_details) = bonded_nym_nodes.get(&gateway.node_id) {
let bond_info = &node_details.bond_information;
let gw_geodata = NodeGeoData {
identity_key: bond_info.node.identity_key.to_owned(),
owner: bond_info.owner.to_owned(),
pledge_amount: bond_info.original_pledge.to_owned(),
location: self.location_cached(gateway).await,
};
gateway_geodata.push(gw_geodata);
}
// refresh geodata for all nodes
for (_, node_description) in described_nodes.iter() {
self.location_cached(node_description).await;
}
let mixnodes_detailed = api_client
@@ -181,7 +171,6 @@ impl Monitor {
.map(|elem| elem.identity_key().to_owned())
.collect::<HashSet<_>>();
// TODO this assumes polyfilling of legacy mixnodes on Nym API
let mixnodes_legacy = nym_nodes
.iter()
.filter(|node| {
@@ -220,8 +209,9 @@ impl Monitor {
let assigned_mixing_count = mixing_assigned_nodes.len();
let count_legacy_mixnodes = mixnodes_legacy.len();
let gateway_records =
self.prepare_gateway_data(&gateways, gateway_geodata, &nym_nodes, &bonded_nym_nodes)?;
let gateway_records = self
.prepare_gateway_data(&gateways, &nym_nodes, &bonded_nym_nodes)
.await?;
let pool = self.db_pool.clone();
let gateways_count = gateway_records.len();
@@ -357,10 +347,9 @@ impl Monitor {
.collect::<Vec<_>>()
}
fn prepare_gateway_data(
&self,
async fn prepare_gateway_data(
&mut self,
described_gateways: &[&NymNodeDescription],
gateway_geodata: Vec<NodeGeoData>,
skimmed_gateways: &[SkimmedNode],
bonded_nodes: &HashMap<NodeId, NymNodeDetails>,
) -> anyhow::Result<Vec<GatewayInsertRecord>> {
@@ -373,11 +362,19 @@ impl Monitor {
let self_described = serde_json::to_string(&gateway.description)?;
let explorer_pretty_bond = gateway_geodata
.iter()
.find(|g| g.identity_key.eq(&identity_key));
let explorer_pretty_bond = {
let location = self.location_cached(gateway).await;
bonded_nodes
.get(&gateway.node_id)
.map(|details| ExplorerPrettyBond {
identity_key: gateway.ed25519_identity_key().to_base58_string(),
owner: details.bond_information.owner.to_owned(),
pledge_amount: details.bond_information.original_pledge.to_owned(),
location,
})
};
let explorer_pretty_bond =
explorer_pretty_bond.and_then(|g| serde_json::to_string(g).ok());
explorer_pretty_bond.and_then(|g| serde_json::to_string(&g).ok());
let performance = skimmed_gateways
.iter()