fail2ban-rs 1.2.1

A pure-Rust fail2ban replacement. Single static binary, fast two-phase matching, nftables/iptables firewall backends.
Documentation
//! MaxMind GeoIP enrichment for ban events.
//!
//! Owns the memory-mapped database readers and per-jail field configuration.
//! Created at tracker startup via [`MaxmindState::load`], updated on
//! hot-reload via [`MaxmindState::reload`].

use std::collections::HashMap;
use std::net::IpAddr;
use std::path::Path;

use maxminddb::geoip2;
use tracing::{error, info, warn};

use crate::config::{GlobalConfig, JailConfig, MaxmindField};
use crate::detect::watcher::Failure;

/// GeoIP enrichment results from MaxMind lookups.
#[derive(Debug, Default)]
pub struct MaxmindEnrichment {
    /// Autonomous system number and organization.
    pub asn: Option<String>,
    /// Country name (English).
    pub country: Option<String>,
    /// City name (English).
    pub city: Option<String>,
}

impl MaxmindEnrichment {
    /// True when at least one field was populated.
    pub fn has_data(&self) -> bool {
        self.asn.is_some() || self.country.is_some() || self.city.is_some()
    }
}

/// Consolidated MaxMind GeoIP state.
pub struct MaxmindState {
    asn: Option<maxminddb::Reader<maxminddb::Mmap>>,
    country: Option<maxminddb::Reader<maxminddb::Mmap>>,
    city: Option<maxminddb::Reader<maxminddb::Mmap>>,
    jail_fields: HashMap<String, Vec<MaxmindField>>,
}

impl MaxmindState {
    /// Load databases from global config, cache per-jail field lists.
    pub fn load(global: &GlobalConfig, jails: &HashMap<String, JailConfig>) -> Self {
        Self {
            asn: global
                .maxmind_asn
                .as_deref()
                .and_then(|p| load_db(p, "ASN")),
            country: global
                .maxmind_country
                .as_deref()
                .and_then(|p| load_db(p, "Country")),
            city: global
                .maxmind_city
                .as_deref()
                .and_then(|p| load_db(p, "City")),
            jail_fields: jails
                .iter()
                .map(|(k, v)| (k.clone(), v.maxmind.clone()))
                .collect(),
        }
    }

    /// Hot-reload: re-open databases and re-cache jail fields.
    pub fn reload(&mut self, global: &GlobalConfig, jails: &HashMap<String, JailConfig>) {
        self.asn = global
            .maxmind_asn
            .as_deref()
            .and_then(|p| load_db(p, "ASN"));
        self.country = global
            .maxmind_country
            .as_deref()
            .and_then(|p| load_db(p, "Country"));
        self.city = global
            .maxmind_city
            .as_deref()
            .and_then(|p| load_db(p, "City"));
        self.jail_fields = jails
            .iter()
            .map(|(k, v)| (k.clone(), v.maxmind.clone()))
            .collect();
    }

    /// Lookup enrichment for an IP based on the jail's configured fields.
    pub fn enrich(&self, ip: IpAddr, jail_id: &str) -> MaxmindEnrichment {
        let fields = match self.jail_fields.get(jail_id) {
            Some(f) => f.as_slice(),
            None => return MaxmindEnrichment::default(),
        };
        let mut result = MaxmindEnrichment::default();
        for field in fields {
            match field {
                MaxmindField::Asn => result.asn = lookup_asn(ip, self.asn.as_ref()),
                MaxmindField::Country => result.country = lookup_country(ip, self.country.as_ref()),
                MaxmindField::City => result.city = lookup_city(ip, self.city.as_ref()),
            }
        }
        result
    }
}

