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
|
# 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:
|
# wins, else the global limits apply. One quoted value string per class:
|
||||||
# allow=<mask[,mask]> deny=yes parent=<name> requiressl=yes|trusted
|
# 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>
|
# limit=<n> maxchans=<n> pingfreq=<s> timeout=<s> modes=<+modes>
|
||||||
# recvq=<bytes> softsendq=<bytes> hardsendq=<bytes> fakelag=no
|
# recvq=<bytes> softsendq=<bytes> hardsendq=<bytes> fakelag=no
|
||||||
# penaltythreshold=<n> commandrate=<s> useident=yes requireident=yes
|
# penaltythreshold=<n> commandrate=<s> useident=yes requireident=yes
|
||||||
|
|
@ -262,6 +262,7 @@ connections {
|
||||||
# connectclass "secure allow=* requiressl=yes password=sha256:<hex> hash=sha256";
|
# connectclass "secure allow=* requiressl=yes password=sha256:<hex> hash=sha256";
|
||||||
# connectclass "vpn allow=* parent=trusted localmax=2 maxchans=20 modes=+ix";
|
# connectclass "vpn allow=* parent=trusted localmax=2 maxchans=20 modes=+ix";
|
||||||
# connectclass "banned allow=1.2.3.0/24 deny=yes";
|
# 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)
|
# 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
|
# security groups — use as an extban: MODE #c +b g:<name>. criteria: public tls
|
||||||
# insecure account unregistered oper exclude-oper bot webirc mask=<glob>
|
# 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 {
|
# securitygroups {
|
||||||
# securitygroup "trusted account tls public";
|
# securitygroup "trusted account tls public";
|
||||||
# securitygroup "newbies scoremax=10 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
|
//! ```text
|
||||||
//! connectclass = <name> allow=<mask[,mask]> [parent=<name>] [deny=yes]
|
//! 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>]
|
//! [localmax=<n>] [globalmax=<n>] [limit=<n>] [maxchans=<n>] [pingfreq=<secs>]
|
||||||
//! [timeout=<secs>] [modes=<+modes>] [recvq=<bytes>] [hardsendq=<bytes>]
|
//! [timeout=<secs>] [modes=<+modes>] [recvq=<bytes>] [hardsendq=<bytes>]
|
||||||
//! [softsendq=<bytes>] [fakelag=yes|no] [penaltythreshold=<n>] [commandrate=<secs>]
|
//! [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 ssl_trusted: bool, // require a TLS client certificate (requiressl=trusted)
|
||||||
pub password: Option<String>, // PASS credential (plain or hashed; verify auto-detects)
|
pub password: Option<String>, // PASS credential (plain or hashed; verify auto-detects)
|
||||||
pub ports: Vec<u16>, // restrict to these listener ports (empty = any)
|
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 localmax: Option<usize>, // max local connections per IP in this class
|
||||||
pub globalmax: Option<usize>, // max network-wide connections per IP
|
pub globalmax: Option<usize>, // max network-wide connections per IP
|
||||||
pub limit: Option<usize>, // max total local users in this class
|
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=`.
|
// regardless of whether it appears before or after `password=`.
|
||||||
"hash" => {}
|
"hash" => {}
|
||||||
"port" => c.ports.extend(list(v).filter_map(|p| p.parse::<u16>().ok())),
|
"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(),
|
"localmax" => c.localmax = v.parse().ok(),
|
||||||
"globalmax" => c.globalmax = v.parse().ok(),
|
"globalmax" => c.globalmax = v.parse().ok(),
|
||||||
"limit" => c.limit = v.parse().ok(),
|
"limit" => c.limit = v.parse().ok(),
|
||||||
|
|
@ -269,6 +271,7 @@ fn pick(
|
||||||
secure: bool,
|
secure: bool,
|
||||||
has_cert: bool,
|
has_cert: bool,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
asn: Option<u32>,
|
||||||
) -> Pick {
|
) -> Pick {
|
||||||
for c in all(s) {
|
for c in all(s) {
|
||||||
if !c.allow.iter().any(|m| mask_match(m, ip, host)) {
|
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) {
|
if !c.ports.is_empty() && !c.ports.contains(&port) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !c.asn.is_empty() && !asn.is_some_and(|a| c.asn.contains(&a)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if c.deny {
|
if c.deny {
|
||||||
return Pick::Deny(c.name);
|
return Pick::Deny(c.name);
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +349,8 @@ pub fn assign(s: &mut Server, uid: Uid) -> Option<String> {
|
||||||
u.port,
|
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) => {
|
Pick::Deny(name) => {
|
||||||
return Some(format!("Connection class {name} denies your address"));
|
return Some(format!("Connection class {name} denies your address"));
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +416,8 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome {
|
||||||
}) else {
|
}) else {
|
||||||
return AuthOutcome::Proceed;
|
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) => {
|
Pick::Deny(name) => {
|
||||||
return AuthOutcome::Reject(format!("Connection class {name} denies your address"));
|
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("192.0.2.*", "192.0.2.7", ""));
|
||||||
assert!(!mask_match("nomatch/33", "1.2.3.4", "")); // unparseable → no match
|
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 accountban;
|
||||||
pub mod antimixedutf8;
|
pub mod antimixedutf8;
|
||||||
pub mod antirandom;
|
pub mod antirandom;
|
||||||
|
pub mod asn;
|
||||||
pub mod autodrop;
|
pub mod autodrop;
|
||||||
pub mod autoop;
|
pub mod autoop;
|
||||||
pub mod banredirect;
|
pub mod banredirect;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Named security groups. A `securitygroup` config line defines a named set of users
|
//! 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
|
//! range). Groups drive the `g:` matching extban, the `SECURITYGROUPS` command, and a
|
||||||
//! WHOIS line.
|
//! WHOIS line.
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ struct SecGroup {
|
||||||
webirc: Tri,
|
webirc: Tri,
|
||||||
score_min: Option<u32>,
|
score_min: Option<u32>,
|
||||||
score_max: Option<u32>,
|
score_max: Option<u32>,
|
||||||
|
asn: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the `securitygroup = <name> [criteria…]` config lines into groups.
|
/// 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,
|
("exclude-webirc", _) => g.webirc = Tri::No,
|
||||||
("scoremin", Some(n)) => g.score_min = n.parse().ok(),
|
("scoremin", Some(n)) => g.score_min = n.parse().ok(),
|
||||||
("scoremax", Some(n)) => g.score_max = 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !g.asn.is_empty() && !crate::modules::asn::user_in(s, uid, &g.asn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue