Skip to main content

actr_framework/util/
geoip.rs

1//! GeoIP geolocation lookup utility
2//!
3//! Provides IP-to-geographic-coordinate conversion based on MaxMind GeoLite2 database
4//!
5//! # Usage Example
6//!
7//! ```rust,ignore
8//! use actr_framework::util::geoip::GeoIpService;
9//! use std::net::IpAddr;
10//!
11//! // Initialize GeoIP service
12//! let geoip = GeoIpService::new("data/geoip/GeoLite2-City.mmdb")?;
13//!
14//! // Query coordinates for an IP address
15//! let ip: IpAddr = "8.8.8.8".parse()?;
16//! if let Some((lat, lon)) = geoip.lookup(ip) {
17//!     println!("IP {} is at coordinates: ({}, {})", ip, lat, lon);
18//! }
19//! ```
20//!
21//! # Obtaining the GeoLite2 Database
22//!
23//! **Auto-download (recommended):**
24//! 1. Visit https://www.maxmind.com/en/geolite2/signup to get a License Key
25//! 2. Set environment variable: `export MAXMIND_LICENSE_KEY="your-key-here"`
26//! 3. Auto-downloaded on first call to `GeoIpService::new()`
27//!
28//! **Manual download (production):**
29//! ```bash
30//! curl -o GeoLite2-City.tar.gz \
31//!   "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz"
32//! tar -xzf GeoLite2-City.tar.gz --strip-components=1 -C data/geoip/ "*/GeoLite2-City.mmdb"
33//! ```
34
35#[cfg(feature = "geoip")]
36use anyhow::{Context, Result};
37#[cfg(feature = "geoip")]
38use maxminddb::{MaxMindDBError, Reader, geoip2::City};
39#[cfg(feature = "geoip")]
40use std::net::IpAddr;
41#[cfg(feature = "geoip")]
42use std::path::Path;
43#[cfg(feature = "geoip")]
44use tracing::{debug, info, warn};
45
46/// GeoIP lookup service
47///
48/// Provides IP-to-geographic-coordinate conversion
49#[cfg(feature = "geoip")]
50#[derive(Debug)]
51pub struct GeoIpService {
52    reader: Reader<Vec<u8>>,
53}
54
55#[cfg(feature = "geoip")]
56impl GeoIpService {
57    /// Initialize GeoIP service (supports auto-download)
58    ///
59    /// # Arguments
60    /// * `db_path` - Path to GeoLite2-City.mmdb database file
61    ///
62    /// # Errors
63    /// Returns error if database file does not exist or has invalid format
64    ///
65    /// # Auto-download
66    /// If the database file does not exist and `MAXMIND_LICENSE_KEY` env var is set,
67    /// GeoLite2-City database will be automatically downloaded from MaxMind.
68    pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
69        let path = db_path.as_ref();
70
71        // If database does not exist, attempt auto-download
72        if !path.exists() {
73            info!("GeoIP database not found at {:?}", path);
74
75            if let Ok(license_key) = std::env::var("MAXMIND_LICENSE_KEY") {
76                info!("MAXMIND_LICENSE_KEY found, attempting auto-download...");
77                Self::download_database(path, &license_key)?;
78            } else {
79                anyhow::bail!(
80                    "GeoIP database not found at {:?}\n\
81                     \n\
82                     To auto-download:\n\
83                     1. Get License Key: https://www.maxmind.com/en/geolite2/signup\n\
84                     2. export MAXMIND_LICENSE_KEY=\"your-key\"\n\
85                     3. Retry\n\
86                     \n\
87                     Or manually download:\n\
88                     curl -o GeoLite2-City.tar.gz \\\n\
89                       'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz'\n\
90                     tar -xzf GeoLite2-City.tar.gz --strip-components=1 -C {:?}/ '*/GeoLite2-City.mmdb'",
91                    path,
92                    path.parent().unwrap_or(Path::new("."))
93                );
94            }
95        }
96
97        info!("Loading GeoIP database from: {:?}", path);
98        let reader = Reader::open_readfile(path)
99            .context(format!("Failed to open GeoIP database at {path:?}"))?;
100
101        info!(
102            "GeoIP service initialized (build epoch: {})",
103            reader.metadata.build_epoch
104        );
105        Ok(Self { reader })
106    }
107
108    /// Auto-download GeoLite2-City database
109    fn download_database(db_path: &Path, license_key: &str) -> Result<()> {
110        use reqwest::blocking::Client;
111
112        info!("Downloading GeoLite2-City database (~70MB)...");
113
114        // Build download URL
115        let url = format!(
116            "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key={}&suffix=tar.gz",
117            license_key
118        );
119
120        // Download
121        let client = Client::builder()
122            .timeout(std::time::Duration::from_secs(300)) // 5 minute timeout
123            .build()?;
124
125        let response = client
126            .get(&url)
127            .send()
128            .context("Failed to download GeoLite2 database")?;
129
130        if !response.status().is_success() {
131            anyhow::bail!(
132                "Download failed with status: {} - Check your MAXMIND_LICENSE_KEY",
133                response.status()
134            );
135        }
136
137        info!("Download complete, extracting...");
138
139        // Decompress tar.gz
140        let tar_gz_data = response.bytes()?;
141        let tar_decoder = flate2::read::GzDecoder::new(&tar_gz_data[..]);
142        let mut archive = tar::Archive::new(tar_decoder);
143
144        // Find and extract the .mmdb file
145        for entry in archive.entries()? {
146            let mut entry = entry?;
147            let path_in_archive = entry.path()?;
148
149            if path_in_archive.extension() == Some(std::ffi::OsStr::new("mmdb"))
150                && path_in_archive.to_string_lossy().contains("GeoLite2-City")
151            {
152                // Create parent directory
153                if let Some(parent) = db_path.parent() {
154                    std::fs::create_dir_all(parent)?;
155                }
156
157                // Extract to target location
158                let mut output = std::fs::File::create(db_path)?;
159                std::io::copy(&mut entry, &mut output)?;
160
161                let size = std::fs::metadata(db_path)?.len();
162                info!(
163                    "GeoIP database downloaded to {:?} ({:.1} MB)",
164                    db_path,
165                    size as f64 / 1_048_576.0
166                );
167                return Ok(());
168            }
169        }
170
171        anyhow::bail!("GeoLite2-City.mmdb not found in downloaded archive");
172    }
173
174    /// Look up geographic coordinates for an IP address
175    ///
176    /// # Arguments
177    /// * `ip` - The IP address to look up
178    ///
179    /// # Returns
180    /// * `Some((latitude, longitude))` - Coordinates found
181    /// * `None` - IP not in database or no coordinate info
182    pub fn lookup(&self, ip: IpAddr) -> Option<(f64, f64)> {
183        match self.reader.lookup::<City>(ip) {
184            Ok(city) => {
185                if let Some(location) = city.location {
186                    if let (Some(lat), Some(lon)) = (location.latitude, location.longitude) {
187                        debug!("GeoIP lookup: {} -> ({}, {})", ip, lat, lon);
188                        return Some((lat, lon));
189                    }
190                }
191                debug!("GeoIP lookup: {} found but no coordinates", ip);
192                None
193            }
194            Err(MaxMindDBError::AddressNotFoundError(_)) => {
195                debug!("GeoIP lookup: {} not in database", ip);
196                None
197            }
198            Err(e) => {
199                warn!("GeoIP lookup error for {}: {}", ip, e);
200                None
201            }
202        }
203    }
204
205    /// Get database metadata
206    pub fn metadata(&self) -> &maxminddb::Metadata {
207        &self.reader.metadata
208    }
209}
210
211/// Fallback implementation when GeoIP feature is disabled
212#[cfg(not(feature = "geoip"))]
213#[derive(Debug)]
214pub struct GeoIpService;
215
216#[cfg(not(feature = "geoip"))]
217impl GeoIpService {
218    /// Initialization fails (requires geoip feature)
219    pub fn new<P>(_db_path: P) -> anyhow::Result<Self> {
220        anyhow::bail!("GeoIP feature is not enabled. Rebuild with --features geoip")
221    }
222
223    /// Always returns None (requires geoip feature)
224    pub fn lookup(&self, _ip: std::net::IpAddr) -> Option<(f64, f64)> {
225        None
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_geoip_module_compiles() {
235        let _ = std::mem::size_of::<GeoIpService>();
236    }
237
238    #[cfg(feature = "geoip")]
239    #[test]
240    fn test_geoip_lookup_requires_database() {
241        // Test requires a real database file; here we only verify API availability
242        let result = GeoIpService::new("/nonexistent/path.mmdb");
243        assert!(result.is_err());
244    }
245
246    #[cfg(not(feature = "geoip"))]
247    #[test]
248    fn test_geoip_feature_disabled() {
249        let result = GeoIpService::new("/any/path.mmdb");
250        assert!(result.is_err());
251        assert!(
252            result
253                .unwrap_err()
254                .to_string()
255                .contains("GeoIP feature is not enabled")
256        );
257    }
258}