Skip to main content

session_guard/intel/
maxmind.rs

1//! [`IpIntel`] backed by `MaxMind` `GeoLite2` / `GeoIP2` databases.
2
3use std::fmt;
4use std::net::IpAddr;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
7use std::time::SystemTime;
8
9use arc_swap::{ArcSwap, ArcSwapOption};
10use maxminddb::{PathElement, Reader};
11use serde::Deserialize;
12
13use super::{Anonymity, IpInfo, IpIntel};
14
15/// Which database an error is about.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum MaxMindDatabase {
19    /// The City or Country database, used for the ISO country code and, with a City edition,
20    /// the city.
21    Country,
22    /// The ASN database, used for the autonomous system number and organization name.
23    Asn,
24    /// The Anonymous IP, Anonymous Plus or IP Risk database, used for the [`Anonymity`].
25    Anonymizer,
26}
27
28impl fmt::Display for MaxMindDatabase {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(match self {
31            Self::Country => "country",
32            Self::Asn => "ASN",
33            Self::Anonymizer => "anonymizer",
34        })
35    }
36}
37
38/// Errors loading `MaxMind` databases.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum MaxMindError {
42    /// The database file could not be read.
43    #[error("cannot read MaxMind {database} database {}: {source}", path.display())]
44    Io {
45        /// Which database.
46        database: MaxMindDatabase,
47        /// The path that failed.
48        path: PathBuf,
49        /// The underlying I/O error.
50        #[source]
51        source: std::io::Error,
52    },
53    /// The bytes are not a valid MMDB file.
54    #[error("invalid MaxMind {database} database: {source}")]
55    Invalid {
56        /// Which database.
57        database: MaxMindDatabase,
58        /// The parser's error.
59        #[source]
60        source: Box<dyn std::error::Error + Send + Sync + 'static>,
61    },
62    /// A valid database of the wrong kind, e.g. an ASN database passed as the country database.
63    #[error("expected a MaxMind {database} database, found {found:?}")]
64    WrongDatabase {
65        /// Which slot the database was passed for.
66        database: MaxMindDatabase,
67        /// The database type from the file's metadata.
68        found: String,
69    },
70}
71
72/// Country, city, ASN, network name and anonymizer lookups from `MaxMind` databases
73/// (`GeoLite2-City` / `GeoLite2-Country` / `GeoIP2-*` for the country, and the city with a City
74/// edition; `GeoLite2-ASN` for the autonomous system number and organization name; and
75/// optionally `GeoIP2-Anonymous-IP`, `GeoIP-Anonymous-Plus` or `GeoIP2-IP-Risk` for VPNs,
76/// proxies and Tor, added with [`with_anonymizer`](Self::with_anonymizer)).
77///
78/// Databases are read into memory once, so lookups on the request path never touch the disk
79/// and are lock-free. Unknown addresses and lookup errors yield empty fields.
80///
81/// # Anonymizers
82///
83/// The anonymizer databases are paid `GeoIP2` products; there is no `GeoLite2` edition. With
84/// one loaded, every lookup sets [`IpInfo::anonymity`], which feeds the
85/// [`TorOrProxyEntered`](crate::Check::TorOrProxyEntered) and
86/// [`AnonymizerEntered`](crate::Check::AnonymizerEntered) checks. The database's flags map to
87/// the most severe [`Anonymity`] that applies:
88///
89/// | Flag | Anonymity |
90/// |---|---|
91/// | `is_tor_exit_node` | [`TorExitNode`](Anonymity::TorExitNode) |
92/// | `is_public_proxy` | [`PublicProxy`](Anonymity::PublicProxy) |
93/// | `is_residential_proxy` | [`ResidentialProxy`](Anonymity::ResidentialProxy) |
94/// | `is_anonymous_vpn`, unless `anonymizer_confidence` is below 50 | [`Vpn`](Anonymity::Vpn) |
95/// | `is_hosting_provider` | [`HostingProvider`](Anonymity::HostingProvider) |
96/// | none of these, or an address the database does not list | [`Direct`](Anonymity::Direct) |
97///
98/// A failed lookup (e.g. an IPv6 address in an IPv4-only database) leaves the anonymity
99/// unknown, so it never fires a check. The IP Risk database's `ip_risk` score is not used: the
100/// guard scores what changed during a session rather than how risky an address is on its own.
101///
102/// ```no_run
103/// use session_guard::MaxMindIntel;
104///
105/// let intel = MaxMindIntel::open("/var/lib/GeoIP/GeoLite2-Country.mmdb", None::<&str>)?
106///     .with_anonymizer("/var/lib/GeoIP/GeoIP2-Anonymous-IP.mmdb")?;
107/// # let _ = intel;
108/// # Ok::<(), session_guard::MaxMindError>(())
109/// ```
110///
111/// ```no_run
112/// use session_guard::{Guard, GuardConfig, MaxMindIntel, MemoryStore};
113///
114/// let intel = MaxMindIntel::open(
115///     "/var/lib/GeoIP/GeoLite2-Country.mmdb",
116///     Some("/var/lib/GeoIP/GeoLite2-ASN.mmdb"),
117/// )?;
118/// let guard = Guard::builder(MemoryStore::new(), GuardConfig::default()).ip_intel(intel).build();
119/// # let _ = guard;
120/// # Ok::<(), session_guard::MaxMindError>(())
121/// ```
122///
123/// # Keeping the databases up to date
124///
125/// `MaxMind` publishes new databases weekly or twice a week, and `geoipupdate` (as a cron job,
126/// a sidecar or a shared volume) replaces the files on disk. Those new files are only used once
127/// they are reloaded:
128///
129/// - [`spawn_reload`](Self::spawn_reload) (feature `tokio`) checks the files on an interval
130///   and reloads them when they change. This is the usual choice.
131/// - [`reload_if_changed`](Self::reload_if_changed) does a single check, to drive from your own
132///   scheduler or runtime.
133/// - [`reload`](Self::reload) and [`reload_from_bytes`](Self::reload_from_bytes) swap the
134///   databases unconditionally.
135///
136/// A change is detected from each file's modification time and size, so it works on every kind
137/// of mount, including network file systems and Kubernetes volumes that deliver no file system
138/// events. A reload swaps the country and ASN databases at once; in-flight lookups finish on the
139/// old ones. The anonymizer database is checked and reloaded on its own, so a failed update of
140/// one never holds back the other. [`reload`](Self::reload) and
141/// [`reload_from_bytes`](Self::reload_from_bytes) leave the anonymizer in place; swap it with
142/// [`reload_anonymizer`](Self::reload_anonymizer) or
143/// [`reload_anonymizer_from_bytes`](Self::reload_anonymizer_from_bytes).
144///
145/// To reload a source the guard uses, wrap it in an `Arc`, pass a clone to
146/// [`GuardBuilder::ip_intel`](crate::GuardBuilder::ip_intel) and keep the other:
147///
148/// ```no_run
149/// # #[cfg(feature = "tokio")]
150/// # async fn run() -> Result<(), session_guard::MaxMindError> {
151/// use std::sync::Arc;
152/// use std::time::Duration;
153///
154/// use session_guard::{Guard, GuardConfig, MaxMindIntel, MemoryStore};
155///
156/// let intel = Arc::new(MaxMindIntel::open(
157///     "/var/lib/GeoIP/GeoLite2-Country.mmdb",
158///     Some("/var/lib/GeoIP/GeoLite2-ASN.mmdb"),
159/// )?);
160/// intel.spawn_reload(Duration::from_secs(60 * 60));
161/// let guard = Guard::builder(MemoryStore::new(), GuardConfig::default())
162///     .ip_intel(Arc::clone(&intel))
163///     .build();
164/// # let _ = guard;
165/// # Ok(())
166/// # }
167/// ```
168pub struct MaxMindIntel {
169    databases: ArcSwap<Databases>,
170    anonymizer: ArcSwapOption<Reader<Vec<u8>>>,
171    /// The files the current databases were read from. The lock also serializes reloads, so an
172    /// older read never replaces a newer one.
173    files: Mutex<Watched>,
174}
175
176/// The files [`MaxMindIntel::reload_if_changed`] follows; `None` for databases from bytes.
177#[derive(Default)]
178struct Watched {
179    geo: Option<Files>,
180    anonymizer: Option<AnonymizerFile>,
181}
182
183/// The files behind the databases and their state just before they were read.
184struct Files {
185    country: PathBuf,
186    asn: Option<PathBuf>,
187    stamp: Stamp,
188}
189
190/// The state of the country and ASN files that [`MaxMindIntel::reload_if_changed`] compares.
191type Stamp = (FileStamp, Option<FileStamp>);
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194struct FileStamp {
195    modified: Option<SystemTime>,
196    len: u64,
197}
198
199impl Files {
200    /// Reads the databases at `country` and `asn`. The files' state is taken before reading, so
201    /// a change during the read is caught by the next check instead of being missed.
202    fn read(country: PathBuf, asn: Option<PathBuf>) -> Result<(Databases, Self), MaxMindError> {
203        let stamp = stamp(&country, asn.as_deref())?;
204        let databases = Databases::read(&country, asn.as_deref())?;
205        Ok((databases, Self { country, asn, stamp }))
206    }
207}
208
209/// The anonymizer database's file and its state just before it was read.
210struct AnonymizerFile {
211    path: PathBuf,
212    stamp: FileStamp,
213}
214
215impl AnonymizerFile {
216    /// Like [`Files::read`], for the anonymizer database.
217    fn read(path: PathBuf) -> Result<(Reader<Vec<u8>>, Self), MaxMindError> {
218        let database = MaxMindDatabase::Anonymizer;
219        let stamp = file_stamp(database, &path)?;
220        let bytes = std::fs::read(&path).map_err(io_error(database, &path))?;
221        Ok((reader(database, bytes)?, Self { path, stamp }))
222    }
223}
224
225fn stamp(country: &Path, asn: Option<&Path>) -> Result<Stamp, MaxMindError> {
226    Ok((
227        file_stamp(MaxMindDatabase::Country, country)?,
228        asn.map(|path| file_stamp(MaxMindDatabase::Asn, path)).transpose()?,
229    ))
230}
231
232fn file_stamp(database: MaxMindDatabase, path: &Path) -> Result<FileStamp, MaxMindError> {
233    let metadata = std::fs::metadata(path).map_err(io_error(database, path))?;
234    Ok(FileStamp {
235        modified: metadata.modified().ok(),
236        len: metadata.len(),
237    })
238}
239
240fn io_error(database: MaxMindDatabase, path: &Path) -> impl FnOnce(std::io::Error) -> MaxMindError + '_ {
241    move |source| MaxMindError::Io {
242        database,
243        path: path.to_owned(),
244        source,
245    }
246}
247
248struct Databases {
249    country: Reader<Vec<u8>>,
250    asn: Option<Reader<Vec<u8>>>,
251}
252
253impl Databases {
254    fn read(country: &Path, asn: Option<impl AsRef<Path>>) -> Result<Self, MaxMindError> {
255        let read = |database, path: &Path| std::fs::read(path).map_err(io_error(database, path));
256        let country = read(MaxMindDatabase::Country, country)?;
257        let asn = asn.map(|path| read(MaxMindDatabase::Asn, path.as_ref())).transpose()?;
258        Self::parse(country, asn)
259    }
260
261    fn parse(country: Vec<u8>, asn: Option<Vec<u8>>) -> Result<Self, MaxMindError> {
262        Ok(Self {
263            country: reader(MaxMindDatabase::Country, country)?,
264            asn: asn.map(|bytes| reader(MaxMindDatabase::Asn, bytes)).transpose()?,
265        })
266    }
267}
268
269fn reader(database: MaxMindDatabase, bytes: Vec<u8>) -> Result<Reader<Vec<u8>>, MaxMindError> {
270    let reader = Reader::from_source(bytes).map_err(|source| MaxMindError::Invalid {
271        database,
272        source: Box::new(source),
273    })?;
274    let kind = reader.metadata().database_type.to_ascii_lowercase();
275    // A database of a kind we do not recognize gets the benefit of the doubt in every slot.
276    let kinds = [
277        (
278            MaxMindDatabase::Country,
279            kind.contains("country") || kind.contains("city"),
280        ),
281        (MaxMindDatabase::Asn, kind.contains("asn")),
282        (
283            MaxMindDatabase::Anonymizer,
284            kind.contains("anonymous") || kind.contains("ip-risk"),
285        ),
286    ];
287    let fits = kinds.iter().any(|&(slot, is)| is && slot == database) || kinds.iter().all(|&(_, is)| !is);
288    if !fits {
289        return Err(MaxMindError::WrongDatabase {
290            database,
291            found: reader.metadata().database_type.clone(),
292        });
293    }
294    Ok(reader)
295}
296
297impl MaxMindIntel {
298    /// Reads the country database (City or Country edition) and, optionally, the ASN database.
299    ///
300    /// # Errors
301    ///
302    /// Fails when a file cannot be read, is not a valid MMDB file, or is the wrong kind of
303    /// database.
304    pub fn open(country_db: impl AsRef<Path>, asn_db: Option<impl AsRef<Path>>) -> Result<Self, MaxMindError> {
305        let (databases, files) = Files::read(
306            country_db.as_ref().to_owned(),
307            asn_db.map(|path| path.as_ref().to_owned()),
308        )?;
309        Ok(Self {
310            databases: ArcSwap::from_pointee(databases),
311            anonymizer: ArcSwapOption::empty(),
312            files: Mutex::new(Watched {
313                geo: Some(files),
314                anonymizer: None,
315            }),
316        })
317    }
318
319    /// Like [`open`](Self::open), from database contents already in memory.
320    ///
321    /// # Errors
322    ///
323    /// Fails when the bytes are not a valid MMDB file or the wrong kind of database.
324    pub fn from_bytes(country_db: Vec<u8>, asn_db: Option<Vec<u8>>) -> Result<Self, MaxMindError> {
325        Ok(Self {
326            databases: ArcSwap::from_pointee(Databases::parse(country_db, asn_db)?),
327            anonymizer: ArcSwapOption::empty(),
328            files: Mutex::new(Watched::default()),
329        })
330    }
331
332    /// Adds an Anonymous IP, Anonymous Plus or IP Risk database, so lookups also report the
333    /// [`Anonymity`] of the network; see [Anonymizers](Self#anonymizers).
334    /// [`reload_if_changed`](Self::reload_if_changed) follows its file too.
335    ///
336    /// # Errors
337    ///
338    /// Fails when the file cannot be read, is not a valid MMDB file, or is the wrong kind of
339    /// database.
340    pub fn with_anonymizer(self, anonymizer_db: impl AsRef<Path>) -> Result<Self, MaxMindError> {
341        self.reload_anonymizer(anonymizer_db)?;
342        Ok(self)
343    }
344
345    /// Like [`with_anonymizer`](Self::with_anonymizer), from database contents already in
346    /// memory.
347    ///
348    /// # Errors
349    ///
350    /// Fails when the bytes are not a valid MMDB file or the wrong kind of database.
351    pub fn with_anonymizer_bytes(self, anonymizer_db: Vec<u8>) -> Result<Self, MaxMindError> {
352        self.reload_anonymizer_from_bytes(anonymizer_db)?;
353        Ok(self)
354    }
355
356    /// Replaces the country and ASN databases with freshly read files, which later
357    /// [`reload_if_changed`](Self::reload_if_changed) checks follow. In-flight lookups finish on
358    /// the old databases. On error the current databases and files stay in place. The
359    /// anonymizer database is left as it is.
360    ///
361    /// # Errors
362    ///
363    /// As for [`open`](Self::open).
364    pub fn reload(&self, country_db: impl AsRef<Path>, asn_db: Option<impl AsRef<Path>>) -> Result<(), MaxMindError> {
365        let mut files = self.lock_files();
366        let (databases, read) = Files::read(
367            country_db.as_ref().to_owned(),
368            asn_db.map(|path| path.as_ref().to_owned()),
369        )?;
370        self.databases.store(Arc::new(databases));
371        files.geo = Some(read);
372        Ok(())
373    }
374
375    /// Like [`reload`](Self::reload), from database contents already in memory. The databases
376    /// no longer come from files, so [`reload_if_changed`](Self::reload_if_changed) stops
377    /// checking them (but still checks the anonymizer database's file, if it has one).
378    ///
379    /// # Errors
380    ///
381    /// As for [`from_bytes`](Self::from_bytes).
382    pub fn reload_from_bytes(&self, country_db: Vec<u8>, asn_db: Option<Vec<u8>>) -> Result<(), MaxMindError> {
383        let mut files = self.lock_files();
384        self.databases.store(Arc::new(Databases::parse(country_db, asn_db)?));
385        files.geo = None;
386        Ok(())
387    }
388
389    /// Replaces (or adds) the anonymizer database with a freshly read file, which later
390    /// [`reload_if_changed`](Self::reload_if_changed) checks follow. On error the current
391    /// anonymizer database and file stay in place.
392    ///
393    /// # Errors
394    ///
395    /// As for [`with_anonymizer`](Self::with_anonymizer).
396    pub fn reload_anonymizer(&self, anonymizer_db: impl AsRef<Path>) -> Result<(), MaxMindError> {
397        let mut files = self.lock_files();
398        let (reader, read) = AnonymizerFile::read(anonymizer_db.as_ref().to_owned())?;
399        self.anonymizer.store(Some(Arc::new(reader)));
400        files.anonymizer = Some(read);
401        Ok(())
402    }
403
404    /// Like [`reload_anonymizer`](Self::reload_anonymizer), from database contents already in
405    /// memory. [`reload_if_changed`](Self::reload_if_changed) then stops checking the
406    /// anonymizer database.
407    ///
408    /// # Errors
409    ///
410    /// As for [`with_anonymizer_bytes`](Self::with_anonymizer_bytes).
411    pub fn reload_anonymizer_from_bytes(&self, anonymizer_db: Vec<u8>) -> Result<(), MaxMindError> {
412        let mut files = self.lock_files();
413        let reader = reader(MaxMindDatabase::Anonymizer, anonymizer_db)?;
414        self.anonymizer.store(Some(Arc::new(reader)));
415        files.anonymizer = None;
416        Ok(())
417    }
418
419    /// Reloads the databases if their files changed since they were last read, and returns
420    /// whether it did.
421    ///
422    /// The files are those given to [`open`](Self::open) or the latest
423    /// [`reload`](Self::reload), and to [`with_anonymizer`](Self::with_anonymizer) or the latest
424    /// [`reload_anonymizer`](Self::reload_anonymizer). Each is compared by modification time and
425    /// size, which costs a `stat` per file; unchanged files are not read. The country and ASN
426    /// databases are reloaded together, the anonymizer database on its own. Databases from
427    /// bytes have no files and are never reloaded here.
428    ///
429    /// This blocks while it reads and parses changed files (tens of megabytes for a City
430    /// database), so call it off the async runtime, or use [`spawn_reload`](Self::spawn_reload).
431    ///
432    /// # Errors
433    ///
434    /// As for [`open`](Self::open), for example when a file is missing, or only partly written
435    /// by a tool that does not replace it atomically. The databases that failed then stay in
436    /// place and the next call tries again; the others are still reloaded. With failures on both
437    /// sides, the country or ASN error is returned.
438    pub fn reload_if_changed(&self) -> Result<bool, MaxMindError> {
439        let mut files = self.lock_files();
440        let geo = self.reload_geo_if_changed(&mut files.geo);
441        let anonymizer = self.reload_anonymizer_if_changed(&mut files.anonymizer);
442        Ok(geo? | anonymizer?)
443    }
444
445    fn reload_geo_if_changed(&self, files: &mut Option<Files>) -> Result<bool, MaxMindError> {
446        let Some(current) = files.as_ref() else {
447            return Ok(false);
448        };
449        if stamp(&current.country, current.asn.as_deref())? == current.stamp {
450            return Ok(false);
451        }
452        let (databases, read) = Files::read(current.country.clone(), current.asn.clone())?;
453        self.databases.store(Arc::new(databases));
454        *files = Some(read);
455        Ok(true)
456    }
457
458    fn reload_anonymizer_if_changed(&self, file: &mut Option<AnonymizerFile>) -> Result<bool, MaxMindError> {
459        let Some(current) = file.as_ref() else {
460            return Ok(false);
461        };
462        if file_stamp(MaxMindDatabase::Anonymizer, &current.path)? == current.stamp {
463            return Ok(false);
464        }
465        let (reader, read) = AnonymizerFile::read(current.path.clone())?;
466        self.anonymizer.store(Some(Arc::new(reader)));
467        *file = Some(read);
468        Ok(true)
469    }
470
471    /// Runs [`reload_if_changed`](Self::reload_if_changed) now and then every `every` on the
472    /// tokio runtime, reading files on its blocking pool. Each reload is logged (`info`, with
473    /// the databases' build times) and each failure too (`warn`), with `tracing`. A failed check
474    /// does not stop the task and is retried on the next tick; abort the handle to stop it.
475    ///
476    /// `MaxMind` releases new databases at most twice a week, so an interval of minutes to an
477    /// hour is plenty, and a check of unchanged files is cheap.
478    ///
479    /// # Panics
480    ///
481    /// Outside a tokio runtime, or if `every` is zero.
482    #[cfg(feature = "tokio")]
483    #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
484    pub fn spawn_reload(self: &Arc<Self>, every: std::time::Duration) -> tokio::task::JoinHandle<()> {
485        let intel = Arc::clone(self);
486        tokio::spawn(async move {
487            let mut ticker = tokio::time::interval(every);
488            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
489            loop {
490                ticker.tick().await;
491                let check = Arc::clone(&intel);
492                match tokio::task::spawn_blocking(move || check.reload_if_changed()).await {
493                    Ok(Ok(true)) => {
494                        let databases = intel.databases.load();
495                        tracing::info!(
496                            country_built = databases.country.metadata().build_epoch,
497                            asn_built = databases.asn.as_ref().map(|reader| reader.metadata().build_epoch),
498                            anonymizer_built = intel
499                                .anonymizer
500                                .load()
501                                .as_ref()
502                                .map(|reader| reader.metadata().build_epoch),
503                            "reloaded MaxMind databases"
504                        );
505                    }
506                    Ok(Ok(false)) => {}
507                    Ok(Err(error)) => tracing::warn!(%error, "reloading MaxMind databases failed"),
508                    Err(error) => tracing::warn!(%error, "reloading MaxMind databases panicked"),
509                }
510            }
511        })
512    }
513
514    /// A reload that panicked left the previous state intact (it is only replaced on success),
515    /// so a poisoned lock is safe to reuse.
516    fn lock_files(&self) -> MutexGuard<'_, Watched> {
517        self.files.lock().unwrap_or_else(PoisonError::into_inner)
518    }
519}
520
521const COUNTRY: [PathElement<'static>; 2] = [PathElement::Key("country"), PathElement::Key("iso_code")];
522const REGISTERED_COUNTRY: [PathElement<'static>; 2] =
523    [PathElement::Key("registered_country"), PathElement::Key("iso_code")];
524const CITY: [PathElement<'static>; 3] = [
525    PathElement::Key("city"),
526    PathElement::Key("names"),
527    PathElement::Key("en"),
528];
529const ASN: [PathElement<'static>; 1] = [PathElement::Key("autonomous_system_number")];
530const NETWORK: [PathElement<'static>; 1] = [PathElement::Key("autonomous_system_organization")];
531
532/// Below this `anonymizer_confidence`, `is_anonymous_vpn` is ignored.
533const VPN_CONFIDENCE: u16 = 50;
534
535/// The fields shared by the Anonymous IP, Anonymous Plus and IP Risk databases. Flags are only
536/// present when true.
537#[derive(Default, Deserialize)]
538#[serde(default)]
539#[allow(clippy::struct_excessive_bools, reason = "mirrors the database's record")]
540struct AnonymizerRecord {
541    is_anonymous_vpn: bool,
542    is_hosting_provider: bool,
543    is_public_proxy: bool,
544    is_residential_proxy: bool,
545    is_tor_exit_node: bool,
546    anonymizer_confidence: Option<u16>,
547}
548
549impl AnonymizerRecord {
550    fn anonymity(&self) -> Anonymity {
551        if self.is_tor_exit_node {
552            Anonymity::TorExitNode
553        } else if self.is_public_proxy {
554            Anonymity::PublicProxy
555        } else if self.is_residential_proxy {
556            Anonymity::ResidentialProxy
557        } else if self.is_anonymous_vpn && self.anonymizer_confidence.is_none_or(|c| c >= VPN_CONFIDENCE) {
558            Anonymity::Vpn
559        } else if self.is_hosting_provider {
560            Anonymity::HostingProvider
561        } else {
562            Anonymity::Direct
563        }
564    }
565}
566
567impl IpIntel for MaxMindIntel {
568    fn lookup(&self, ip: IpAddr) -> IpInfo {
569        let ip = crate::client::canonical_ip(ip);
570        let databases = self.databases.load();
571        let geo = databases.country.lookup(ip).ok();
572        let country = geo.as_ref().and_then(|found| {
573            [&COUNTRY[..], &REGISTERED_COUNTRY[..]]
574                .into_iter()
575                .find_map(|path| found.decode_path::<&str>(path).ok().flatten())
576                .map(str::to_owned)
577        });
578        // Country editions have no `city`, so this is only found in City editions.
579        let city = geo.and_then(|found| found.decode_path::<String>(&CITY).ok().flatten());
580        let system = databases.asn.as_ref().and_then(|reader| reader.lookup(ip).ok());
581        let asn = system
582            .as_ref()
583            .and_then(|found| found.decode_path::<u32>(&ASN).ok().flatten());
584        let network = system.and_then(|found| found.decode_path::<String>(&NETWORK).ok().flatten());
585        // An address the database does not list is not known to be an anonymizer.
586        let anonymity = self.anonymizer.load().as_ref().and_then(|reader| {
587            let found = reader.lookup(ip).ok()?.decode::<AnonymizerRecord>().ok()?;
588            Some(found.map_or(Anonymity::Direct, |record| record.anonymity()))
589        });
590        IpInfo {
591            anonymity,
592            city,
593            network,
594            ..IpInfo::new(country, asn)
595        }
596    }
597}
598
599impl fmt::Debug for MaxMindIntel {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        let databases = self.databases.load();
602        let anonymizer = self.anonymizer.load();
603        let files = self.files.lock().unwrap_or_else(PoisonError::into_inner);
604        let geo = files.geo.as_ref();
605        f.debug_struct("MaxMindIntel")
606            .field("country", &databases.country.metadata().database_type)
607            .field("asn", &databases.asn.as_ref().map(|r| &r.metadata().database_type))
608            .field("anonymizer", &anonymizer.as_ref().map(|r| &r.metadata().database_type))
609            .field("country_file", &geo.map(|files| &files.country))
610            .field("asn_file", &geo.and_then(|files| files.asn.as_ref()))
611            .field("anonymizer_file", &files.anonymizer.as_ref().map(|file| &file.path))
612            .finish()
613    }
614}
615
616#[cfg(test)]
617#[path = "maxmind_tests.rs"]
618mod tests;