Add Network Explorer API (#720)

* Adding the explorer API

* Added explorer-api to workspace

* Re-jigged explorer api cargo paths

* Fixed compiler warnings

* Removing unused code

* network-explorer-api: configure state with env var API_STATE_FILE or fall back to default value of `explorer-api-state.json`

* network-explorer-api: updates to `Cargo.lock` file after rebasing

* network-explorer-api: make clippy happy
This commit is contained in:
Dave Hrycyszyn
2021-08-04 12:38:35 +01:00
committed by GitHub
parent 2db79de722
commit b36ca2c4f1
18 changed files with 3052 additions and 12 deletions
Generated
+164 -12
View File
@@ -367,6 +367,20 @@ dependencies = [
"keystream",
]
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"libc",
"num-integer",
"num-traits",
"serde",
"time 0.1.44",
"winapi",
]
[[package]]
name = "cipher"
version = "0.2.5"
@@ -385,7 +399,7 @@ dependencies = [
"ansi_term",
"atty",
"bitflags",
"strsim",
"strsim 0.8.0",
"textwrap",
"unicode-width",
"vec_map",
@@ -477,7 +491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdf8865bac3d9a3bde5bde9088ca431b11f5d37c7a578b8086af77248b76627"
dependencies = [
"percent-encoding",
"time",
"time 0.2.27",
"version_check",
]
@@ -693,6 +707,41 @@ dependencies = [
"zeroize",
]
[[package]]
name = "darling"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "757c0ded2af11d8e739c4daea1ac623dd1624b06c844cf3f5a39f1bdbd99bb12"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c34d8efb62d0c2d7f60ece80f75e5c63c1588ba68032740494b0b9a996466e3"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade7bff147130fe5e6d39f089c6bd49ec0250f35d70b2eebf72afdfc919f15cc"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "4.0.2"
@@ -913,6 +962,26 @@ dependencies = [
"termcolor",
]
[[package]]
name = "explorer-api"
version = "0.1.0"
dependencies = [
"chrono",
"isocountry",
"log",
"mixnet-contract",
"okapi",
"pretty_env_logger",
"reqwest",
"rocket",
"rocket_okapi",
"schemars",
"serde",
"serde_json",
"tokio",
"validator-client",
]
[[package]]
name = "fake-simd"
version = "0.1.2"
@@ -1184,7 +1253,7 @@ dependencies = [
"cfg-if 1.0.0",
"js-sys",
"libc",
"wasi 0.10.2+wasi-snapshot-preview1",
"wasi 0.10.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
@@ -1426,6 +1495,12 @@ dependencies = [
"tokio-native-tls",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.2.3"
@@ -1487,6 +1562,16 @@ dependencies = [
"serde",
]
[[package]]
name = "isocountry"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04"
dependencies = [
"serde",
"thiserror",
]
[[package]]
name = "itoa"
version = "0.4.7"
@@ -1782,6 +1867,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-integer"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.14"
@@ -2117,6 +2212,17 @@ dependencies = [
"sphinx",
]
[[package]]
name = "okapi"
version = "0.6.0-alpha-1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb085e00daf8d75b9dbf0ffdb4738e69503e28898d9641fa8bdc6ad536c7bcf4"
dependencies = [
"schemars",
"serde",
"serde_json",
]
[[package]]
name = "once_cell"
version = "1.8.0"
@@ -2701,7 +2807,7 @@ dependencies = [
"serde_json",
"state",
"tempfile",
"time",
"time 0.2.27",
"tokio",
"tokio-stream",
"tokio-util",
@@ -2764,11 +2870,38 @@ dependencies = [
"smallvec",
"stable-pattern",
"state",
"time",
"time 0.2.27",
"tokio",
"uncased",
]
[[package]]
name = "rocket_okapi"
version = "0.7.0-alpha-1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2f4f48fb070f9f6c56d5663df5fa8a514406207744f4abd84661bfb24efd7d"
dependencies = [
"okapi",
"rocket",
"rocket_okapi_codegen",
"schemars",
"serde",
"serde_json",
]
[[package]]
name = "rocket_okapi_codegen"
version = "0.7.0-alpha-1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ccf1550e1c806461a6b08e2ab64eb10701d41bf50bde59ab9aa3a57ab14d41"
dependencies = [
"darling",
"proc-macro2",
"quote",
"rocket_http",
"syn",
]
[[package]]
name = "rocket_sync_db_pools"
version = "0.1.0-rc.1"
@@ -2852,6 +2985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc6ab463ae35acccb5cba66c0084c985257b797d288b6050cc2f6ac1b266cb78"
dependencies = [
"dyn-clone",
"indexmap",
"schemars_derive",
"serde",
"serde_json",
@@ -2989,9 +3123,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.64"
version = "1.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79"
checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127"
dependencies = [
"itoa",
"ryu",
@@ -3277,7 +3411,7 @@ dependencies = [
"sqlx-rt",
"stringprep",
"thiserror",
"time",
"time 0.2.27",
"tokio-stream",
"url",
"webpki",
@@ -3423,6 +3557,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "1.0.0"
@@ -3516,6 +3656,17 @@ dependencies = [
"syn",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi 0.10.0+wasi-snapshot-preview1",
"winapi",
]
[[package]]
name = "time"
version = "0.2.27"
@@ -3571,9 +3722,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
[[package]]
name = "tokio"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "570c2eb13b3ab38208130eccd41be92520388791207fde783bda7c1e8ace28d4"
checksum = "4b7b349f11a7047e6d1276853e612d152f5e8a352c61917887cc2169e2366b4c"
dependencies = [
"autocfg",
"bytes",
@@ -3582,6 +3733,7 @@ dependencies = [
"mio",
"num_cpus",
"once_cell",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"tokio-macros",
@@ -3951,9 +4103,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "wasm-bindgen"
+1
View File
@@ -39,6 +39,7 @@ members = [
"common/socks5/proxy-helpers",
"common/topology",
"common/wasm-utils",
"explorer-api",
"gateway",
"gateway/gateway-requests",
"mixnode",
+2
View File
@@ -0,0 +1,2 @@
target
explorer-api-state.json
+2438
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "explorer-api"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
isocountry = "0.3.2"
reqwest = "0.11.4"
rocket = {version = "0.5.0-rc.1", features=["json"] }
serde = "1.0.126"
serde_json = "1.0.66"
tokio = {version = "1.9.0", features = ["full"] }
chrono = { version = "0.4.19", features = ["serde"] }
schemars = { version = "0.8", features = ["preserve_order"] }
okapi = { version = "0.6.0-alpha-1", features = ["derive_json_schema"] }
rocket_okapi = "0.7.0-alpha-1"
log = "0.4.0"
pretty_env_logger = "0.4.0"
mixnet-contract = { path = "../common/mixnet-contract" }
validator-client = { path = "../common/client-libs/validator-client" }
+17
View File
@@ -0,0 +1,17 @@
Network Explorer API
====================
An API that can:
* calculate how many nodes are in which country, by checking the IPs of all nodes against an external service
* serve "hello world" via HTTP
TODO:
* record the number of mixnodes on a given date and write to a file for later retrieval
* store the nodes per country state in a variable
* grab mixnode description info via reqwest and serve it (avoid mixed-content errors)
* serve it all over http
* dependency injection
* tests
@@ -0,0 +1,35 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
pub type CountryNodesDistribution = HashMap<String, u32>;
#[derive(Clone)]
pub struct ConcurrentCountryNodesDistribution {
inner: Arc<RwLock<CountryNodesDistribution>>,
}
impl ConcurrentCountryNodesDistribution {
pub(crate) fn new() -> Self {
ConcurrentCountryNodesDistribution {
inner: Arc::new(RwLock::new(CountryNodesDistribution::new())),
}
}
pub(crate) fn attach(country_node_distribution: CountryNodesDistribution) -> Self {
ConcurrentCountryNodesDistribution {
inner: Arc::new(RwLock::new(country_node_distribution)),
}
}
pub(crate) async fn set_all(&mut self, country_node_distribution: CountryNodesDistribution) {
self.inner
.write()
.await
.clone_from(&country_node_distribution)
}
pub(crate) async fn get_all(&self) -> HashMap<String, u32> {
self.inner.read().await.clone()
}
}
@@ -0,0 +1,19 @@
use crate::country_statistics::country_nodes_distribution::CountryNodesDistribution;
use crate::state::ExplorerApiStateContext;
use rocket::serde::json::Json;
use rocket::{Route, State};
pub fn make_default_routes() -> Vec<Route> {
routes_with_openapi![index]
}
// We could either separate stuff by structure (like this, http is separate), or we could just
// stick the http route directly into each sub-application (e.g. put this file into the
// "country_statistics" module directly
#[openapi(tag = "country_statistics")]
#[get("/")]
pub(crate) async fn index(
state: &State<ExplorerApiStateContext>,
) -> Json<CountryNodesDistribution> {
Json(state.inner.country_node_distribution.get_all().await)
}
+124
View File
@@ -0,0 +1,124 @@
use isocountry::CountryCode;
use log::{info, trace, warn};
use mixnet_contract::MixNodeBond;
use reqwest::Error as ReqwestError;
use validator_client::Config;
use models::GeoLocation;
use crate::country_statistics::country_nodes_distribution::CountryNodesDistribution;
use crate::state::ExplorerApiStateContext;
pub mod country_nodes_distribution;
pub mod http;
mod models;
pub(crate) struct CountryStatistics {
state: ExplorerApiStateContext,
}
impl CountryStatistics {
pub(crate) fn new(state: ExplorerApiStateContext) -> Self {
CountryStatistics { state }
}
pub(crate) fn start(mut self) {
info!("Spawning task runner...");
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
loop {
// wait for the next interval tick
interval_timer.tick().await;
info!("Running task...");
self.calculate_nodes_per_country().await;
info!("Done");
}
});
}
/// 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) {
let mixnode_bonds = retrieve_mixnodes().await;
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;
trace!(
"Ip {} is located in {:#?}",
bond.mix_node.host,
map_2_letter_to_3_letter_country_code(&location)
);
if (i % 100) == 0 {
info!(
"Located {} mixnodes in {} countries",
i + 1,
distribution.len()
);
}
}
Err(e) => warn!("❌ Oh no! Location failed {}", e),
}
}
// replace the shared distribution to be the new distribution
self.state
.inner
.country_node_distribution
.set_all(distribution)
.await;
info!(
"Locating mixnodes done: {:?}",
self.state.inner.country_node_distribution.get_all().await
);
// keep state on disk, so that when this process dies it can start up again and users get some data
self.state.write_to_file().await;
}
}
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?;
Ok(location)
}
async fn retrieve_mixnodes() -> Vec<MixNodeBond> {
let client = new_validator_client();
info!("About to retrieve mixnode bonds...");
let bonds: Vec<MixNodeBond> = match client.get_cached_mix_nodes().await {
Ok(result) => result,
Err(e) => panic!("Unable to retrieve mixnode bonds: {:?}", e),
};
info!("Fetched {} mixnode bonds", bonds.len());
bonds
}
// TODO: inject constants
fn new_validator_client() -> validator_client::Client {
let config = Config::new(vec![crate::VALIDATOR_API.to_string()], crate::CONTRACT);
validator_client::Client::new(config)
}
@@ -0,0 +1,16 @@
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,
}
+28
View File
@@ -0,0 +1,28 @@
mod swagger;
use crate::country_statistics::http::make_default_routes;
use crate::http::swagger::get_docs;
use crate::ping::http::ping_make_default_routes;
use crate::state::ExplorerApiStateContext;
use log::info;
use rocket_okapi::swagger_ui::make_swagger_ui;
pub(crate) fn start(state: ExplorerApiStateContext) {
tokio::spawn(async move {
info!("Starting up...");
let config = rocket::config::Config::release_default();
rocket::build()
.configure(config)
.mount("/countries", make_default_routes())
.mount("/ping", ping_make_default_routes())
.mount("/swagger", make_swagger_ui(&get_docs()))
// .register("/", catchers![not_found])
.manage(state)
// .manage(descriptor)
// .manage(node_stats_pointer)
.launch()
.await
});
}
+11
View File
@@ -0,0 +1,11 @@
use rocket_okapi::swagger_ui::{SwaggerUIConfig, UrlObject};
pub(crate) fn get_docs() -> SwaggerUIConfig {
SwaggerUIConfig {
urls: vec![
UrlObject::new("Country statistics", "/countries/openapi.json"),
UrlObject::new("Node ping", "/ping/openapi.json"),
],
..Default::default()
}
}
+72
View File
@@ -0,0 +1,72 @@
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate rocket_okapi;
use log::info;
mod country_statistics;
mod http;
mod ping;
mod state;
const VALIDATOR_API: &str = "http://testnet-milhon-validator1.nymtech.net:8080";
const CONTRACT: &str = "punk10pyejy66429refv3g35g2t7am0was7yalwrzen";
const GEO_IP_SERVICE: &str = "https://freegeoip.app/json/";
#[tokio::main]
async fn main() {
setup_logging();
let mut explorer_api = ExplorerApi::new();
explorer_api.run().await;
}
pub struct ExplorerApi {
state: state::ExplorerApiStateContext,
}
impl ExplorerApi {
fn new() -> ExplorerApi {
ExplorerApi {
state: state::ExplorerApiStateContext::new(),
}
}
async fn run(&mut self) {
info!("Explorer API starting up...");
// spawn concurrent tasks
country_statistics::CountryStatistics::new(self.state.clone()).start();
http::start(self.state.clone());
// wait for user to press ctrl+C
self.wait_for_interrupt().await
}
async fn wait_for_interrupt(&self) {
if let Err(e) = tokio::signal::ctrl_c().await {
error!(
"There was an error while capturing SIGINT - {:?}. We will terminate regardless",
e
);
}
info!(
"Received SIGINT - the mixnode will terminate now (threads are not yet nicely stopped, if you see stack traces that's alright)."
);
}
}
fn setup_logging() {
let mut log_builder = pretty_env_logger::formatted_timed_builder();
if let Ok(s) = ::std::env::var("RUST_LOG") {
log_builder.parse_filters(&s);
} else {
// default to 'Info'
log_builder.filter(None, log::LevelFilter::Info);
}
log_builder
.filter_module("tokio_reactor", log::LevelFilter::Warn)
.filter_module("reqwest", log::LevelFilter::Warn)
.init();
}
+19
View File
@@ -0,0 +1,19 @@
use rocket::serde::json::Json;
use rocket::Route;
use serde::Deserialize;
use serde::Serialize;
pub fn ping_make_default_routes() -> Vec<Route> {
routes_with_openapi![index]
}
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct PingResponse {
response_time: u32,
}
#[openapi(tag = "ping")]
#[get("/")]
pub(crate) async fn index() -> Json<PingResponse> {
Json(PingResponse { response_time: 42 })
}
+1
View File
@@ -0,0 +1 @@
pub mod http;
+82
View File
@@ -0,0 +1,82 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::path::Path;
use log::info;
use crate::country_statistics::country_nodes_distribution::{
ConcurrentCountryNodesDistribution, CountryNodesDistribution,
};
// TODO: change to an environment variable with a default value
const STATE_FILE: &str = "explorer-api-state.json";
#[derive(Clone)]
pub struct ExplorerApiState {
pub(crate) country_node_distribution: ConcurrentCountryNodesDistribution,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExplorerApiStateOnDisk {
pub(crate) country_node_distribution: CountryNodesDistribution,
pub(crate) as_at: DateTime<Utc>,
}
#[derive(Clone)]
pub(crate) struct ExplorerApiStateContext {
pub(crate) inner: ExplorerApiState,
state_file: String,
}
impl ExplorerApiStateContext {
pub(crate) fn new() -> Self {
ExplorerApiStateContext {
inner: ExplorerApiStateContext::read_from_file(),
state_file: std::env::var("API_STATE_FILE").unwrap_or_else(|_| STATE_FILE.to_string()),
}
}
pub(crate) fn read_from_file() -> ExplorerApiState {
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");
info!("Loaded state from file {:?}: {:?}", json_file, state);
ExplorerApiState {
country_node_distribution: ConcurrentCountryNodesDistribution::attach(
state.country_node_distribution,
),
}
}
Err(_e) => {
warn!(
"Failed to load state from file {:?}, starting with empty state!",
json_file
);
ExplorerApiState {
country_node_distribution: ConcurrentCountryNodesDistribution::new(),
}
}
}
}
pub(crate) async fn write_to_file(&self) {
let json_file = get_state_file_path().to_string();
let json_file_path = Path::new(&json_file);
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,
as_at: Utc::now(),
};
serde_json::to_writer(file, &state).expect("error writing state to disk");
info!("Saved file to '{:?}'", json_file_path.canonicalize());
}
}
fn get_state_file_path() -> String {
std::env::var("API_STATE_FILE").unwrap_or_else(|_| STATE_FILE.to_string())
}