From 7a99f49ca4828e80ab0a90a8fd0c1af3491d709e Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 21:43:57 +0000 Subject: [PATCH] geoip: native maxmind .mmdb reader, G: geoban extban, GEOIP command, whois country --- echoircd.conf.example | 4 + src/channels.rs | 3 +- src/coremods/core_info.rs | 4 + src/ircd.rs | 1 + src/modules/geoip.rs | 392 ++++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 2 + src/server.rs | 2 +- 7 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 src/modules/geoip.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 09d0099..14bd97b 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -117,3 +117,7 @@ amu_target = both # hidewhois_hide_server = yes # hide 312 # hidewhois_hide_idle = yes # hide 317 # hidewhois_hide_secure = yes # hide 671 +# --- geoip (m_geo_maxmind): native MaxMind .mmdb country lookup. Enables the +# G: ban extban (e.g. +b G:CN,RU), the oper GEOIP command and +# a country line in WHOIS (opers). Point at a GeoLite2-Country.mmdb file: +# geoip_database = /etc/echoircd/GeoLite2-Country.mmdb diff --git a/src/channels.rs b/src/channels.rs index f2f295b..6f7de3e 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -921,6 +921,7 @@ impl Server { Some(b'r') => crate::modules::realnameban::matches(self, uid, &b.mask[2..]), Some(b'j') => crate::modules::channelban::matches(self, uid, &b.mask[2..]), Some(b's') => crate::modules::serverban::matches(self, uid, &b.mask[2..]), + Some(b'G') => crate::modules::geoip::geoban_match(self, uid, &b.mask[2..]), _ => false, } } else { @@ -1132,7 +1133,7 @@ pub fn normalize_ban_mask(m: &str) -> String { // These extbans carry a name / spec / channel / server, not a host mask, // so they must not be host-normalised: g: (security group), y: (reputation // score), r: (realname), j: (channel), s: (server name). - if matches!(b[0], b'g' | b'y' | b'r' | b'j' | b's') { + if matches!(b[0], b'g' | b'y' | b'r' | b'j' | b's' | b'G') { return m.to_string(); } return format!("{}:{}", &m[..1], normalize_mask(&m[2..])); diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index b8d5af4..ffb734a 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -262,6 +262,10 @@ impl Command for Whois { if let Some(line) = crate::modules::whoisport::line(s, tuid) { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } + // geoip: the country the user connects from — opers only + if let Some(line) = crate::modules::geoip::whois_line(s, tuid) { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); + } } // opers can see through the cloak to the real host/ip if asker_oper && disp != realhost { diff --git a/src/ircd.rs b/src/ircd.rs index 4a49860..5fd1dac 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -95,6 +95,7 @@ impl Ircd { server.load_xlines(); // restore persisted bans (m_xline_db) crate::modules::metadata::load(&mut server); // restore channel metadata (m_metadata_db) crate::modules::reputation::load(&mut server); // restore per-IP reputation + crate::modules::geoip::init(&mut server); // load the GeoIP database (m_geo_maxmind) Ircd { server, commands: command_table(), diff --git a/src/modules/geoip.rs b/src/modules/geoip.rs new file mode 100644 index 0000000..a66cbf7 --- /dev/null +++ b/src/modules/geoip.rs @@ -0,0 +1,392 @@ +//! geoip — native MaxMind DB (`.mmdb`) country lookup, with the `G:` geoban +//! extban, the `GEOIP` command and a WHOIS country line. The `maxminddb` crate is +//! off-limits (openssl+mio only), so the binary format is parsed by hand in pure +//! std: the metadata section, the record-size-aware search tree, and the typed data +//! decoder — no crate, no `unsafe`, no C FFI. +//! +//! Config: `geoip_database = /path/to/GeoLite2-Country.mmdb` (loaded once at boot). +//! +//! Behaviour reference: InspIRCd's `m_geo_maxmind` + `m_geoban` + `m_geocmd`. +//! Original native Rust. + +use std::net::IpAddr; +use std::sync::Arc; + +use crate::command::{CmdResult, Command}; +use crate::numeric::ERR_NOPRIVILEGES; +use crate::server::Server; +use crate::Uid; + +const MARKER: &[u8] = b"\xab\xcd\xefMaxMind.com"; +const SEPARATOR: usize = 16; + +/// A loaded MaxMind DB, cached in `Server.ext`. +pub struct GeoDb(pub Arc); + +/// A parsed `.mmdb` file: the raw bytes plus the tree geometry from its metadata. +pub struct Mmdb { + data: Vec, + node_count: usize, + node_byte_size: usize, // record_size * 2 / 8 + record_size: u32, + data_start: usize, // node_count*node_byte_size + 16 (first byte after the separator) + ip_version: u16, +} + +/// Big-endian value of up to 8 bytes. +fn be(bytes: &[u8]) -> u64 { + bytes.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64) +} + +/// A cursor over the data section for pointer-aware decoding. `base` is the offset +/// pointers are relative to (the start of the section being decoded). +struct Decoder<'a> { + data: &'a [u8], + base: usize, +} + +impl<'a> Decoder<'a> { + /// Parse the control byte at `off`: returns `(type, size, payload_offset)`. + /// Not used for pointers (they encode size differently — see `resolve`). + fn control(&self, off: usize) -> Option<(u8, usize, usize)> { + let b = *self.data.get(off)?; + let mut typ = b >> 5; + let mut o = off + 1; + if typ == 0 { + typ = 7 + *self.data.get(o)?; + o += 1; + } + let mut size = (b & 0x1f) as usize; + if size >= 29 { + match size { + 29 => { + size = 29 + *self.data.get(o)? as usize; + o += 1; + } + 30 => { + size = 285 + be(self.data.get(o..o + 2)?) as usize; + o += 2; + } + _ => { + size = 65821 + be(self.data.get(o..o + 3)?) as usize; + o += 3; + } + } + } + Some((typ, size, o)) + } + + /// Follow pointer(s) at `off` to the concrete value offset (non-pointers pass + /// through). Depth-limited against malformed data. + fn resolve(&self, off: usize, depth: u8) -> Option { + if depth > 8 { + return None; + } + let b = *self.data.get(off)?; + if b >> 5 != 1 { + return Some(off); + } + let ps = ((b >> 3) & 0x3) as usize; + let v0 = (b & 0x7) as usize; + let ptr = match ps { + 0 => (v0 << 8) | *self.data.get(off + 1)? as usize, + 1 => ((v0 << 16) | be(self.data.get(off + 1..off + 3)?) as usize) + 2048, + 2 => ((v0 << 24) | be(self.data.get(off + 1..off + 4)?) as usize) + 526336, + _ => be(self.data.get(off + 1..off + 5)?) as usize, + }; + self.resolve(self.base + ptr, depth + 1) + } + + /// The number of bytes the value at `off` occupies (pointers are 2–5 bytes; not + /// followed). Recurses into maps/arrays. Returns 0 on malformed input. + fn value_len(&self, off: usize) -> usize { + let b = match self.data.get(off) { + Some(&b) => b, + None => return 0, + }; + if b >> 5 == 1 { + return 1 + ((b >> 3) & 0x3) as usize + 1; // 2..=5 bytes + } + let Some((typ, size, payload)) = self.control(off) else { + return 0; + }; + let header = payload - off; + match typ { + 7 => { + // map: `size` key/value pairs + let mut cur = payload; + for _ in 0..size { + cur += self.value_len(cur); // key + cur += self.value_len(cur); // value + } + cur - off + } + 11 => { + // array: `size` elements + let mut cur = payload; + for _ in 0..size { + cur += self.value_len(cur); + } + cur - off + } + 14 => header, // bool: value is in the size field, no payload + _ => header + size, // string/bytes/ints/float/double + } + } + + /// Read a UTF-8 string at `off` (following pointers). + fn string(&self, off: usize) -> Option { + let off = self.resolve(off, 0)?; + let (typ, size, payload) = self.control(off)?; + if typ != 2 { + return None; + } + std::str::from_utf8(self.data.get(payload..payload + size)?) + .ok() + .map(str::to_string) + } + + /// Read an unsigned integer at `off` (following pointers). + fn uint(&self, off: usize) -> Option { + let off = self.resolve(off, 0)?; + let (typ, size, payload) = self.control(off)?; + if !matches!(typ, 5 | 6 | 9 | 10) { + return None; + } + Some(be(self.data.get(payload..payload + size)?)) + } + + /// The value offset for `key` in the map at `off` (following pointers). + fn map_get(&self, off: usize, key: &str) -> Option { + let off = self.resolve(off, 0)?; + let (typ, size, payload) = self.control(off)?; + if typ != 7 { + return None; + } + let mut cur = payload; + for _ in 0..size { + let k = self.string(cur)?; + cur += self.value_len(cur); + if k == key { + return Some(cur); + } + cur += self.value_len(cur); + } + None + } +} + +impl Mmdb { + /// Load and parse an `.mmdb` file. + pub fn open(path: &str) -> Option { + let data = std::fs::read(path).ok()?; + let marker = data.windows(MARKER.len()).rposition(|w| w == MARKER)?; + let meta = marker + MARKER.len(); + let d = Decoder { data: &data, base: meta }; + let node_count = d.uint(d.map_get(meta, "node_count")?)? as usize; + let record_size = d.uint(d.map_get(meta, "record_size")?)? as u32; + let ip_version = d.uint(d.map_get(meta, "ip_version")?)? as u16; + if !matches!(record_size, 24 | 28 | 32) || node_count == 0 { + return None; + } + let node_byte_size = record_size as usize * 2 / 8; + let data_start = node_count * node_byte_size + SEPARATOR; + Some(Mmdb { + data, + node_count, + node_byte_size, + record_size, + data_start, + ip_version, + }) + } + + /// The left (bit=false) or right (bit=true) record of tree `node`. + fn record(&self, node: usize, bit: bool) -> Option { + let base = node * self.node_byte_size; + match self.record_size { + 24 => { + let o = base + if bit { 3 } else { 0 }; + Some(be(self.data.get(o..o + 3)?) as usize) + } + 28 => { + let mid = *self.data.get(base + 3)?; + if bit { + Some(((mid as usize & 0x0f) << 24) | be(self.data.get(base + 4..base + 7)?) as usize) + } else { + Some(((mid as usize >> 4) << 24) | be(self.data.get(base..base + 3)?) as usize) + } + } + _ => { + let o = base + if bit { 4 } else { 0 }; + Some(be(self.data.get(o..o + 4)?) as usize) + } + } + } + + /// The 2-letter ISO country code for `ip`, if the database has one. + pub fn country(&self, ip: IpAddr) -> Option { + // build the bit path; IPv4 in an IPv6 db is prefixed with 96 zero bits + let mut bits: Vec = Vec::with_capacity(128); + match ip { + IpAddr::V4(a) => { + if self.ip_version == 6 { + bits.extend(std::iter::repeat(false).take(96)); + } + for byte in a.octets() { + for i in (0..8).rev() { + bits.push((byte >> i) & 1 == 1); + } + } + } + IpAddr::V6(a) => { + for byte in a.octets() { + for i in (0..8).rev() { + bits.push((byte >> i) & 1 == 1); + } + } + } + } + let mut node = 0usize; + for bit in bits { + if node >= self.node_count { + return None; + } + let rec = self.record(node, bit)?; + if rec == self.node_count { + return None; // no data + } + if rec > self.node_count { + // data pointer: abs = tree_size + (rec - node_count) + let abs = (self.data_start - SEPARATOR) + rec - self.node_count; + let d = Decoder { + data: &self.data, + base: self.data_start, + }; + let country = d.map_get(abs, "country")?; + return d.string(d.map_get(country, "iso_code")?); + } + node = rec; + } + None + } +} + +/// Load the configured database into `Server.ext` at boot. Called from `Ircd::new`. +pub fn init(s: &mut Server) { + let Some(path) = s.conf("geoip_database").map(str::to_string) else { + return; + }; + match Mmdb::open(&path) { + Some(db) => { + s.ext.set(GeoDb(Arc::new(db))); + eprintln!("echoircd: loaded GeoIP database {path}"); + } + None => eprintln!("echoircd: could not read GeoIP database {path}"), + } +} + +/// The ISO country code of `ip` per the loaded database, uppercased. +pub fn country_of(s: &Server, ip: IpAddr) -> Option { + s.ext + .get::() + .and_then(|db| db.0.country(ip)) + .map(|c| c.to_ascii_uppercase()) +} + +/// The `G:` geoban match: does `uid`'s country equal (case-insensitively) the +/// country code in the extban? Dispatched from `Server::ban_list_hit`. +pub fn geoban_match(s: &Server, uid: Uid, spec: &str) -> bool { + let Some(ip) = s.users.get(&uid).map(|u| u.addr.ip()) else { + return false; + }; + match country_of(s, ip) { + Some(cc) => spec + .split(',') + .any(|want| want.trim().eq_ignore_ascii_case(&cc)), + None => false, + } +} + +/// A WHOIS line (opers only) showing the target's country. +pub fn whois_line(s: &Server, tuid: Uid) -> Option { + let ip = s.users.get(&tuid).map(|u| u.addr.ip())?; + country_of(s, ip).map(|cc| format!("is connecting from country {cc}")) +} + +pub fn commands() -> Vec> { + vec![Box::new(GeoIpCmd)] +} + +/// GEOIP `` — oper command reporting the country of a user or raw IP. +struct GeoIpCmd; +impl Command for GeoIpCmd { + fn name(&self) -> &'static str { + "GEOIP" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let target = ¶ms[0]; + let ip = s + .find_nick(target) + .and_then(|t| s.users.get(&t)) + .map(|u| u.addr.ip()) + .or_else(|| target.parse::().ok()); + let nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); + let msg = match ip { + None => format!("GEOIP: no such nick, and {target} is not an IP"), + Some(ip) => match country_of(s, ip) { + Some(cc) => format!("GEOIP: {target} ({ip}) is in country {cc}"), + None => format!("GEOIP: no country found for {target} ({ip})"), + }, + }; + s.send(uid, format!(":{} NOTICE {nick} :*** {msg}", s.name)); + CmdResult::Ok + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + // A real GeoLite2-Country.mmdb if one is present; otherwise the test no-ops so + // CI (which has no database) stays green. + const DB_CANDIDATES: &[&str] = &[ + "/home/debian/irc/ircd/inspircd/run/conf/geodata/GeoLite2-Country.mmdb", + "/usr/share/GeoIP/GeoLite2-Country.mmdb", + ]; + + fn load() -> Option { + DB_CANDIDATES.iter().find_map(|p| Mmdb::open(p)) + } + + #[test] + fn known_ips_resolve() { + let Some(db) = load() else { + return; + }; + // 8.8.8.8 (Google DNS) is US in every GeoLite2 vintage. + assert_eq!( + db.country(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))).as_deref(), + Some("US") + ); + // A private address has no country record. + assert_eq!(db.country(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), None); + // IPv6 traversal (Google public DNS) also resolves to US. + assert_eq!( + db.country("2001:4860:4860::8888".parse().unwrap()).as_deref(), + Some("US") + ); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 782625a..4dac687 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -23,6 +23,7 @@ pub mod extjwt; pub mod filehost; pub mod filter; pub mod flood; +pub mod geoip; pub mod hashident; pub mod hidewhois; pub mod irccloudtags; @@ -113,5 +114,6 @@ pub fn module_commands() -> Vec> { .chain(tline::commands()) .chain(rmode::commands()) .chain(customtitle::commands()) + .chain(geoip::commands()) .collect() } diff --git a/src/server.rs b/src/server.rs index bec0908..cc965e7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -585,7 +585,7 @@ impl Server { /// burst and the `ISUPPORT` command (draft/extended-isupport). pub fn isupport_lines(&self) -> Vec { let mut lines = vec![format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cgjmnrsy WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={}", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,Gcgjmnrsy WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={}", self.network )]; if let Some(tok) = crate::modules::network_icon::isupport(self) {