From 068924de86e2089820b2ade3ff6c35156a3150f9 Mon Sep 17 00:00:00 2001 From: reverse Date: Fri, 28 Aug 2026 11:53:55 +0000 Subject: [PATCH] asn: core ASN lookup module (native MMDB); match connect classes + security groups on origin AS --- echoircd.conf.example | 6 ++-- src/modules/asn.rs | 64 +++++++++++++++++++++++++++++++++++ src/modules/connclass.rs | 22 ++++++++++-- src/modules/mod.rs | 1 + src/modules/securitygroups.rs | 7 +++- 5 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/modules/asn.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 3b82626..f74aa89 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -252,7 +252,7 @@ connections { # clients by IP/host mask (glob OR CIDR) + optional TLS/port; first match # wins, else the global limits apply. One quoted value string per class: # allow= deny=yes parent= requiressl=yes|trusted -# password= hash= port= localmax= globalmax= +# password= hash= port= asn= localmax= globalmax= # limit= maxchans= pingfreq= timeout= modes=<+modes> # recvq= softsendq= hardsendq= fakelag=no # penaltythreshold= commandrate= useident=yes requireident=yes @@ -262,6 +262,7 @@ connections { # connectclass "secure allow=* requiressl=yes password=sha256: hash=sha256"; # connectclass "vpn allow=* parent=trusted localmax=2 maxchans=20 modes=+ix"; # connectclass "banned allow=1.2.3.0/24 deny=yes"; +# connectclass "byasn allow=* asn=3215,16276"; # only these origin ASNs (needs geoip_asn_database) # connectclass_required yes; # refuse clients that match no allow class (default no) # } @@ -436,10 +437,11 @@ restrictions { # } # security groups — use as an extban: MODE #c +b g:. criteria: public tls # insecure account unregistered oper exclude-oper bot webirc mask= -# exclude= scoremin= scoremax=. +# exclude= scoremin= scoremax= asn= (asn needs geoip_asn_database). # securitygroups { # securitygroup "trusted account tls public"; # securitygroup "newbies scoremax=10 public"; +# securitygroup "myisp asn=3215 public"; # members whose origin AS is 3215 # } diff --git a/src/modules/asn.rs b/src/modules/asn.rs new file mode 100644 index 0000000..5a153e2 --- /dev/null +++ b/src/modules/asn.rs @@ -0,0 +1,64 @@ +//! ASN (autonomous system) as a core lookup, backed by the GeoLite2-ASN `.mmdb` the +//! geoip module loads (`geoip_asn_database`). Exposes `lookup`/`of` so any subsystem — +//! connect classes, security groups, extbans, WHOIS — can match a client on its origin +//! AS number, plus the `parse_list` config helper the matchers share. +//! +//! There's no separate database or `init` here: MaxMind ships ASN as its own db, which +//! geoip already parses with its hand-rolled MMDB reader; this module is the thin, +//! core-level seam other code calls, so ASN matching lives in one place. + +use std::net::IpAddr; + +use crate::server::Server; +use crate::Uid; + +/// The origin AS number for `ip`, from the loaded ASN database. `None` when no ASN db +/// is configured or the address has no record. +pub fn lookup(s: &Server, ip: IpAddr) -> Option { + crate::modules::geoip::asn(s, ip).map(|a| a.number) +} + +/// The AS number **and** organisation for `ip`, if available (for display / WHOIS). +pub fn full(s: &Server, ip: IpAddr) -> Option<(u32, String)> { + crate::modules::geoip::asn(s, ip).map(|a| (a.number, a.org)) +} + +/// The origin AS of user `uid`, resolved from its connecting IP. +pub fn of(s: &Server, uid: Uid) -> Option { + let ip = s.users.get(&uid)?.addr.ip(); + lookup(s, ip) +} + +/// Parse a config value — `3215,15169`, `AS3215 AS15169`, or a mix — into AS numbers. +/// A leading `AS`/`as` on a token is optional; unparseable tokens are dropped. +pub fn parse_list(v: &str) -> Vec { + v.split([',', ' ']) + .map(str::trim) + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let n = t.strip_prefix("AS").or_else(|| t.strip_prefix("as")).unwrap_or(t); + n.parse::().ok() + }) + .collect() +} + +/// Whether `uid`'s origin AS is one of `list`. An empty `list` is "no ASN constraint" +/// and never matches here — callers treat an empty list as "criterion absent". +pub fn user_in(s: &Server, uid: Uid, list: &[u32]) -> bool { + !list.is_empty() && of(s, uid).is_some_and(|a| list.contains(&a)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_list_forms() { + assert_eq!(parse_list("3215,15169"), vec![3215, 15169]); + assert_eq!(parse_list("AS3215 AS15169"), vec![3215, 15169]); + assert_eq!(parse_list(" as16276 , 3215 "), vec![16276, 3215]); + assert_eq!(parse_list("3215,,bogus,15169"), vec![3215, 15169]); + assert!(parse_list("").is_empty()); + assert!(parse_list("notanumber").is_empty()); + } +} diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs index f36de1d..300932c 100644 --- a/src/modules/connclass.rs +++ b/src/modules/connclass.rs @@ -7,7 +7,7 @@ //! //! ```text //! connectclass = allow= [parent=] [deny=yes] -//! [requiressl=yes|trusted] [password=] [hash=] [port=] +//! [requiressl=yes|trusted] [password=] [hash=] [port=] [asn=] //! [localmax=] [globalmax=] [limit=] [maxchans=] [pingfreq=] //! [timeout=] [modes=<+modes>] [recvq=] [hardsendq=] //! [softsendq=] [fakelag=yes|no] [penaltythreshold=] [commandrate=] @@ -35,6 +35,7 @@ pub struct ConnClass { pub ssl_trusted: bool, // require a TLS client certificate (requiressl=trusted) pub password: Option, // PASS credential (plain or hashed; verify auto-detects) pub ports: Vec, // restrict to these listener ports (empty = any) + pub asn: Vec, // restrict to these origin AS numbers (empty = any) pub localmax: Option, // max local connections per IP in this class pub globalmax: Option, // max network-wide connections per IP pub limit: Option, // max total local users in this class @@ -75,6 +76,7 @@ fn apply(c: &mut ConnClass, k: &str, v: &str) { // regardless of whether it appears before or after `password=`. "hash" => {} "port" => c.ports.extend(list(v).filter_map(|p| p.parse::().ok())), + "asn" => c.asn.extend(crate::modules::asn::parse_list(v)), "localmax" => c.localmax = v.parse().ok(), "globalmax" => c.globalmax = v.parse().ok(), "limit" => c.limit = v.parse().ok(), @@ -269,6 +271,7 @@ fn pick( secure: bool, has_cert: bool, port: u16, + asn: Option, ) -> Pick { for c in all(s) { if !c.allow.iter().any(|m| mask_match(m, ip, host)) { @@ -283,6 +286,9 @@ fn pick( if !c.ports.is_empty() && !c.ports.contains(&port) { continue; } + if !c.asn.is_empty() && !asn.is_some_and(|a| c.asn.contains(&a)) { + continue; + } if c.deny { return Pick::Deny(c.name); } @@ -343,7 +349,8 @@ pub fn assign(s: &mut Server, uid: Uid) -> Option { u.port, ) }; - let class = match pick(s, uid, &ip, "", secure, has_cert, port) { + let asn = crate::modules::asn::of(s, uid); + let class = match pick(s, uid, &ip, "", secure, has_cert, port, asn) { Pick::Deny(name) => { return Some(format!("Connection class {name} denies your address")); } @@ -409,7 +416,8 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome { }) else { return AuthOutcome::Proceed; }; - match pick(s, uid, &ip, &host, secure, has_cert, port) { + let asn = crate::modules::asn::of(s, uid); + match pick(s, uid, &ip, &host, secure, has_cert, port, asn) { Pick::Deny(name) => { return AuthOutcome::Reject(format!("Connection class {name} denies your address")); } @@ -558,4 +566,12 @@ mod tests { assert!(mask_match("192.0.2.*", "192.0.2.7", "")); assert!(!mask_match("nomatch/33", "1.2.3.4", "")); // unparseable → no match } + + #[test] + fn asn_param_parses() { + let mut c = ConnClass::default(); + apply(&mut c, "asn", "3215,15169"); + apply(&mut c, "asn", "AS16276"); + assert_eq!(c.asn, vec![3215, 15169, 16276]); + } } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 7277f53..229600a 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -7,6 +7,7 @@ pub mod account_registration; pub mod accountban; pub mod antimixedutf8; pub mod antirandom; +pub mod asn; pub mod autodrop; pub mod autoop; pub mod banredirect; diff --git a/src/modules/securitygroups.rs b/src/modules/securitygroups.rs index 39cd54f..8640ff6 100644 --- a/src/modules/securitygroups.rs +++ b/src/modules/securitygroups.rs @@ -1,5 +1,5 @@ //! Named security groups. A `securitygroup` config line defines a named set of users -//! by AND-ed criteria (host masks, TLS, account, oper, bot, webirc, reputation score +//! by AND-ed criteria (host masks, TLS, account, oper, bot, webirc, origin ASN, reputation score //! range). Groups drive the `g:` matching extban, the `SECURITYGROUPS` command, and a //! WHOIS line. @@ -32,6 +32,7 @@ struct SecGroup { webirc: Tri, score_min: Option, score_max: Option, + asn: Vec, } /// Parse the `securitygroup = [criteria…]` config lines into groups. @@ -103,6 +104,7 @@ fn parse_groups(s: &Server) -> Vec { ("exclude-webirc", _) => g.webirc = Tri::No, ("scoremin", Some(n)) => g.score_min = n.parse().ok(), ("scoremax", Some(n)) => g.score_max = n.parse().ok(), + ("asn", Some(a)) => g.asn.extend(crate::modules::asn::parse_list(a)), _ => {} } } @@ -159,6 +161,9 @@ fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool { return false; } } + if !g.asn.is_empty() && !crate::modules::asn::user_in(s, uid, &g.asn) { + return false; + } true }