dwara_core/security/geoip.rs
1//! GeoIP lookup for geo-based ACL predicates (DW-050, feature analysis
2//! 5-Security "Geo-blocking").
3//!
4//! A single MaxMind-format database file (GeoLite2-Country, GeoLite2-
5//! ASN, or a combined DB) is opened once and held behind an
6//! [`arc_swap::ArcSwapOption`] on the dataplane; authorization rules
7//! (`authorization.geoip`) consult it with the EFFECTIVE client IP
8//! (the same XFF-resolved address `ip_acl` uses — behind a trusted
9//! proxy or PROXY-protocol LB, geo decisions follow the real client,
10//! not the hop).
11//!
12//! Semantics frozen with the design:
13//!
14//! - Country is the ISO 3166-1 alpha-2 code, UPPERCASE, preferring
15//! `country` over `registered_country` (the MaxMind convention:
16//! the more specific of the two).
17//! - ASN is `autonomous_system_number`.
18//! - An address the database does not resolve (private/reserved
19//! ranges, not-in-DB, or no database loaded) yields `None` —
20//! "unknown" — and matches NEITHER allow nor deny lists in the
21//! authorization rule: a deny-list keeps passing unknowns (geo
22//! blocking must not fail closed on unlocatable addresses — they
23//! are usually infrastructure), an allow-list keeps rejecting them
24//! (an allow-list that admitted unknowns would filter nothing).
25//! - The database HOT-RELOADS: the watcher in dwara-bin polls the
26//! file's mtime and swaps the reader; in-flight lookups keep the
27//! reader they loaded (an `Arc` swap — no torn state).
28
29use std::net::IpAddr;
30
31use serde::Deserialize;
32
33/// Why a GeoIP database could not be opened.
34#[derive(Debug)]
35pub struct GeoipError(String);
36
37impl std::fmt::Display for GeoipError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "geoip database: {}", self.0)
40 }
41}
42
43impl std::error::Error for GeoipError {}
44
45/// The `country`/`registered_country` subtree shape (maxminddb
46/// deserializes ONLY the fields the struct names — extra tree data is
47/// ignored, so one struct serves country and combined databases).
48#[derive(Deserialize)]
49struct CountryNode {
50 #[serde(rename = "iso_code")]
51 iso_code: Option<String>,
52}
53
54#[derive(Deserialize)]
55struct CountryTree {
56 country: Option<CountryNode>,
57 registered_country: Option<CountryNode>,
58}
59
60/// The `autonomous_system_number` subtree shape (GeoLite2-ASN /
61/// combined databases).
62#[derive(Deserialize)]
63struct AsnTree {
64 #[serde(rename = "autonomous_system_number")]
65 asn: Option<u32>,
66}
67
68/// An opened MaxMind database.
69pub struct GeoipDb {
70 reader: maxminddb::Reader<Vec<u8>>,
71}
72
73impl GeoipDb {
74 /// Open (and memory-map) the database at `path`. The file is read
75 /// eagerly into the reader's buffer, so a later atomic replace of
76 /// the path never tears an in-flight lookup.
77 pub fn open(path: &str) -> Result<Self, GeoipError> {
78 let reader = maxminddb::Reader::open_readfile(path)
79 .map_err(|e| GeoipError(format!("open '{path}' failed: {e}")))?;
80 Ok(GeoipDb { reader })
81 }
82
83 /// The ISO 3166-1 alpha-2 country code for `ip` (uppercase), if
84 /// the database resolves one. `country` is preferred over
85 /// `registered_country`.
86 pub fn country(&self, ip: IpAddr) -> Option<String> {
87 let found = self.reader.lookup(ip).ok()?;
88 let tree: Option<CountryTree> = found.decode().ok().flatten();
89 tree.and_then(|t| {
90 t.country
91 .or(t.registered_country)
92 .and_then(|c| c.iso_code)
93 .map(|s| s.to_ascii_uppercase())
94 })
95 }
96
97 /// The autonomous system number for `ip`, if the database
98 /// resolves one.
99 pub fn asn(&self, ip: IpAddr) -> Option<u32> {
100 let found = self.reader.lookup(ip).ok()?;
101 let tree: Option<AsnTree> = found.decode().ok().flatten();
102 tree.and_then(|t| t.asn)
103 }
104}
105
106impl std::fmt::Debug for GeoipDb {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("GeoipDb").finish_non_exhaustive()
109 }
110}