connectclass: per-class connection policy (allow/deny, localmax, password, maxchans, pingfreq, timeout, modes) + PASS

This commit is contained in:
Jean Chevronnet 2026-08-10 17:49:12 +00:00
parent 02b2061561
commit f5f888dbaa
9 changed files with 211 additions and 4 deletions

View file

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

View file

@ -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) {

View file

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

View file

@ -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
View 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
}

View file

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

View file

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

View file

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

View file

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