Explorer API: add new HTTP resource to decorate mix nodes with geoip locations (#734)

* explorer-api: decorate mix nodes with locations from the geoip service and keep mix node cache in a hash map instead of a vec

* explorer-api: add `lat` and `lng` for map views

* explorer-api: remove function and simplify code

* explorer-api: review feedback

* network-explorer: format
This commit is contained in:
Mark Sinclair
2021-08-11 17:24:34 +01:00
committed by GitHub
parent 8694396942
commit ccf8063a52
7 changed files with 195 additions and 71 deletions
@@ -16,7 +16,9 @@ impl ConcurrentCountryNodesDistribution {
}
}
pub(crate) fn attach(country_node_distribution: CountryNodesDistribution) -> Self {
pub(crate) fn new_from_distribution(
country_node_distribution: CountryNodesDistribution,
) -> Self {
ConcurrentCountryNodesDistribution {
inner: Arc::new(RwLock::new(country_node_distribution)),
}
+17 -32
View File
@@ -1,15 +1,12 @@
use isocountry::CountryCode;
use log::{info, trace, warn};
use reqwest::Error as ReqwestError;
use models::GeoLocation;
use crate::country_statistics::country_nodes_distribution::CountryNodesDistribution;
use crate::mix_nodes::{GeoLocation, Location};
use crate::state::ExplorerApiStateContext;
pub mod country_nodes_distribution;
pub mod http;
mod models;
pub(crate) struct CountryStatistics {
state: ExplorerApiStateContext,
@@ -38,30 +35,31 @@ impl CountryStatistics {
/// Retrieves the current list of mixnodes from the validators and calculates how many nodes are in each country
async fn calculate_nodes_per_country(&mut self) {
// force the mixnode cache to invalidate
let mixnode_bonds = self
.state
.inner
.mix_nodes
.clone()
.refresh_and_get()
.await
.value;
let mixnode_bonds = self.state.inner.mix_nodes.refresh_and_get().await.value;
let mut distribution = CountryNodesDistribution::new();
info!("Locating mixnodes...");
for (i, bond) in mixnode_bonds.iter().enumerate() {
match locate(&bond.mix_node.host).await {
Ok(location) => {
let country_code = map_2_letter_to_3_letter_country_code(&location);
*(distribution.entry(country_code)).or_insert(0) += 1;
for (i, cache_item) in mixnode_bonds.values().enumerate() {
match locate(&cache_item.bond.mix_node.host).await {
Ok(geo_location) => {
let location = Location::new(geo_location);
*(distribution.entry(location.three_letter_iso_country_code.to_string()))
.or_insert(0) += 1;
trace!(
"Ip {} is located in {:#?}",
bond.mix_node.host,
map_2_letter_to_3_letter_country_code(&location)
cache_item.bond.mix_node.host,
location.three_letter_iso_country_code,
);
self.state
.inner
.mix_nodes
.set_location(&cache_item.bond.mix_node.identity_key, location)
.await;
if (i % 100) == 0 {
info!(
"Located {} mixnodes in {} countries",
@@ -91,19 +89,6 @@ impl CountryStatistics {
}
}
fn map_2_letter_to_3_letter_country_code(geo: &GeoLocation) -> String {
match CountryCode::for_alpha2(&geo.country_code) {
Ok(three_letter_country_code) => three_letter_country_code.alpha3().to_string(),
Err(_e) => {
warn!(
"❌ Oh no! map_2_letter_to_3_letter_country_code failed for '{:#?}'",
geo
);
"???".to_string()
}
}
}
async fn locate(ip: &str) -> Result<GeoLocation, ReqwestError> {
let response = reqwest::get(format!("{}{}", crate::GEO_IP_SERVICE, ip)).await?;
let location = response.json::<GeoLocation>().await?;
@@ -1,16 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct GeoLocation {
pub(crate) ip: String,
pub(crate) country_code: String,
pub(crate) country_name: String,
pub(crate) region_code: String,
pub(crate) region_name: String,
pub(crate) city: String,
pub(crate) zip_code: String,
pub(crate) time_zone: String,
pub(crate) latitude: f32,
pub(crate) longitude: f32,
pub(crate) metro_code: u32,
}
+43 -2
View File
@@ -1,13 +1,54 @@
use reqwest::Error as ReqwestError;
use rocket::serde::json::Json;
use rocket::{Route, State};
use serde::Serialize;
use mixnet_contract::{Addr, Coin, Layer, MixNode};
use crate::mix_node::models::{NodeDescription, NodeStats};
use crate::mix_nodes::Location;
use crate::state::ExplorerApiStateContext;
pub fn mix_node_make_default_routes() -> Vec<Route> {
routes_with_openapi![get_description, get_stats]
routes_with_openapi![get_description, get_stats, list]
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub(crate) struct PrettyMixNodeBondWithLocation {
pub location: Option<Location>,
pub bond_amount: Coin,
pub total_delegation: Coin,
pub owner: Addr,
pub layer: Layer,
pub mix_node: MixNode,
}
#[openapi(tag = "mix_node")]
#[get("/")]
pub(crate) async fn list(
state: &State<ExplorerApiStateContext>,
) -> Json<Vec<PrettyMixNodeBondWithLocation>> {
Json(
state
.inner
.mix_nodes
.get()
.await
.value
.values()
.map(|i| {
let mix_node = i.bond.clone();
PrettyMixNodeBondWithLocation {
location: i.location.clone(),
bond_amount: mix_node.bond_amount,
total_delegation: mix_node.total_delegation,
owner: mix_node.owner,
layer: mix_node.layer,
mix_node: mix_node.mix_node,
}
})
.collect::<Vec<PrettyMixNodeBondWithLocation>>(),
)
}
#[openapi(tag = "mix_node")]
+99 -7
View File
@@ -1,15 +1,66 @@
mod utils;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use rocket::tokio::sync::RwLock;
use serde::{Deserialize, Serialize};
use crate::mix_nodes::utils::map_2_letter_to_3_letter_country_code;
use mixnet_contract::MixNodeBond;
use validator_client::Config;
pub(crate) type LocationCache = HashMap<String, Location>;
#[derive(Debug, Deserialize)]
pub(crate) struct GeoLocation {
pub(crate) ip: String,
pub(crate) country_code: String,
pub(crate) country_name: String,
pub(crate) region_code: String,
pub(crate) region_name: String,
pub(crate) city: String,
pub(crate) zip_code: String,
pub(crate) time_zone: String,
pub(crate) latitude: f32,
pub(crate) longitude: f32,
pub(crate) metro_code: u32,
}
#[derive(Clone, Debug, JsonSchema, Serialize, Deserialize)]
pub(crate) struct Location {
pub(crate) two_letter_iso_country_code: String,
pub(crate) three_letter_iso_country_code: String,
pub(crate) country_name: String,
pub(crate) lat: f32,
pub(crate) lng: f32,
}
impl Location {
pub(crate) fn new(geo_location: GeoLocation) -> Self {
let three_letter_iso_country_code = map_2_letter_to_3_letter_country_code(&geo_location);
Location {
country_name: geo_location.country_name,
two_letter_iso_country_code: geo_location.country_code,
three_letter_iso_country_code,
lat: geo_location.latitude,
lng: geo_location.longitude,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct MixNodeBondWithLocation {
pub(crate) location: Option<Location>,
pub(crate) bond: MixNodeBond,
}
#[derive(Clone, Debug)]
pub(crate) struct MixNodesResult {
pub(crate) valid_until: SystemTime,
pub(crate) value: Vec<MixNodeBond>,
pub(crate) value: HashMap<String, MixNodeBondWithLocation>,
location_cache: LocationCache,
}
#[derive(Clone)]
@@ -21,15 +72,45 @@ impl ThreadsafeMixNodesResult {
pub(crate) fn new() -> Self {
ThreadsafeMixNodesResult {
inner: Arc::new(RwLock::new(MixNodesResult {
value: vec![],
value: HashMap::new(),
valid_until: SystemTime::now() - Duration::from_secs(60), // in the past
location_cache: LocationCache::new(),
})),
}
}
pub(crate) fn new_with_location_cache(location_cache: LocationCache) -> Self {
ThreadsafeMixNodesResult {
inner: Arc::new(RwLock::new(MixNodesResult {
value: HashMap::new(),
valid_until: SystemTime::now() - Duration::from_secs(60), // in the past
location_cache,
})),
}
}
pub(crate) async fn get_location_cache(&self) -> LocationCache {
self.inner.read().await.location_cache.clone()
}
pub(crate) async fn set_location(&self, identity_key: &str, location: Location) {
let mut guard = self.inner.write().await;
// cache the location for this mix node so that it can be used when the mix node list is refreshed
guard
.location_cache
.insert(identity_key.to_string(), location.clone());
// add the location to the mix node
guard
.value
.entry(identity_key.to_string())
.and_modify(|item| item.location = Some(location));
}
pub(crate) async fn get(&self) -> MixNodesResult {
// check ttl
let valid_until = self.inner.clone().read().await.valid_until;
let valid_until = self.inner.read().await.valid_until;
if valid_until < SystemTime::now() {
// force reload
@@ -37,7 +118,7 @@ impl ThreadsafeMixNodesResult {
}
// return in-memory cache
self.inner.clone().read().await.clone()
self.inner.read().await.clone()
}
pub(crate) async fn refresh_and_get(&self) -> MixNodesResult {
@@ -48,10 +129,21 @@ impl ThreadsafeMixNodesResult {
async fn refresh(&self) {
// get mixnodes and cache the new value
let value = retrieve_mixnodes().await;
self.inner.write().await.clone_from(&MixNodesResult {
value,
let location_cache = self.inner.read().await.location_cache.clone();
*self.inner.write().await = MixNodesResult {
value: value
.into_iter()
.map(|bond| {
let location = location_cache.get(&bond.mix_node.identity_key).cloned(); // add the location, if we've located this mix node before
(
bond.mix_node.identity_key.to_string(),
MixNodeBondWithLocation { bond, location },
)
})
.collect(),
valid_until: SystemTime::now() + Duration::from_secs(60 * 10), // valid for 10 minutes
});
location_cache,
};
}
}
+15
View File
@@ -0,0 +1,15 @@
use crate::mix_nodes::GeoLocation;
use isocountry::CountryCode;
pub(crate) fn map_2_letter_to_3_letter_country_code(geo: &GeoLocation) -> String {
match CountryCode::for_alpha2(&geo.country_code) {
Ok(three_letter_country_code) => three_letter_country_code.alpha3().to_string(),
Err(_e) => {
warn!(
"❌ Oh no! map_2_letter_to_3_letter_country_code failed for '{:#?}'",
geo
);
"???".to_string()
}
}
}
+18 -13
View File
@@ -5,13 +5,14 @@ use chrono::{DateTime, Utc};
use log::info;
use serde::{Deserialize, Serialize};
use mixnet_contract::MixNodeBond;
use crate::country_statistics::country_nodes_distribution::{
ConcurrentCountryNodesDistribution, CountryNodesDistribution,
};
use crate::mix_node::models::ThreadsafeMixNodeCache;
use crate::mix_nodes::ThreadsafeMixNodesResult;
use crate::mix_nodes::{LocationCache, ThreadsafeMixNodesResult};
use crate::ping::models::ThreadsafePingCache;
use mixnet_contract::MixNodeBond;
// TODO: change to an environment variable with a default value
const STATE_FILE: &str = "explorer-api-state.json";
@@ -30,15 +31,15 @@ impl ExplorerApiState {
.get()
.await
.value
.iter()
.find(|node| node.mix_node.identity_key == pubkey)
.cloned()
.get(pubkey)
.map(|cache_item| cache_item.bond.clone())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExplorerApiStateOnDisk {
pub(crate) country_node_distribution: CountryNodesDistribution,
pub(crate) location_cache: LocationCache,
pub(crate) as_at: DateTime<Utc>,
}
@@ -60,25 +61,28 @@ impl ExplorerApiStateContext {
let json_file = get_state_file_path();
let json_file_path = Path::new(&json_file);
info!("Loading state from file {:?}...", json_file);
match File::open(json_file_path) {
Ok(file) => {
let state: ExplorerApiStateOnDisk =
serde_json::from_reader(file).expect("error while reading json");
match File::open(json_file_path).map(serde_json::from_reader::<_, ExplorerApiStateOnDisk>) {
Ok(Ok(state)) => {
info!("Loaded state from file {:?}: {:?}", json_file, state);
ExplorerApiState {
country_node_distribution: ConcurrentCountryNodesDistribution::attach(
state.country_node_distribution,
country_node_distribution:
ConcurrentCountryNodesDistribution::new_from_distribution(
state.country_node_distribution,
),
mix_nodes: ThreadsafeMixNodesResult::new_with_location_cache(
state.location_cache,
),
mix_nodes: ThreadsafeMixNodesResult::new(),
mix_node_cache: ThreadsafeMixNodeCache::new(),
ping_cache: ThreadsafePingCache::new(),
}
}
Err(_e) => {
_ => {
warn!(
"Failed to load state from file {:?}, starting with empty state!",
json_file
);
ExplorerApiState {
country_node_distribution: ConcurrentCountryNodesDistribution::new(),
mix_nodes: ThreadsafeMixNodesResult::new(),
@@ -95,6 +99,7 @@ impl ExplorerApiStateContext {
let file = File::create(json_file_path).expect("unable to create state json file");
let state = ExplorerApiStateOnDisk {
country_node_distribution: self.inner.country_node_distribution.get_all().await,
location_cache: self.inner.mix_nodes.get_location_cache().await,
as_at: Utc::now(),
};
serde_json::to_writer(file, &state).expect("error writing state to disk");