From f5f888dbaaaf2d711d299be02015af27d1236dd8 Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 10 Aug 2026 17:49:12 +0000 Subject: [PATCH] connectclass: per-class connection policy (allow/deny, localmax, password, maxchans, pingfreq, timeout, modes) + PASS --- echoircd.conf.example | 9 +++ src/channels.rs | 13 ++++ src/coremods/core_user.rs | 24 +++++++ src/ircd.rs | 11 +++- src/modules/connclass.rs | 134 ++++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 1 + src/numeric.rs | 1 + src/server.rs | 20 +++++- src/users.rs | 2 + 9 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 src/modules/connclass.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 9dec220..5ddb7f4 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -84,6 +84,15 @@ amu_target = both # --- connflood: refuse >max connections per 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=, deny=yes (reject), ssl=yes (TLS only), password= +# (client must PASS it), localmax= (max connections per IP in this class), +# maxchans=, pingfreq=, timeout= (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 = [criteria...] # criteria: public tls insecure account unregistered oper exclude-oper # bot exclude-bot webirc exclude-webirc mask= exclude= diff --git a/src/channels.rs b/src/channels.rs index 608014c..a671b80 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -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) { diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 4665e3e..3377dae 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -18,6 +18,7 @@ pub fn commands() -> Vec> { 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 { diff --git a/src/ircd.rs b/src/ircd.rs index f900f82..df5a3f4 100644 --- a/src/ircd.rs +++ b/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); } diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs new file mode 100644 index 0000000..30b0b3b --- /dev/null +++ b/src/modules/connclass.rs @@ -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 = allow= [deny=yes] [ssl=yes] [password=] +//! [localmax=] [maxchans=] [pingfreq=] [timeout=] [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, + pub localmax: Option, + pub maxchans: Option, + pub pingfreq: Option, + pub timeout: Option, + pub modes: Option, +} + +fn parse(line: &str) -> Option { + 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 { + s.conf_all("connectclass") + .iter() + .filter_map(|l| parse(l)) + .collect() +} + +pub fn named(s: &Server, name: &str) -> Option { + 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 { + 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 { + 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 { + let name = s.users.get(&uid).and_then(|u| u.class.clone())?; + named(s, &name) +} + +pub fn ping_freq(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.pingfreq +} +pub fn reg_timeout(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.timeout +} +pub fn max_chans(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.maxchans +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 4817fe5..f402d7f 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -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; diff --git a/src/numeric.rs b/src/numeric.rs index 35e9174..a5cde31 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -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; diff --git a/src/server.rs b/src/server.rs index 27c3989..4456388 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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, diff --git a/src/users.rs b/src/users.rs index 82875fc..43a8bc2 100644 --- a/src/users.rs +++ b/src/users.rs @@ -293,6 +293,8 @@ pub struct User { pub registered: bool, pub dns_pending: bool, // holding registration for a reverse-DNS lookup pub waitpong: Option, // conn_waitpong: cookie the client must PONG before registering + pub class: Option, // connectclass: assigned connection class name + pub pass: Option, // password sent via PASS (for connectclass passwords) pub deferred: Vec, // 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)