use std::collections::HashMap;
use std::net::IpAddr;
use std::path::Path;
use maxminddb::geoip2;
use tracing::{error, info, warn};
use crate::config::{GlobalConfig, JailConfig, MaxmindField};
use crate::detect::watcher::Failure;
#[derive(Debug, Default)]
pub struct MaxmindEnrichment {
pub asn: Option<String>,
pub country: Option<String>,
pub city: Option<String>,
}
impl MaxmindEnrichment {
pub fn has_data(&self) -> bool {
self.asn.is_some() || self.country.is_some() || self.city.is_some()
}
}
pub struct MaxmindState {
asn: Option<maxminddb::Reader<maxminddb::Mmap>>,
country: Option<maxminddb::Reader<maxminddb::Mmap>>,
city: Option<maxminddb::Reader<maxminddb::Mmap>>,
jail_fields: HashMap<String, Vec<MaxmindField>>,
}
impl MaxmindState {
pub fn load(global: &GlobalConfig, jails: &HashMap<String, JailConfig>) -> Self {
Self {
asn: global
.maxmind_asn
.as_deref()
.and_then(|p| load_db(p, "ASN")),
country: global
.maxmind_country
.as_deref()
.and_then(|p| load_db(p, "Country")),
city: global
.maxmind_city
.as_deref()
.and_then(|p| load_db(p, "City")),
jail_fields: jails
.iter()
.map(|(k, v)| (k.clone(), v.maxmind.clone()))
.collect(),
}
}
pub fn reload(&mut self, global: &GlobalConfig, jails: &HashMap<String, JailConfig>) {
self.asn = global
.maxmind_asn
.as_deref()
.and_then(|p| load_db(p, "ASN"));
self.country = global
.maxmind_country
.as_deref()
.and_then(|p| load_db(p, "Country"));
self.city = global
.maxmind_city
.as_deref()
.and_then(|p| load_db(p, "City"));
self.jail_fields = jails
.iter()
.map(|(k, v)| (k.clone(), v.maxmind.clone()))
.collect();
}
pub fn enrich(&self, ip: IpAddr, jail_id: &str) -> MaxmindEnrichment {
let fields = match self.jail_fields.get(jail_id) {
Some(f) => f.as_slice(),
None => return MaxmindEnrichment::default(),
};
let mut result = MaxmindEnrichment::default();
for field in fields {
match field {
MaxmindField::Asn => result.asn = lookup_asn(ip, self.asn.as_ref()),
MaxmindField::Country => result.country = lookup_country(ip, self.country.as_ref()),
MaxmindField::City => result.city = lookup_city(ip, self.city.as_ref()),
}
}
result
}
}
pub fn load_db(path: &Path, label: &str) -> Option<maxminddb::Reader<maxminddb::Mmap>> {
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => {
warn!(path = %path.display(), db = label, error = %e, "cannot stat MaxMind database");
return None;
}
};
if !meta.is_file() {
warn!(path = %path.display(), db = label, "MaxMind path is not a regular file");
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o002 != 0 {
error!(
path = %path.display(), db = label,
"refusing to load world-writable MaxMind database — fix permissions with chmod o-w"
);
return None;
}
}
#[allow(unsafe_code)]
let reader_result = unsafe { maxminddb::Reader::open_mmap(path) };
match reader_result {
Ok(reader) => {
info!(path = %path.display(), db = label, "MaxMind database loaded");
Some(reader)
}
Err(e) => {
warn!(path = %path.display(), db = label, error = %e, "failed to load MaxMind database");
None
}
}
}
pub fn log_ban_event(
failure: &Failure,
ban_time: i64,
ban_count: u32,
enrichment: &MaxmindEnrichment,
) {
if enrichment.has_data() {
info!(
ip = %failure.ip,
jail = %failure.jail_id,
maxmind_asn = enrichment.asn,
maxmind_country = enrichment.country,
maxmind_city = enrichment.city,
ban_time,
ban_count,
"threshold reached, banning"
);
} else {
info!(
ip = %failure.ip,
jail = %failure.jail_id,
ban_time,
ban_count,
"threshold reached, banning"
);
}
}
fn lookup_asn(ip: IpAddr, reader: Option<&maxminddb::Reader<maxminddb::Mmap>>) -> Option<String> {
let reader = reader?;
let result = reader.lookup(ip).ok()?;
let record = result.decode::<geoip2::Asn>().ok()??;
match (
record.autonomous_system_number,
record.autonomous_system_organization,
) {
(Some(num), Some(org)) => Some(format!("AS{num} ({org})")),
(Some(num), None) => Some(format!("AS{num}")),
(None, Some(org)) => Some(org.to_string()),
(None, None) => None,
}
}
fn lookup_country(
ip: IpAddr,
reader: Option<&maxminddb::Reader<maxminddb::Mmap>>,
) -> Option<String> {
let reader = reader?;
let result = reader.lookup(ip).ok()?;
let record = result.decode::<geoip2::Country>().ok()??;
record
.country
.names
.english
.map(std::string::ToString::to_string)
}
fn lookup_city(ip: IpAddr, reader: Option<&maxminddb::Reader<maxminddb::Mmap>>) -> Option<String> {
let reader = reader?;
let result = reader.lookup(ip).ok()?;
let record = result.decode::<geoip2::City>().ok()??;
record
.city
.names
.english
.map(std::string::ToString::to_string)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::track::maxmind::load_db;
#[test]
fn test_load_db_missing_file_returns_none() {
let result = load_db(Path::new("/nonexistent/path/GeoLite2-ASN.mmdb"), "ASN");
assert!(result.is_none(), "missing file should return None");
}
#[test]
fn test_load_db_directory_returns_none() {
let dir = tempfile::tempdir().expect("failed to create tempdir");
let result = load_db(dir.path(), "ASN");
assert!(result.is_none(), "directory path should return None");
}
#[cfg(unix)]
#[test]
fn test_load_db_world_writable_returns_none() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("failed to create tempdir");
let path = dir.path().join("test.mmdb");
std::fs::write(&path, b"").expect("failed to create temp file");
let mut perms = std::fs::metadata(&path)
.expect("failed to stat temp file")
.permissions();
perms.set_mode(0o666); std::fs::set_permissions(&path, perms).expect("failed to set permissions");
let result = load_db(&path, "ASN");
assert!(
result.is_none(),
"world-writable .mmdb file must be rejected"
);
}
#[cfg(unix)]
#[test]
fn test_load_db_valid_fixture_loads() {
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/GeoLite2-ASN-Test.mmdb");
let result = load_db(&fixture, "ASN");
assert!(
result.is_some(),
"valid, normally-permissioned fixture should load successfully"
);
}
}