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}