use core::net::IpAddr;
use std::path::Path;
use super::location::GeoLocation;
use super::{GeoIpError, MmdbReader};
use serde::{Deserialize, Serialize};
pub const RAMA_IP_GEO_DB_ENV: &str = "RAMA_IP_GEO_DB";
#[derive(Debug, Clone)]
struct GeoSource {
label: Box<str>,
readers: Vec<MmdbReader>,
}
impl GeoSource {
fn lookup(&self, ip: IpAddr) -> Option<GeoLocation> {
merge_readers(&self.readers, ip)
}
}
fn merge_in_order(locations: impl IntoIterator<Item = GeoLocation>) -> Option<GeoLocation> {
locations.into_iter().reduce(|mut acc, loc| {
acc.fill_gaps_from(&loc);
acc
})
}
fn merge_readers(readers: &[MmdbReader], ip: IpAddr) -> Option<GeoLocation> {
merge_in_order(
readers
.iter()
.filter_map(|reader| reader.lookup(ip).map(|view| view.to_owned()))
.filter(|loc| !loc.is_empty()),
)
}
#[derive(Debug, Clone, Default)]
pub struct IpGeoDb {
sources: Vec<GeoSource>,
}
impl IpGeoDb {
#[must_use]
pub fn builder() -> IpGeoDbBuilder {
IpGeoDbBuilder::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.sources.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
pub fn labels(&self) -> impl Iterator<Item = &str> {
self.sources.iter().map(|s| s.label.as_ref())
}
pub fn attributions(&self) -> impl Iterator<Item = &'static str> + '_ {
let mut seen: Vec<&'static str> = Vec::new();
self.sources
.iter()
.filter_map(|source| attribution_for(&source.label))
.filter(move |¬ice| {
let fresh = !seen.contains(¬ice);
if fresh {
seen.push(notice);
}
fresh
})
}
#[must_use]
pub fn lookup(&self, ip: IpAddr) -> Option<GeoLocation> {
merge_in_order(self.sources.iter().filter_map(|source| source.lookup(ip)))
}
#[must_use]
pub fn lookup_all(&self, ip: IpAddr) -> Vec<IpGeoSourceResult> {
self.sources
.iter()
.filter_map(|s| {
s.lookup(ip).map(|location| IpGeoSourceResult {
label: s.label.clone(),
location,
})
})
.collect()
}
#[must_use]
pub fn resolve(&self, ip: IpAddr) -> Option<IpGeoInfo> {
let by_source = self.lookup_all(ip);
let location = merge_in_order(by_source.iter().map(|r| r.location.clone()))?;
Some(IpGeoInfo {
ip,
location,
by_source,
})
}
pub fn from_env() -> Result<Option<Self>, GeoIpError> {
match std::env::var(RAMA_IP_GEO_DB_ENV) {
Ok(spec) if !spec.trim().is_empty() => Self::parse_spec(&spec).map(Some),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(GeoIpError::InvalidConfig(
format!("{RAMA_IP_GEO_DB_ENV} is set but not valid UTF-8").into_boxed_str(),
)),
}
}
pub fn parse_spec(spec: &str) -> Result<Self, GeoIpError> {
let invalid = |why: String| GeoIpError::InvalidConfig(why.into_boxed_str());
let mut builder = Self::builder();
for entry in spec.split(';') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (label, paths_str) = match entry.split_once('=') {
Some((label, paths)) => (label.trim(), paths),
None => ("", entry),
};
let paths: Vec<&str> = paths_str.split('+').map(str::trim).collect();
if paths.iter().any(|p| p.is_empty()) {
return Err(invalid(format!("empty path in entry {entry:?}")));
}
let label: Box<str> = if label.is_empty() {
default_label(paths[0]).into()
} else {
label.into()
};
let mut readers = Vec::with_capacity(paths.len());
for path in paths {
let reader = open_reader(path).map_err(|error| GeoIpError::Source {
path: path.into(),
error: Box::new(error),
})?;
readers.push(reader);
}
builder = builder.source(label, readers);
}
let db = builder.build();
if db.is_empty() {
return Err(invalid(format!("no sources parsed from {spec:?}")));
}
Ok(db)
}
}
fn open_reader(path: &str) -> Result<MmdbReader, GeoIpError> {
#[cfg(feature = "mmap")]
{
MmdbReader::open_mmap(path)
}
#[cfg(not(feature = "mmap"))]
{
MmdbReader::open(path)
}
}
fn default_label(path: &str) -> &str {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
}
const ATTRIBUTION_GEOLITE2: &str = "This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com";
const ATTRIBUTION_IP2LOCATION_LITE: &str = "This site or product includes IP2Location LITE data available from https://lite.ip2location.com";
const ATTRIBUTION_DBIP: &str =
"This product includes IP geolocation data from DB-IP, available from https://db-ip.com";
fn attribution_for(label: &str) -> Option<&'static str> {
rama_utils::macros::match_ignore_ascii_case_str! {
match (label) {
contains "geolite" | "maxmind" => Some(ATTRIBUTION_GEOLITE2),
contains "ip2location" => Some(ATTRIBUTION_IP2LOCATION_LITE),
contains "dbip" | "db-ip" => Some(ATTRIBUTION_DBIP),
_ => None,
}
}
}
#[derive(Debug, Default)]
pub struct IpGeoDbBuilder {
sources: Vec<GeoSource>,
}
impl IpGeoDbBuilder {
#[must_use]
pub fn source(
mut self,
label: impl Into<Box<str>>,
readers: impl IntoIterator<Item = MmdbReader>,
) -> Self {
let readers: Vec<_> = readers.into_iter().collect();
if !readers.is_empty() {
self.sources.push(GeoSource {
label: label.into(),
readers,
});
}
self
}
#[must_use]
pub fn reader(self, label: impl Into<Box<str>>, reader: MmdbReader) -> Self {
self.source(label, [reader])
}
#[must_use]
pub fn build(self) -> IpGeoDb {
IpGeoDb {
sources: self.sources,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IpGeoSourceResult {
pub label: Box<str>,
pub location: GeoLocation,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, rama_core::extensions::Extension)]
#[extension(tags(net))]
pub struct IpGeoInfo {
pub ip: IpAddr,
pub location: GeoLocation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub by_source: Vec<IpGeoSourceResult>,
}
#[cfg(test)]
mod tests {
use core::net::IpAddr;
use super::super::AsOrg;
use super::*;
use crate::address::ip::geo::mmdb::{IpVersion, MmdbBuilder};
use crate::asn::LossyAsn;
use rama_core::geo::Country;
use ipnet::IpNet;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn net(s: &str) -> IpNet {
s.parse().unwrap()
}
fn country_reader(code: &str) -> MmdbReader {
let mut b = MmdbBuilder::new(IpVersion::V4, "GeoLite2-Country");
let loc = GeoLocation {
country: Some(Country::from_code(code)),
..Default::default()
};
b.insert(net("1.2.3.0/24"), &loc).unwrap();
MmdbReader::from_bytes(b.build().unwrap()).unwrap()
}
fn asn_reader(asn: u32, org: &str) -> MmdbReader {
let mut b = MmdbBuilder::new(IpVersion::V4, "GeoLite2-ASN");
let loc = GeoLocation {
autonomous_system: Some(AsOrg {
asn: Some(LossyAsn::from(asn)),
organization: Some(org.into()),
}),
..Default::default()
};
b.insert(net("1.2.3.0/24"), &loc).unwrap();
MmdbReader::from_bytes(b.build().unwrap()).unwrap()
}
#[test]
fn merges_readers_within_a_source() {
let db = IpGeoDb::builder()
.source(
"geolite2",
[country_reader("BE"), asn_reader(15169, "Google LLC")],
)
.build();
let loc = db.lookup(ip("1.2.3.4")).expect("data present");
assert_eq!(loc.country, Some(Country::Belgium));
assert_eq!(
loc.autonomous_system
.as_ref()
.unwrap()
.asn
.unwrap()
.as_u32(),
15169
);
assert!(db.lookup(ip("9.9.9.9")).is_none());
}
#[test]
fn earlier_sources_win_on_conflict() {
let db = IpGeoDb::builder()
.reader("primary", country_reader("BE"))
.reader("secondary", country_reader("DE"))
.build();
assert_eq!(
db.lookup(ip("1.2.3.4")).unwrap().country,
Some(Country::Belgium)
);
let all = db.lookup_all(ip("1.2.3.4"));
assert_eq!(all.len(), 2);
assert_eq!(all[0].label.as_ref(), "primary");
assert_eq!(all[0].location.country, Some(Country::Belgium));
assert_eq!(all[1].label.as_ref(), "secondary");
assert_eq!(all[1].location.country, Some(Country::Germany));
}
#[test]
fn resolve_bundles_ip_merged_and_breakdown() {
let db = IpGeoDb::builder()
.reader("geo", country_reader("BE"))
.reader("asn", asn_reader(15169, "Google LLC"))
.build();
let info = db.resolve(ip("1.2.3.4")).expect("data present");
assert_eq!(info.ip, ip("1.2.3.4"));
assert_eq!(info.location.country, Some(Country::Belgium));
assert_eq!(info.by_source.len(), 2);
let json = serde_json::to_string(&info).unwrap();
let back: IpGeoInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info, back);
assert!(db.resolve(ip("9.9.9.9")).is_none());
}
#[test]
fn parse_spec_validation() {
assert!(matches!(
IpGeoDb::parse_spec(" "),
Err(GeoIpError::InvalidConfig(_))
));
assert!(matches!(
IpGeoDb::parse_spec("label=a.mmdb+"),
Err(GeoIpError::InvalidConfig(_))
));
assert!(matches!(
IpGeoDb::parse_spec("label=/nonexistent/does-not-exist.mmdb"),
Err(GeoIpError::Source { .. })
));
}
#[test]
fn parse_spec_loads_files() {
let dir = tempfile::tempdir().expect("tempdir");
let country = dir.path().join("country.mmdb");
let asn = dir.path().join("asn.mmdb");
let mut b = MmdbBuilder::new(IpVersion::V4, "GeoLite2-Country");
b.insert(
net("1.2.3.0/24"),
&GeoLocation {
country: Some(Country::Belgium),
..Default::default()
},
)
.unwrap();
b.write_to_file(&country).unwrap();
let mut a = MmdbBuilder::new(IpVersion::V4, "GeoLite2-ASN");
a.insert(
net("1.2.3.0/24"),
&GeoLocation {
autonomous_system: Some(AsOrg {
asn: Some(LossyAsn::from(15169)),
organization: None,
}),
..Default::default()
},
)
.unwrap();
a.write_to_file(&asn).unwrap();
let spec = format!(
"geolite2={}+{};other={}",
country.display(),
asn.display(),
country.display()
);
let db = IpGeoDb::parse_spec(&spec).unwrap();
assert_eq!(db.len(), 2);
assert_eq!(db.labels().collect::<Vec<_>>(), vec!["geolite2", "other"]);
let loc = db.lookup(ip("1.2.3.4")).unwrap();
assert_eq!(loc.country, Some(Country::Belgium));
assert_eq!(loc.autonomous_system.unwrap().asn.unwrap().as_u32(), 15169);
let db2 = IpGeoDb::parse_spec(&country.display().to_string()).unwrap();
assert_eq!(db2.labels().collect::<Vec<_>>(), vec!["country"]);
}
fn reader_typed(database_type: &str) -> MmdbReader {
let mut b = MmdbBuilder::new(IpVersion::V4, database_type);
b.insert(
net("1.2.3.0/24"),
&GeoLocation {
country: Some(Country::Belgium),
..Default::default()
},
)
.unwrap();
MmdbReader::from_bytes(b.build().unwrap()).unwrap()
}
#[test]
fn attributions_reflect_loaded_sources() {
let db = IpGeoDb::builder()
.source(
"geolite2",
[reader_typed("GeoLite2-City"), reader_typed("GeoLite2-ASN")],
)
.source("ip2location", [reader_typed("GeoLite2-City")])
.reader("custom", reader_typed("Custom-DB"))
.build();
let attr: Vec<_> = db.attributions().collect();
assert_eq!(attr.len(), 2, "{attr:?}");
assert!(attr.iter().any(|a| a.contains("GeoLite2")));
assert!(attr.iter().any(|a| a.contains("IP2Location")));
assert!(IpGeoDb::default().attributions().next().is_none());
}
}