geoip: native maxmind .mmdb reader, G: geoban extban, GEOIP command, whois country
This commit is contained in:
parent
aa384d56f2
commit
7a99f49ca4
7 changed files with 406 additions and 2 deletions
|
|
@ -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..]));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
392
src/modules/geoip.rs
Normal file
392
src/modules/geoip.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
//! geoip — native MaxMind DB (`.mmdb`) country lookup, with the `G:<cc>` 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<Mmdb>);
|
||||
|
||||
/// A parsed `.mmdb` file: the raw bytes plus the tree geometry from its metadata.
|
||||
pub struct Mmdb {
|
||||
data: Vec<u8>,
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<u64> {
|
||||
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<usize> {
|
||||
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<Mmdb> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
// build the bit path; IPv4 in an IPv6 db is prefixed with 96 zero bits
|
||||
let mut bits: Vec<bool> = 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<String> {
|
||||
s.ext
|
||||
.get::<GeoDb>()
|
||||
.and_then(|db| db.0.country(ip))
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// The `G:<cc>` 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<String> {
|
||||
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<Box<dyn Command>> {
|
||||
vec![Box::new(GeoIpCmd)]
|
||||
}
|
||||
|
||||
/// GEOIP `<nick|ip>` — 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::<IpAddr>().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<Mmdb> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Box<dyn Command>> {
|
|||
.chain(tline::commands())
|
||||
.chain(rmode::commands())
|
||||
.chain(customtitle::commands())
|
||||
.chain(geoip::commands())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -585,7 +585,7 @@ impl Server {
|
|||
/// burst and the `ISUPPORT` command (draft/extended-isupport).
|
||||
pub fn isupport_lines(&self) -> Vec<String> {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue