feature(explorer-api): rewrite geoip2 location service (#1651)

This commit is contained in:
Pierre Dommerc
2022-09-23 14:53:56 +02:00
committed by GitHub
parent b289a3570a
commit 996f0bf732
16 changed files with 298 additions and 106 deletions
Generated
+23 -1
View File
@@ -1669,6 +1669,7 @@ dependencies = [
"isocountry",
"itertools",
"log",
"maxminddb",
"mixnet-contract-common",
"network-defaults",
"okapi",
@@ -2642,6 +2643,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e70ee094dc02fd9c13fdad4940090f22dbd6ac7c9e7094a46cf0232a50bc7c"
[[package]]
name = "ipnetwork"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4088d739b183546b239688ddbc79891831df421773df95e236daf7867866d355"
dependencies = [
"serde",
]
[[package]]
name = "ipnetwork"
version = "0.20.0"
@@ -2915,6 +2925,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "maxminddb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2ba61113f9f7a9f0e87c519682d39c43a6f3f79c2cc42c3ba3dda83b1fa334"
dependencies = [
"ipnetwork 0.18.0",
"log",
"memchr",
"serde",
]
[[package]]
name = "maybe-uninit"
version = "2.0.0"
@@ -3371,7 +3393,7 @@ dependencies = [
"completions",
"dirs",
"futures",
"ipnetwork",
"ipnetwork 0.20.0",
"log",
"network-defaults",
"nymsphinx",
+1
View File
@@ -23,6 +23,7 @@ serde = "1.0.126"
serde_json = "1.0.66"
thiserror = "1.0.29"
tokio = {version = "1.19.1", features = ["full"] }
maxminddb = "0.23.0"
mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract" }
network-defaults = { path = "../common/network-defaults" }
+7 -1
View File
@@ -11,10 +11,16 @@ Features:
## Running
Supply the environment variable `GEO_IP_SERVICE_API_KEY` with a key from https://app.ipbase.com/.
Supply the environment variable `GEOIP_DATABASE_PATH` with a path
that points to a GeoIP2 database file in binary format.
Run as a service and reverse proxy with `nginx` to add `https` with Lets Encrypt.
Setup nginx to inject the request IP to the header `X-Real-IP`.
Use https://github.com/maxmind/geoipupdate to automatically
provide and update the GeoIP2 database file.
# TODO / Known Issues
## TODO
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::geo_ip::location::Location;
use crate::state::ExplorerApiStateContext;
use rocket::response::status;
use rocket::serde::json::Json;
use rocket::{Route, State};
use rocket_okapi::okapi::openapi3::OpenApi;
use rocket_okapi::settings::OpenApiSettings;
pub fn nym_terms_make_default_routes(settings: &OpenApiSettings) -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![settings: terms]
}
#[openapi(tag = "terms")]
#[get("/")]
pub(crate) async fn terms(
_state: &State<ExplorerApiStateContext>,
location: Location,
) -> Result<Json<String>, status::Forbidden<String>> {
if location.iso_alpha2 == "US" {
return Err(status::Forbidden(Some("US government sucks".to_string())));
}
Ok(Json("Nym Terms & Conditions: Welcome".to_string()))
}
+1
View File
@@ -0,0 +1 @@
pub(crate) mod http;
@@ -1,12 +1,10 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::mix_nodes::location::{GeoLocation, Location};
use crate::mix_nodes::location::Location;
use crate::state::ExplorerApiStateContext;
use log::{info, warn};
use reqwest::Error as ReqwestError;
use task::ShutdownListener;
use thiserror::Error;
pub(crate) struct GeoLocateTask {
state: ExplorerApiStateContext,
@@ -19,13 +17,6 @@ impl GeoLocateTask {
}
pub(crate) fn start(mut self) {
if ::std::env::var("GEO_IP_SERVICE_API_KEY").is_err() {
error!(
"Env var GEO_IP_SERVICE_API_KEY is not set. Geolocation tasks will not be started."
);
return;
}
info!("Spawning mix node locator task runner...");
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(std::time::Duration::from_millis(50));
@@ -53,6 +44,8 @@ impl GeoLocateTask {
.await
.unwrap_or_default();
let geo_ip = self.state.inner.geo_ip.0.clone();
for (i, cache_item) in mixnode_bonds.values().enumerate() {
if self
.state
@@ -65,99 +58,50 @@ impl GeoLocateTask {
continue;
}
// the mix node has not been located or is the cache time has expired
match locate(&cache_item.mix_node().host).await {
Ok(geo_location) => {
let location = Location::new(geo_location);
match geo_ip.query(&cache_item.mix_node().host) {
Ok(opt) => match opt {
Some(location) => {
let location = Location::new(location);
trace!(
"{} mix nodes already located. Ip {} is located in {:#?}",
i,
cache_item.mix_node().host,
location.three_letter_iso_country_code,
);
if i > 0 && (i % 100) == 0 {
info!(
"Located {} mixnodes...",
i + 1,
trace!(
"{} mix nodes already located. Ip {} is located in {:#?}",
i,
cache_item.mix_node().host,
location.three_letter_iso_country_code,
);
if i > 0 && (i % 100) == 0 {
info!("Located {} mixnodes...", i + 1,);
}
self.state
.inner
.mixnodes
.set_location(cache_item.mix_id(), Some(location))
.await;
// one node has been located, so return out of the loop
return;
}
self.state
.inner
.mixnodes
.set_location(cache_item.mix_id(), Some(location))
.await;
// one node has been located, so return out of the loop
return;
}
Err(e) => match e {
LocateError::ReqwestError(e) => warn!(
"❌ Oh no! Location for {} failed {}",
cache_item.mix_node().host, e
),
LocateError::NotFound(e) => {
warn!(
"❌ Location for {} not found. Response body: {}",
cache_item.mix_node().host, e
);
None => {
warn!("❌ Location for {} not found.", cache_item.mix_node().host);
self.state
.inner
.mixnodes
.set_location(cache_item.mix_id(), None)
.await;
},
LocateError::RateLimited(e) => warn!(
"❌ Oh no, we've been rate limited! Location for {} failed. Response body: {}",
cache_item.mix_node().host, e
),
}
},
}
Err(e) => {
warn!(
"❌ Oh no! Location for {} failed. Error: {:#?}",
cache_item.mix_node().host,
e
);
}
};
}
trace!("All mix nodes located");
}
}
#[derive(Debug, Error)]
enum LocateError {
#[error("Oops, we have made too many requests and are being rate limited. Request body: {0}")]
RateLimited(String),
#[error("Geolocation not found. Request body: {0}")]
NotFound(String),
#[error(transparent)]
ReqwestError(#[from] ReqwestError),
}
async fn locate(ip: &str) -> Result<GeoLocation, LocateError> {
let api_key = ::std::env::var("GEO_IP_SERVICE_API_KEY")
.expect("Env var GEO_IP_SERVICE_API_KEY is not set");
let uri = format!("{}/?apikey={}&ip={}", crate::GEO_IP_SERVICE, api_key, ip);
match reqwest::get(uri.clone()).await {
Ok(response) => {
if response.status() == 429 {
return Err(LocateError::RateLimited(
response
.text()
.await
.unwrap_or_else(|_| "(the response body is empty)".to_string()),
));
}
if response.status() == 404 {
return Err(LocateError::NotFound(
response
.text()
.await
.unwrap_or_else(|_| "(the response body is empty)".to_string()),
));
}
let location = response.json::<GeoLocation>().await?;
Ok(location)
}
Err(e) => Err(LocateError::ReqwestError(e)),
}
}
Binary file not shown.
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use isocountry::CountryCode;
use log::warn;
use maxminddb::{geoip2::Country, MaxMindDBError, Reader};
use std::{net::IpAddr, str::FromStr, sync::Arc};
const DEFAULT_DATABASE_PATH: &str = "./src/geo_ip/GeoLite2-Country.mmdb";
#[derive(Debug)]
pub enum GeoIpError {
NoValidIP,
InternalError,
}
// The current State implementation does not allow to fail on state
// creation, ie. returning Result<>. To avoid to use unwrap family,
// as a workaround, wrap the state inside an Option<>
// If Reader::open_readfile fails for some reason db will will be set to None
// and an error will be logged.
pub(crate) struct GeoIp {
pub(crate) db: Option<Reader<Vec<u8>>>,
}
#[derive(Clone)]
pub(crate) struct ThreadsafeGeoIp(pub Arc<GeoIp>);
pub(crate) struct Location {
/// two-letter country code (ISO 3166-1 alpha-2)
pub(crate) iso_alpha2: String,
/// three-letter country code (ISO 3166-1 alpha-3)
pub(crate) iso_alpha3: String,
/// English country short name (ISO 3166-1)
pub(crate) name: String,
}
impl GeoIp {
pub fn new() -> Self {
let db_path = std::env::var("GEOIP_DATABASE_PATH").unwrap_or_else(|e| {
warn!(
"Env variable GEOIP_DATABASE_PATH is not set: {} - Fallback to {}",
e, DEFAULT_DATABASE_PATH
);
DEFAULT_DATABASE_PATH.to_string()
});
let reader = Reader::open_readfile(&db_path)
.map_err(|e| {
error!("Fail to open GeoLite2 database file {}: {}", db_path, e);
})
.ok();
GeoIp { db: reader }
}
pub fn query(&self, address: &str) -> Result<Option<Location>, GeoIpError> {
let ip: IpAddr = FromStr::from_str(address).map_err(|e| {
error!("Fail to create IpAddr from {}: {}", &address, e);
GeoIpError::NoValidIP
})?;
let result = self
.db
.as_ref()
.ok_or_else(|| {
error!("No registered GeoIP database");
GeoIpError::InternalError
})?
.lookup::<Country>(ip);
match &result {
Ok(v) => Ok(Some(
Location::try_from(v).map_err(|_| GeoIpError::InternalError)?,
)),
Err(e) => match e {
MaxMindDBError::AddressNotFoundError(_) => Ok(None),
_ => Err(GeoIpError::InternalError),
},
}
}
}
impl<'a> TryFrom<&Country<'a>> for Location {
type Error = String;
fn try_from(country: &Country) -> Result<Self, Self::Error> {
let data = country.country.as_ref().ok_or_else(|| {
warn!("No Country data found");
"No Country data found"
})?;
let iso_alpha2 = String::from(data.iso_code.ok_or_else(|| {
warn!("No iso alpha-2 code found in Country data {:#?}", data);
"No iso alpha-2 code found in Country data"
})?);
let iso_codes = CountryCode::for_alpha2(&iso_alpha2).map_err(|e| {
let message = format!(
"Fail to get iso codes from iso alpha-2 country code {}: {}",
&iso_alpha2, e
);
warn!("{}", &message);
message
})?;
Ok(Location {
iso_alpha2,
iso_alpha3: String::from(iso_codes.alpha3()),
name: String::from(iso_codes.name()),
})
}
}
impl ThreadsafeGeoIp {
pub fn new() -> Self {
ThreadsafeGeoIp(Arc::new(GeoIp::new()))
}
}
+1
View File
@@ -0,0 +1 @@
pub(crate) mod location;
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::geo_ip::location::{GeoIpError, Location};
use crate::state::ExplorerApiStateContext;
use rocket::http::Status;
use rocket::request::FromRequest;
use rocket::request::Outcome;
use rocket::Request;
use rocket_okapi::gen::OpenApiGenerator;
use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
const IP_HEADER: &str = "X-Real-IP";
#[derive(Debug)]
pub enum LocationError {
NoIP,
LocationNotFound,
InternalError,
}
fn find_location(request: &Request<'_>) -> Result<Location, (Status, LocationError)> {
let ip = request
.headers()
.get_one(IP_HEADER)
.map(|f| f.to_string())
.ok_or_else(|| {
error!("Header not found, {}", IP_HEADER);
(Status::Forbidden, LocationError::NoIP)
})?;
let geo_ip = &request
.rocket()
.state::<ExplorerApiStateContext>()
.ok_or((Status::InternalServerError, LocationError::InternalError))? // should never fail
.inner
.geo_ip;
let location = geo_ip
.0
.clone()
.query(&ip)
.map_err(|e| match e {
GeoIpError::NoValidIP => (Status::Forbidden, LocationError::NoIP),
GeoIpError::InternalError => {
(Status::InternalServerError, LocationError::InternalError)
}
})?
.ok_or_else(|| {
warn!("Fail to find a matching location for {}", ip);
(Status::Forbidden, LocationError::LocationNotFound)
})?;
Ok(location)
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Location {
type Error = LocationError;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match find_location(request) {
Ok(loc) => Outcome::Success(loc),
Err(e) => Outcome::Failure(e),
}
}
}
impl<'a> OpenApiFromRequest<'a> for Location {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> rocket_okapi::Result<RequestHeaderInput> {
Ok(RequestHeaderInput::None)
}
}
+1
View File
@@ -0,0 +1 @@
pub(crate) mod location;
+2
View File
@@ -5,6 +5,7 @@ use rocket::{Build, Request, Rocket};
use rocket_cors::{AllowedHeaders, AllowedOrigins};
use rocket_okapi::swagger_ui::make_swagger_ui;
use crate::buy_terms::http::nym_terms_make_default_routes;
use crate::country_statistics::http::country_statistics_make_default_routes;
use crate::gateways::http::gateways_make_default_routes;
use crate::http::swagger::get_docs;
@@ -56,6 +57,7 @@ fn configure_rocket(state: ExplorerApiStateContext) -> Rocket<Build> {
"/overview" => overview_make_default_routes(&openapi_settings),
"/ping" => ping_make_default_routes(&openapi_settings),
"/validators" => validators_make_default_routes(&openapi_settings),
"/terms" => nym_terms_make_default_routes(&openapi_settings),
};
building_rocket
+3 -1
View File
@@ -8,11 +8,14 @@ use log::info;
use network_defaults::setup_env;
use task::ShutdownNotifier;
mod buy_terms;
pub(crate) mod cache;
mod client;
pub(crate) mod commands;
mod country_statistics;
mod gateways;
mod geo_ip;
mod guards;
mod helpers;
mod http;
mod mix_node;
@@ -23,7 +26,6 @@ mod state;
mod tasks;
mod validators;
const GEO_IP_SERVICE: &str = "https://api.ipbase.com/json";
const COUNTRY_DATA_REFRESH_INTERVAL: u64 = 60 * 15; // every 15 minutes
#[tokio::main]
+5 -10
View File
@@ -1,7 +1,7 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::mix_nodes::utils::map_2_letter_to_3_letter_country_code;
use crate::geo_ip::location;
use mixnet_contract_common::NodeId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -36,19 +36,14 @@ 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);
pub(crate) fn new(location: location::Location) -> Self {
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,
country_name: location.name,
two_letter_iso_country_code: location.iso_alpha2,
three_letter_iso_country_code: location.iso_alpha3,
}
}
}
-1
View File
@@ -6,7 +6,6 @@ use std::time::Duration;
pub(crate) mod http;
pub(crate) mod location;
pub(crate) mod models;
pub(crate) mod utils;
pub(crate) const CACHE_REFRESH_RATE: Duration = Duration::from_secs(30);
pub(crate) const CACHE_ENTRY_TTL: Duration = Duration::from_secs(60);
+4
View File
@@ -7,6 +7,7 @@ use mixnet_contract_common::{IdentityKeyRef, NodeId};
use serde::{Deserialize, Serialize};
use crate::client::ThreadsafeValidatorClient;
use crate::geo_ip::location::ThreadsafeGeoIp;
use validator_client::models::MixNodeBondAnnotated;
use crate::country_statistics::country_nodes_distribution::{
@@ -30,6 +31,7 @@ pub struct ExplorerApiState {
pub(crate) mixnodes: ThreadsafeMixNodesCache,
pub(crate) ping: ThreadsafePingCache,
pub(crate) validators: ThreadsafeValidatorCache,
pub(crate) geo_ip: ThreadsafeGeoIp,
// TODO: discuss with @MS whether this is an appropriate spot for it
pub(crate) validator_client: ThreadsafeValidatorClient,
@@ -87,6 +89,7 @@ impl ExplorerApiStateContext {
ping: ThreadsafePingCache::new(),
validators: ThreadsafeValidatorCache::new(),
validator_client: ThreadsafeValidatorClient::new(),
geo_ip: ThreadsafeGeoIp::new(),
}
} else {
warn!(
@@ -102,6 +105,7 @@ impl ExplorerApiStateContext {
ping: ThreadsafePingCache::new(),
validators: ThreadsafeValidatorCache::new(),
validator_client: ThreadsafeValidatorClient::new(),
geo_ip: ThreadsafeGeoIp::new(),
}
}
}