#[cfg(feature = "geoip")]
use anyhow::{Context, Result};
#[cfg(feature = "geoip")]
use maxminddb::{MaxMindDBError, Reader, geoip2::City};
#[cfg(feature = "geoip")]
use std::net::IpAddr;
#[cfg(feature = "geoip")]
use std::path::Path;
#[cfg(feature = "geoip")]
use tracing::{debug, info, warn};
#[cfg(feature = "geoip")]
#[derive(Debug)]
pub struct GeoIpService {
reader: Reader<Vec<u8>>,
}
#[cfg(feature = "geoip")]
impl GeoIpService {
pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
let path = db_path.as_ref();
if !path.exists() {
info!("GeoIP database not found at {:?}", path);
if let Ok(license_key) = std::env::var("MAXMIND_LICENSE_KEY") {
info!("MAXMIND_LICENSE_KEY found, attempting auto-download...");
Self::download_database(path, &license_key)?;
} else {
anyhow::bail!(
"GeoIP database not found at {:?}\n\
\n\
To auto-download:\n\
1. Get License Key: https://www.maxmind.com/en/geolite2/signup\n\
2. export MAXMIND_LICENSE_KEY=\"your-key\"\n\
3. Retry\n\
\n\
Or manually download:\n\
curl -o GeoLite2-City.tar.gz \\\n\
'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz'\n\
tar -xzf GeoLite2-City.tar.gz --strip-components=1 -C {:?}/ '*/GeoLite2-City.mmdb'",
path,
path.parent().unwrap_or(Path::new("."))
);
}
}
info!("Loading GeoIP database from: {:?}", path);
let reader = Reader::open_readfile(path)
.context(format!("Failed to open GeoIP database at {path:?}"))?;
info!(
"GeoIP service initialized (build epoch: {})",
reader.metadata.build_epoch
);
Ok(Self { reader })
}
fn download_database(db_path: &Path, license_key: &str) -> Result<()> {
use reqwest::blocking::Client;
info!("Downloading GeoLite2-City database (~70MB)...");
let url = format!(
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key={}&suffix=tar.gz",
license_key
);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(300)) .build()?;
let response = client
.get(&url)
.send()
.context("Failed to download GeoLite2 database")?;
if !response.status().is_success() {
anyhow::bail!(
"Download failed with status: {} - Check your MAXMIND_LICENSE_KEY",
response.status()
);
}
info!("Download complete, extracting...");
let tar_gz_data = response.bytes()?;
let tar_decoder = flate2::read::GzDecoder::new(&tar_gz_data[..]);
let mut archive = tar::Archive::new(tar_decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path_in_archive = entry.path()?;
if path_in_archive.extension() == Some(std::ffi::OsStr::new("mmdb"))
&& path_in_archive.to_string_lossy().contains("GeoLite2-City")
{
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut output = std::fs::File::create(db_path)?;
std::io::copy(&mut entry, &mut output)?;
let size = std::fs::metadata(db_path)?.len();
info!(
"GeoIP database downloaded to {:?} ({:.1} MB)",
db_path,
size as f64 / 1_048_576.0
);
return Ok(());
}
}
anyhow::bail!("GeoLite2-City.mmdb not found in downloaded archive");
}
pub fn lookup(&self, ip: IpAddr) -> Option<(f64, f64)> {
match self.reader.lookup::<City>(ip) {
Ok(city) => {
if let Some(location) = city.location {
if let (Some(lat), Some(lon)) = (location.latitude, location.longitude) {
debug!("GeoIP lookup: {} -> ({}, {})", ip, lat, lon);
return Some((lat, lon));
}
}
debug!("GeoIP lookup: {} found but no coordinates", ip);
None
}
Err(MaxMindDBError::AddressNotFoundError(_)) => {
debug!("GeoIP lookup: {} not in database", ip);
None
}
Err(e) => {
warn!("GeoIP lookup error for {}: {}", ip, e);
None
}
}
}
pub fn metadata(&self) -> &maxminddb::Metadata {
&self.reader.metadata
}
}
#[cfg(not(feature = "geoip"))]
#[derive(Debug)]
pub struct GeoIpService;
#[cfg(not(feature = "geoip"))]
impl GeoIpService {
pub fn new<P>(_db_path: P) -> anyhow::Result<Self> {
anyhow::bail!("GeoIP feature is not enabled. Rebuild with --features geoip")
}
pub fn lookup(&self, _ip: std::net::IpAddr) -> Option<(f64, f64)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_geoip_module_compiles() {
let _ = std::mem::size_of::<GeoIpService>();
}
#[cfg(feature = "geoip")]
#[test]
fn test_geoip_lookup_requires_database() {
let result = GeoIpService::new("/nonexistent/path.mmdb");
assert!(result.is_err());
}
#[cfg(not(feature = "geoip"))]
#[test]
fn test_geoip_feature_disabled() {
let result = GeoIpService::new("/any/path.mmdb");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("GeoIP feature is not enabled")
);
}
}