/// Load a MaxMind database from disk via memory-mapped I/O.
pub fn load_db(path: &Path, label: &str) -> Option<maxminddb::Reader<maxminddb::Mmap>> {
    let meta = match std::fs::metadata(path) {
        Ok(m) => m,
        Err(e) => {
            warn!(path = %path.display(), db = label, error = %e, "cannot stat MaxMind database");
            return None;
        }
    };
    if !meta.is_file() {
        warn!(path = %path.display(), db = label, "MaxMind path is not a regular file");
        return None;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if meta.permissions().mode() & 0o002 != 0 {
            error!(
                path = %path.display(), db = label,
                "refusing to load world-writable MaxMind database — fix permissions with chmod o-w"
            );
            return None;
        }
    }

    // SAFETY: The .mmdb file must not be modified, truncated, or replaced
    // non-atomically while this Reader (and its Mmap) exists. Operators must
    // use atomic file replacement (write to temp file + rename) when updating
    // MaxMind databases — the standard practice for geoipupdate.
    #[allow(unsafe_code)]
    let reader_result = unsafe { maxminddb::Reader::open_mmap(path) };
    match reader_result {
        Ok(reader) => {
            info!(path = %path.display(), db = label, "MaxMind database loaded");
            Some(reader)
        }
        Err(e) => {
            warn!(path = %path.display(), db = label, error = %e, "failed to load MaxMind database");
            None
        }
    }
}

/// Log a ban event, including MaxMind fields only when enrichment data exists.
pub fn log_ban_event(
    failure: &Failure,
    ban_time: i64,
    ban_count: u32,
    enrichment: &MaxmindEnrichment,
) {
    if enrichment.has_data() {
        info!(
            ip = %failure.ip,
            jail = %failure.jail_id,
            maxmind_asn = enrichment.asn,
            maxmind_country = enrichment.country,
            maxmind_city = enrichment.city,
            ban_time,
            ban_count,
            "threshold reached, banning"
        );
    } else {
        info!(
            ip = %failure.ip,
            jail = %failure.jail_id,
            ban_time,
            ban_count,
            "threshold reached, banning"
        );
    }
}

fn lookup_asn(ip: IpAddr, reader: Option<&maxminddb::Reader<maxminddb::Mmap>>) -> Option<String> {
    let reader = reader?;
    let result = reader.lookup(ip).ok()?;
    let record = result.decode::<geoip2::Asn>().ok()??;
    match (
        record.autonomous_system_number,
        record.autonomous_system_organization,
    ) {
        (Some(num), Some(org)) => Some(format!("AS{num} ({org})")),
        (Some(num), None) => Some(format!("AS{num}")),
        (None, Some(org)) => Some(org.to_string()),
        (None, None) => None,
    }
}

fn lookup_country(
    ip: IpAddr,
    reader: Option<&maxminddb::Reader<maxminddb::Mmap>>,
) -> Option<String> {
    let reader = reader?;
    let result = reader.lookup(ip).ok()?;
    let record = result.decode::<geoip2::Country>().ok()??;
    record
        .country
        .names
        .english
        .map(std::string::ToString::to_string)
}

fn lookup_city(ip: IpAddr, reader: Option<&maxminddb::Reader<maxminddb::Mmap>>) -> Option<String> {
    let reader = reader?;
    let result = reader.lookup(ip).ok()?;
    let record = result.decode::<geoip2::City>().ok()??;
    record
        .city
        .names
        .english
        .map(std::string::ToString::to_string)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::track::maxmind::load_db;

    #[test]
    fn test_load_db_missing_file_returns_none() {
        let result = load_db(Path::new("/nonexistent/path/GeoLite2-ASN.mmdb"), "ASN");
        assert!(result.is_none(), "missing file should return None");
    }

    #[test]
    fn test_load_db_directory_returns_none() {
        let dir = tempfile::tempdir().expect("failed to create tempdir");
        let result = load_db(dir.path(), "ASN");
        assert!(result.is_none(), "directory path should return None");
    }

    #[cfg(unix)]
    #[test]
    fn test_load_db_world_writable_returns_none() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("failed to create tempdir");
        let path = dir.path().join("test.mmdb");
        std::fs::write(&path, b"").expect("failed to create temp file");

        let mut perms = std::fs::metadata(&path)
            .expect("failed to stat temp file")
            .permissions();
        perms.set_mode(0o666); // world-writable
        std::fs::set_permissions(&path, perms).expect("failed to set permissions");

        let result = load_db(&path, "ASN");
        assert!(
            result.is_none(),
            "world-writable .mmdb file must be rejected"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_load_db_valid_fixture_loads() {
        let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/GeoLite2-ASN-Test.mmdb");
        let result = load_db(&fixture, "ASN");
        assert!(
            result.is_some(),
            "valid, normally-permissioned fixture should load successfully"
        );
    }
}