asn: core ASN lookup module (native MMDB); match connect classes + security groups on origin AS
This commit is contained in:
parent
cc13ce02c7
commit
068924de86
5 changed files with 94 additions and 6 deletions
|
|
@ -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
64
src/modules/asn.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue