connectclass: per-class connection policy (allow/deny, localmax, password, maxchans, pingfreq, timeout, modes) + PASS
This commit is contained in:
parent
02b2061561
commit
f5f888dbaa
9 changed files with 211 additions and 4 deletions
|
|
@ -84,6 +84,15 @@ amu_target = both
|
|||
# --- connflood: refuse >max connections per <secs> from a single IP ---
|
||||
# connflood = 5 10
|
||||
|
||||
# --- connectclass: per-class connection policy. Each line matches connecting
|
||||
# clients by IP glob (+ optional TLS); first match wins, else global limits.
|
||||
# Keys: allow=<ip glob>, deny=yes (reject), ssl=yes (TLS only), password=<pw>
|
||||
# (client must PASS it), localmax=<n> (max connections per IP in this class),
|
||||
# maxchans=<n>, pingfreq=<secs>, timeout=<secs> (registration), modes=<+modes>.
|
||||
# connectclass = trusted allow=10.0.0.* maxchans=200 pingfreq=120
|
||||
# connectclass = vpn allow=* localmax=2 maxchans=20 modes=+ix
|
||||
# connectclass = banned allow=1.2.3.* deny=yes
|
||||
|
||||
# --- security groups: securitygroup = <name> [criteria...]
|
||||
# criteria: public tls insecure account unregistered oper exclude-oper
|
||||
# bot exclude-bot webirc exclude-webirc mask=<glob> exclude=<glob>
|
||||
|
|
|
|||
|
|
@ -525,6 +525,19 @@ impl Server {
|
|||
// `overrode`, snoticed once the join succeeds.
|
||||
let is_oper = self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
||||
let mut overrode = false;
|
||||
// connectclass max-channels cap (opers exempt)
|
||||
if !is_oper {
|
||||
if let Some(max) = crate::modules::connclass::max_chans(self, uid) {
|
||||
if self.users.get(&uid).map(|u| u.channels.len()).unwrap_or(0) >= max {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_TOOMANYCHANNELS,
|
||||
&format!("{name} :You have joined too many channels"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// CBAN — a forbidden channel name (opers bypass)
|
||||
if !is_oper {
|
||||
if let Some(reason) = self.matched_cban(&key) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
|||
Box::new(UserCmd),
|
||||
Box::new(Ping),
|
||||
Box::new(Pong),
|
||||
Box::new(Pass),
|
||||
Box::new(Quit),
|
||||
Box::new(Away),
|
||||
Box::new(SetName),
|
||||
|
|
@ -562,6 +563,29 @@ impl Command for Pong {
|
|||
}
|
||||
}
|
||||
|
||||
struct Pass;
|
||||
impl Command for Pass {
|
||||
fn name(&self) -> &'static str {
|
||||
"PASS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
// stored for a connectclass password check at registration
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if u.registered {
|
||||
return CmdResult::Fail; // can't re-send PASS after registering
|
||||
}
|
||||
u.pass = Some(params[0].clone());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Quit;
|
||||
impl Command for Quit {
|
||||
fn name(&self) -> &'static str {
|
||||
|
|
|
|||
11
src/ircd.rs
11
src/ircd.rs
|
|
@ -11,7 +11,7 @@ use crate::config::Config;
|
|||
use crate::coremods::command_table;
|
||||
use crate::message;
|
||||
use crate::module::{Hook, ModResult, Module};
|
||||
use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_UNKNOWNCOMMAND};
|
||||
use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_PASSWDMISMATCH, ERR_UNKNOWNCOMMAND};
|
||||
use crate::server::Server;
|
||||
use crate::socketengine::OutSink;
|
||||
use crate::Uid;
|
||||
|
|
@ -414,6 +414,15 @@ impl Ircd {
|
|||
self.server.remove_user(uid, &reason);
|
||||
return;
|
||||
}
|
||||
// connectclass: verify the class password and apply its on-connect modes
|
||||
if let Some(reason) = crate::modules::connclass::on_register(&mut self.server, uid) {
|
||||
self.server
|
||||
.numeric(uid, ERR_PASSWDMISMATCH, &format!(":{reason}"));
|
||||
self.server
|
||||
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||
self.server.remove_user(uid, &reason);
|
||||
return;
|
||||
}
|
||||
self.server.welcome(uid);
|
||||
}
|
||||
|
||||
|
|
|
|||
134
src/modules/connclass.rs
Normal file
134
src/modules/connclass.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! connclass — connection classes. Each `connectclass` config line matches
|
||||
//! connecting clients by IP glob (and optionally TLS), then applies per-class
|
||||
//! policy: reject (deny), a per-IP connection cap, a password, usermodes on
|
||||
//! connect, and overrides for max channels / ping frequency / registration
|
||||
//! timeout. Config, one line per class (first token = name, rest key=value):
|
||||
//!
|
||||
//! ```text
|
||||
//! connectclass = <name> allow=<ip glob> [deny=yes] [ssl=yes] [password=<pw>]
|
||||
//! [localmax=<n>] [maxchans=<n>] [pingfreq=<secs>] [timeout=<secs>] [modes=<+modes>]
|
||||
//! ```
|
||||
//!
|
||||
//! The first class whose `allow` glob (and `ssl` if given) matches a client is
|
||||
//! assigned at connect. With no class, the global limits apply. Matching is against
|
||||
//! the IP (the host isn't resolved yet at connect).
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnClass {
|
||||
pub name: String,
|
||||
pub allow: String,
|
||||
pub deny: bool,
|
||||
pub ssl: bool,
|
||||
pub password: Option<String>,
|
||||
pub localmax: Option<usize>,
|
||||
pub maxchans: Option<usize>,
|
||||
pub pingfreq: Option<u64>,
|
||||
pub timeout: Option<u64>,
|
||||
pub modes: Option<String>,
|
||||
}
|
||||
|
||||
fn parse(line: &str) -> Option<ConnClass> {
|
||||
let mut it = line.split_whitespace();
|
||||
let mut c = ConnClass {
|
||||
name: it.next()?.to_string(),
|
||||
allow: "*".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for tok in it {
|
||||
let Some((k, v)) = tok.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
match k {
|
||||
"allow" => c.allow = v.to_string(),
|
||||
"deny" => c.deny = v.eq_ignore_ascii_case("yes"),
|
||||
"ssl" | "requiressl" => c.ssl = v.eq_ignore_ascii_case("yes"),
|
||||
"password" | "pass" => c.password = Some(v.to_string()),
|
||||
"localmax" => c.localmax = v.parse().ok(),
|
||||
"maxchans" => c.maxchans = v.parse().ok(),
|
||||
"pingfreq" => c.pingfreq = v.parse().ok(),
|
||||
"timeout" => c.timeout = v.parse().ok(),
|
||||
"modes" => c.modes = Some(v.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
pub fn all(s: &Server) -> Vec<ConnClass> {
|
||||
s.conf_all("connectclass")
|
||||
.iter()
|
||||
.filter_map(|l| parse(l))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn named(s: &Server, name: &str) -> Option<ConnClass> {
|
||||
all(s).into_iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Assign the connecting client to the first matching class. Returns `Some(reason)`
|
||||
/// if the connection must be rejected (a deny class or a per-IP cap); otherwise sets
|
||||
/// the class name on the user and returns `None`. Called from `add_conn`.
|
||||
pub fn assign(s: &mut Server, uid: Uid) -> Option<String> {
|
||||
let (ip, secure) = {
|
||||
let u = s.users.get(&uid)?;
|
||||
(u.addr.ip().to_string(), u.secure)
|
||||
};
|
||||
let class = all(s)
|
||||
.into_iter()
|
||||
.find(|c| glob_match(&c.allow, &ip) && (!c.ssl || secure))?;
|
||||
if class.deny {
|
||||
return Some(format!("Connection class {} denies your address", class.name));
|
||||
}
|
||||
if let Some(max) = class.localmax {
|
||||
let n = s
|
||||
.users
|
||||
.values()
|
||||
.filter(|u| {
|
||||
u.addr.ip().to_string() == ip && u.class.as_deref() == Some(class.name.as_str())
|
||||
})
|
||||
.count();
|
||||
if n >= max {
|
||||
return Some("Too many connections from your address".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.class = Some(class.name);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// At registration: verify the class password (if any) and apply the class's
|
||||
/// on-connect usermodes. Returns `Some(reason)` to reject.
|
||||
pub fn on_register(s: &mut Server, uid: Uid) -> Option<String> {
|
||||
let name = s.users.get(&uid)?.class.clone()?;
|
||||
let class = named(s, &name)?;
|
||||
if let Some(pw) = &class.password {
|
||||
let ok = s.users.get(&uid).and_then(|u| u.pass.clone());
|
||||
if ok.as_deref() != Some(pw.as_str()) {
|
||||
return Some("Password mismatch for your connection class".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(m) = class.modes {
|
||||
crate::coremods::core_mode::svs_set_user_modes(s, uid, &m);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn class_of(s: &Server, uid: Uid) -> Option<ConnClass> {
|
||||
let name = s.users.get(&uid).and_then(|u| u.class.clone())?;
|
||||
named(s, &name)
|
||||
}
|
||||
|
||||
pub fn ping_freq(s: &Server, uid: Uid) -> Option<u64> {
|
||||
class_of(s, uid)?.pingfreq
|
||||
}
|
||||
pub fn reg_timeout(s: &Server, uid: Uid) -> Option<u64> {
|
||||
class_of(s, uid)?.timeout
|
||||
}
|
||||
pub fn max_chans(s: &Server, uid: Uid) -> Option<usize> {
|
||||
class_of(s, uid)?.maxchans
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod chathistory;
|
|||
pub mod cloak;
|
||||
pub mod cloudflare_challenge;
|
||||
pub mod conn_waitpong;
|
||||
pub mod connclass;
|
||||
pub mod connectban;
|
||||
pub mod connflood;
|
||||
pub mod customtitle;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ pub const RPL_ISUPPORT: u16 = 5;
|
|||
pub const RPL_UMODEIS: u16 = 221;
|
||||
pub const RPL_YOUREOPER: u16 = 381;
|
||||
pub const ERR_PASSWDMISMATCH: u16 = 464;
|
||||
pub const ERR_TOOMANYCHANNELS: u16 = 405;
|
||||
pub const ERR_NOPRIVILEGES: u16 = 481;
|
||||
pub const ERR_UMODEUNKNOWNFLAG: u16 = 501;
|
||||
pub const RPL_LUSERCLIENT: u16 = 251;
|
||||
|
|
|
|||
|
|
@ -317,6 +317,8 @@ impl Server {
|
|||
registered: false,
|
||||
dns_pending: false,
|
||||
waitpong: None,
|
||||
class: None,
|
||||
pass: None,
|
||||
deferred: Vec::new(),
|
||||
cap: false,
|
||||
cap_302: false,
|
||||
|
|
@ -350,6 +352,13 @@ impl Server {
|
|||
// connectban — z-line an IP range that opens too many connections (see modules::connectban)
|
||||
crate::modules::connectban::on_connect(self, ip);
|
||||
|
||||
// connectclass — assign a connection class; a deny class or per-IP cap rejects
|
||||
if let Some(reason) = crate::modules::connclass::assign(self, uid) {
|
||||
self.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||
self.remove_user(uid, &reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-registration connection notices. The ident-113 notices are cosmetic
|
||||
// (ident is archaic and firewalled); the hostname lookup is real (see
|
||||
// `resolver`) and its result arrives later as an Event.
|
||||
|
|
@ -1003,15 +1012,18 @@ impl Server {
|
|||
let mut quit = Vec::new();
|
||||
for (&uid, u) in &self.users {
|
||||
let idle = now.saturating_sub(u.last_active);
|
||||
// a connection class may override the registration timeout / ping frequency
|
||||
let reg_to = crate::modules::connclass::reg_timeout(self, uid).unwrap_or(reg_timeout);
|
||||
let pa = crate::modules::connclass::ping_freq(self, uid).unwrap_or(ping_after);
|
||||
if !u.registered {
|
||||
if idle >= reg_timeout {
|
||||
if idle >= reg_to {
|
||||
quit.push(uid); // never registered in time
|
||||
}
|
||||
} else if u.ping_sent {
|
||||
if idle >= ping_after + ping_timeout {
|
||||
if idle >= pa + ping_timeout {
|
||||
quit.push(uid); // no reply to the server PING
|
||||
}
|
||||
} else if idle >= ping_after {
|
||||
} else if idle >= pa {
|
||||
ping.push(uid); // idle — poke it
|
||||
}
|
||||
}
|
||||
|
|
@ -1048,6 +1060,8 @@ mod tests {
|
|||
registered: true,
|
||||
dns_pending: false,
|
||||
waitpong: None,
|
||||
class: None,
|
||||
pass: None,
|
||||
deferred: Vec::new(),
|
||||
cap: false,
|
||||
cap_302: false,
|
||||
|
|
|
|||
|
|
@ -293,6 +293,8 @@ pub struct User {
|
|||
pub registered: bool,
|
||||
pub dns_pending: bool, // holding registration for a reverse-DNS lookup
|
||||
pub waitpong: Option<String>, // conn_waitpong: cookie the client must PONG before registering
|
||||
pub class: Option<String>, // connectclass: assigned connection class name
|
||||
pub pass: Option<String>, // password sent via PASS (for connectclass passwords)
|
||||
pub deferred: Vec<String>, // handshake lines held while dns_pending (replayed after)
|
||||
pub cap: bool, // CAP negotiation in progress (holds registration)
|
||||
pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue