use std::fmt;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::SystemTime;
use arc_swap::{ArcSwap, ArcSwapOption};
use maxminddb::{PathElement, Reader};
use serde::Deserialize;
use super::{Anonymity, IpInfo, IpIntel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MaxMindDatabase {
Country,
Asn,
Anonymizer,
}
impl fmt::Display for MaxMindDatabase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Country => "country",
Self::Asn => "ASN",
Self::Anonymizer => "anonymizer",
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MaxMindError {
#[error("cannot read MaxMind {database} database {}: {source}", path.display())]
Io {
database: MaxMindDatabase,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid MaxMind {database} database: {source}")]
Invalid {
database: MaxMindDatabase,
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("expected a MaxMind {database} database, found {found:?}")]
WrongDatabase {
database: MaxMindDatabase,
found: String,
},
}
pub struct MaxMindIntel {
databases: ArcSwap<Databases>,
anonymizer: ArcSwapOption<Reader<Vec<u8>>>,
files: Mutex<Watched>,
}
#[derive(Default)]
struct Watched {
geo: Option<Files>,
anonymizer: Option<AnonymizerFile>,
}
struct Files {
country: PathBuf,
asn: Option<PathBuf>,
stamp: Stamp,
}
type Stamp = (FileStamp, Option<FileStamp>);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FileStamp {
modified: Option<SystemTime>,
len: u64,
}
impl Files {
fn read(country: PathBuf, asn: Option<PathBuf>) -> Result<(Databases, Self), MaxMindError> {
let stamp = stamp(&country, asn.as_deref())?;
let databases = Databases::read(&country, asn.as_deref())?;
Ok((databases, Self { country, asn, stamp }))
}
}
struct AnonymizerFile {
path: PathBuf,
stamp: FileStamp,
}
impl AnonymizerFile {
fn read(path: PathBuf) -> Result<(Reader<Vec<u8>>, Self), MaxMindError> {
let database = MaxMindDatabase::Anonymizer;
let stamp = file_stamp(database, &path)?;
let bytes = std::fs::read(&path).map_err(io_error(database, &path))?;
Ok((reader(database, bytes)?, Self { path, stamp }))
}
}
fn stamp(country: &Path, asn: Option<&Path>) -> Result<Stamp, MaxMindError> {
Ok((
file_stamp(MaxMindDatabase::Country, country)?,
asn.map(|path| file_stamp(MaxMindDatabase::Asn, path)).transpose()?,
))
}
fn file_stamp(database: MaxMindDatabase, path: &Path) -> Result<FileStamp, MaxMindError> {
let metadata = std::fs::metadata(path).map_err(io_error(database, path))?;
Ok(FileStamp {
modified: metadata.modified().ok(),
len: metadata.len(),
})
}
fn io_error(database: MaxMindDatabase, path: &Path) -> impl FnOnce(std::io::Error) -> MaxMindError + '_ {
move |source| MaxMindError::Io {
database,
path: path.to_owned(),
source,
}
}
struct Databases {
country: Reader<Vec<u8>>,
asn: Option<Reader<Vec<u8>>>,
}
impl Databases {
fn read(country: &Path, asn: Option<impl AsRef<Path>>) -> Result<Self, MaxMindError> {
let read = |database, path: &Path| std::fs::read(path).map_err(io_error(database, path));
let country = read(MaxMindDatabase::Country, country)?;
let asn = asn.map(|path| read(MaxMindDatabase::Asn, path.as_ref())).transpose()?;
Self::parse(country, asn)
}
fn parse(country: Vec<u8>, asn: Option<Vec<u8>>) -> Result<Self, MaxMindError> {
Ok(Self {
country: reader(MaxMindDatabase::Country, country)?,
asn: asn.map(|bytes| reader(MaxMindDatabase::Asn, bytes)).transpose()?,
})
}
}
fn reader(database: MaxMindDatabase, bytes: Vec<u8>) -> Result<Reader<Vec<u8>>, MaxMindError> {
let reader = Reader::from_source(bytes).map_err(|source| MaxMindError::Invalid {
database,
source: Box::new(source),
})?;
let kind = reader.metadata().database_type.to_ascii_lowercase();
let kinds = [
(
MaxMindDatabase::Country,
kind.contains("country") || kind.contains("city"),
),
(MaxMindDatabase::Asn, kind.contains("asn")),
(
MaxMindDatabase::Anonymizer,
kind.contains("anonymous") || kind.contains("ip-risk"),
),
];
let fits = kinds.iter().any(|&(slot, is)| is && slot == database) || kinds.iter().all(|&(_, is)| !is);
if !fits {
return Err(MaxMindError::WrongDatabase {
database,
found: reader.metadata().database_type.clone(),
});
}
Ok(reader)
}
impl MaxMindIntel {
pub fn open(country_db: impl AsRef<Path>, asn_db: Option<impl AsRef<Path>>) -> Result<Self, MaxMindError> {
let (databases, files) = Files::read(
country_db.as_ref().to_owned(),
asn_db.map(|path| path.as_ref().to_owned()),
)?;
Ok(Self {
databases: ArcSwap::from_pointee(databases),
anonymizer: ArcSwapOption::empty(),
files: Mutex::new(Watched {
geo: Some(files),
anonymizer: None,
}),
})
}
pub fn from_bytes(country_db: Vec<u8>, asn_db: Option<Vec<u8>>) -> Result<Self, MaxMindError> {
Ok(Self {
databases: ArcSwap::from_pointee(Databases::parse(country_db, asn_db)?),
anonymizer: ArcSwapOption::empty(),
files: Mutex::new(Watched::default()),
})
}
pub fn with_anonymizer(self, anonymizer_db: impl AsRef<Path>) -> Result<Self, MaxMindError> {
self.reload_anonymizer(anonymizer_db)?;
Ok(self)
}
pub fn with_anonymizer_bytes(self, anonymizer_db: Vec<u8>) -> Result<Self, MaxMindError> {
self.reload_anonymizer_from_bytes(anonymizer_db)?;
Ok(self)
}
pub fn reload(&self, country_db: impl AsRef<Path>, asn_db: Option<impl AsRef<Path>>) -> Result<(), MaxMindError> {
let mut files = self.lock_files();
let (databases, read) = Files::read(
country_db.as_ref().to_owned(),
asn_db.map(|path| path.as_ref().to_owned()),
)?;
self.databases.store(Arc::new(databases));
files.geo = Some(read);
Ok(())
}
pub fn reload_from_bytes(&self, country_db: Vec<u8>, asn_db: Option<Vec<u8>>) -> Result<(), MaxMindError> {
let mut files = self.lock_files();
self.databases.store(Arc::new(Databases::parse(country_db, asn_db)?));
files.geo = None;
Ok(())
}
pub fn reload_anonymizer(&self, anonymizer_db: impl AsRef<Path>) -> Result<(), MaxMindError> {
let mut files = self.lock_files();
let (reader, read) = AnonymizerFile::read(anonymizer_db.as_ref().to_owned())?;
self.anonymizer.store(Some(Arc::new(reader)));
files.anonymizer = Some(read);
Ok(())
}
pub fn reload_anonymizer_from_bytes(&self, anonymizer_db: Vec<u8>) -> Result<(), MaxMindError> {
let mut files = self.lock_files();
let reader = reader(MaxMindDatabase::Anonymizer, anonymizer_db)?;
self.anonymizer.store(Some(Arc::new(reader)));
files.anonymizer = None;
Ok(())
}
pub fn reload_if_changed(&self) -> Result<bool, MaxMindError> {
let mut files = self.lock_files();
let geo = self.reload_geo_if_changed(&mut files.geo);
let anonymizer = self.reload_anonymizer_if_changed(&mut files.anonymizer);
Ok(geo? | anonymizer?)
}
fn reload_geo_if_changed(&self, files: &mut Option<Files>) -> Result<bool, MaxMindError> {
let Some(current) = files.as_ref() else {
return Ok(false);
};
if stamp(¤t.country, current.asn.as_deref())? == current.stamp {
return Ok(false);
}
let (databases, read) = Files::read(current.country.clone(), current.asn.clone())?;
self.databases.store(Arc::new(databases));
*files = Some(read);
Ok(true)
}
fn reload_anonymizer_if_changed(&self, file: &mut Option<AnonymizerFile>) -> Result<bool, MaxMindError> {
let Some(current) = file.as_ref() else {
return Ok(false);
};
if file_stamp(MaxMindDatabase::Anonymizer, ¤t.path)? == current.stamp {
return Ok(false);
}
let (reader, read) = AnonymizerFile::read(current.path.clone())?;
self.anonymizer.store(Some(Arc::new(reader)));
*file = Some(read);
Ok(true)
}
#[cfg(feature = "tokio")]
#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
pub fn spawn_reload(self: &Arc<Self>, every: std::time::Duration) -> tokio::task::JoinHandle<()> {
let intel = Arc::clone(self);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(every);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let check = Arc::clone(&intel);
match tokio::task::spawn_blocking(move || check.reload_if_changed()).await {
Ok(Ok(true)) => {
let databases = intel.databases.load();
tracing::info!(
country_built = databases.country.metadata().build_epoch,
asn_built = databases.asn.as_ref().map(|reader| reader.metadata().build_epoch),
anonymizer_built = intel
.anonymizer
.load()
.as_ref()
.map(|reader| reader.metadata().build_epoch),
"reloaded MaxMind databases"
);
}
Ok(Ok(false)) => {}
Ok(Err(error)) => tracing::warn!(%error, "reloading MaxMind databases failed"),
Err(error) => tracing::warn!(%error, "reloading MaxMind databases panicked"),
}
}
})
}
fn lock_files(&self) -> MutexGuard<'_, Watched> {
self.files.lock().unwrap_or_else(PoisonError::into_inner)
}
}
const COUNTRY: [PathElement<'static>; 2] = [PathElement::Key("country"), PathElement::Key("iso_code")];
const REGISTERED_COUNTRY: [PathElement<'static>; 2] =
[PathElement::Key("registered_country"), PathElement::Key("iso_code")];
const CITY: [PathElement<'static>; 3] = [
PathElement::Key("city"),
PathElement::Key("names"),
PathElement::Key("en"),
];
const ASN: [PathElement<'static>; 1] = [PathElement::Key("autonomous_system_number")];
const NETWORK: [PathElement<'static>; 1] = [PathElement::Key("autonomous_system_organization")];
const VPN_CONFIDENCE: u16 = 50;
#[derive(Default, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools, reason = "mirrors the database's record")]
struct AnonymizerRecord {
is_anonymous_vpn: bool,
is_hosting_provider: bool,
is_public_proxy: bool,
is_residential_proxy: bool,
is_tor_exit_node: bool,
anonymizer_confidence: Option<u16>,
}
impl AnonymizerRecord {
fn anonymity(&self) -> Anonymity {
if self.is_tor_exit_node {
Anonymity::TorExitNode
} else if self.is_public_proxy {
Anonymity::PublicProxy
} else if self.is_residential_proxy {
Anonymity::ResidentialProxy
} else if self.is_anonymous_vpn && self.anonymizer_confidence.is_none_or(|c| c >= VPN_CONFIDENCE) {
Anonymity::Vpn
} else if self.is_hosting_provider {
Anonymity::HostingProvider
} else {
Anonymity::Direct
}
}
}
impl IpIntel for MaxMindIntel {
fn lookup(&self, ip: IpAddr) -> IpInfo {
let ip = crate::client::canonical_ip(ip);
let databases = self.databases.load();
let geo = databases.country.lookup(ip).ok();
let country = geo.as_ref().and_then(|found| {
[&COUNTRY[..], ®ISTERED_COUNTRY[..]]
.into_iter()
.find_map(|path| found.decode_path::<&str>(path).ok().flatten())
.map(str::to_owned)
});
let city = geo.and_then(|found| found.decode_path::<String>(&CITY).ok().flatten());
let system = databases.asn.as_ref().and_then(|reader| reader.lookup(ip).ok());
let asn = system
.as_ref()
.and_then(|found| found.decode_path::<u32>(&ASN).ok().flatten());
let network = system.and_then(|found| found.decode_path::<String>(&NETWORK).ok().flatten());
let anonymity = self.anonymizer.load().as_ref().and_then(|reader| {
let found = reader.lookup(ip).ok()?.decode::<AnonymizerRecord>().ok()?;
Some(found.map_or(Anonymity::Direct, |record| record.anonymity()))
});
IpInfo {
anonymity,
city,
network,
..IpInfo::new(country, asn)
}
}
}
impl fmt::Debug for MaxMindIntel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let databases = self.databases.load();
let anonymizer = self.anonymizer.load();
let files = self.files.lock().unwrap_or_else(PoisonError::into_inner);
let geo = files.geo.as_ref();
f.debug_struct("MaxMindIntel")
.field("country", &databases.country.metadata().database_type)
.field("asn", &databases.asn.as_ref().map(|r| &r.metadata().database_type))
.field("anonymizer", &anonymizer.as_ref().map(|r| &r.metadata().database_type))
.field("country_file", &geo.map(|files| &files.country))
.field("asn_file", &geo.and_then(|files| files.asn.as_ref()))
.field("anonymizer_file", &files.anonymizer.as_ref().map(|file| &file.path))
.finish()
}
}
#[cfg(test)]
#[path = "maxmind_tests.rs"]
mod tests;