asn: core ASN lookup module (native MMDB); match connect classes + security groups on origin AS

This commit is contained in:
Jean Chevronnet 2026-08-28 11:53:55 +00:00
parent cc13ce02c7
commit 068924de86
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 94 additions and 6 deletions

View file

@ -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=<mask[,mask]> deny=yes parent=<name> requiressl=yes|trusted
# password=<pw> hash=<algo> port=<p[,p]> localmax=<n> globalmax=<n>
# password=<pw> hash=<algo> port=<p[,p]> asn=<n[,n]> localmax=<n> globalmax=<n>
# limit=<n> maxchans=<n> pingfreq=<s> timeout=<s> modes=<+modes>
# recvq=<bytes> softsendq=<bytes> hardsendq=<bytes> fakelag=no
# penaltythreshold=<n> commandrate=<s> useident=yes requireident=yes
@ -262,6 +262,7 @@ connections {
# connectclass "secure allow=* requiressl=yes password=sha256:<hex> 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:<name>. criteria: public tls
# insecure account unregistered oper exclude-oper bot webirc mask=<glob>
# exclude=<glob> scoremin=<n> scoremax=<n>.
# exclude=<glob> scoremin=<n> scoremax=<n> asn=<n[,n]> (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
# }

64
src/modules/asn.rs Normal file
View file

@ -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<u32> {
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<u32> {
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<u32> {
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::<u32>().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());
}
}

View file

@ -7,7 +7,7 @@
//!
//! ```text
//! connectclass = <name> allow=<mask[,mask]> [parent=<name>] [deny=yes]
//! [requiressl=yes|trusted] [password=<pw>] [hash=<algo>] [port=<p[,p]>]
//! [requiressl=yes|trusted] [password=<pw>] [hash=<algo>] [port=<p[,p]>] [asn=<n[,n]>]
//! [localmax=<n>] [globalmax=<n>] [limit=<n>] [maxchans=<n>] [pingfreq=<secs>]
//! [timeout=<secs>] [modes=<+modes>] [recvq=<bytes>] [hardsendq=<bytes>]
//! [softsendq=<bytes>] [fakelag=yes|no] [penaltythreshold=<n>] [commandrate=<secs>]
@ -35,6 +35,7 @@ pub struct ConnClass {
pub ssl_trusted: bool, // require a TLS client certificate (requiressl=trusted)
pub password: Option<String>, // PASS credential (plain or hashed; verify auto-detects)
pub ports: Vec<u16>, // restrict to these listener ports (empty = any)
pub asn: Vec<u32>, // restrict to these origin AS numbers (empty = any)
pub localmax: Option<usize>, // max local connections per IP in this class
pub globalmax: Option<usize>, // max network-wide connections per IP
pub limit: Option<usize>, // 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::<u16>().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<u32>,
) -> 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<String> {
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]);
}
}

View file

@ -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;

View file

@ -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<u32>,
score_max: Option<u32>,
asn: Vec<u32>,
}
/// Parse the `securitygroup = <name> [criteria…]` config lines into groups.
@ -103,6 +104,7 @@ fn parse_groups(s: &Server) -> Vec<SecGroup> {
("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
}