From 64dcbd8bf42da97314b6d960f0c281fbfe69d7a8 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 08:08:06 +0000 Subject: [PATCH] securitygroups: UnrealIRCd-style groups + g: extban + SECURITYGROUPS + WHOIS --- echoircd.conf.example | 7 ++ src/channels.rs | 29 ++++++-- src/config.rs | 67 +++++++++++++++++ src/coremods/core_info.rs | 9 +++ src/coremods/core_user.rs | 1 + src/modules/mod.rs | 2 + src/modules/securitygroups.rs | 135 ++++++++++++++++++++++++++++++++++ src/server.rs | 2 + src/users.rs | 3 +- 9 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 src/modules/securitygroups.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 13a753e..323e8fe 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -83,3 +83,10 @@ amu_target = both # --- connflood: refuse >max connections per from a single IP --- # connflood = 5 10 + +# --- security groups (UnrealIRCd-style): securitygroup = [criteria...] +# criteria: public tls insecure account unregistered oper exclude-oper +# bot exclude-bot webirc exclude-webirc mask= exclude= +# scoremin= scoremax= — use as an extban: MODE #c +b g: +# securitygroup = trusted account tls public +# securitygroup = newbies scoremax=10 public diff --git a/src/channels.rs b/src/channels.rs index cabba28..059da32 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -545,10 +545,8 @@ impl Server { } } // +b — bans block even an invited user, unless a +e exception matches - let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); - if ch.bans.iter().any(|b| glob_match(&b.mask, &mask)) - && !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask)) - { + // (both honour the g: security-group extban) + if self.ban_list_hit(uid, &ch.bans) && !self.ban_list_hit(uid, &ch.excepts) { if !is_oper { self.numeric( uid, @@ -562,7 +560,7 @@ impl Server { // +i — unless invited or matched by a +I invite exception if ch.modes.invite_only && !ch.invites.contains(&uid) - && !ch.invex.iter().any(|e| glob_match(&e.mask, &mask)) + && !self.ban_list_hit(uid, &ch.invex) { if !is_oper { self.numeric( @@ -885,6 +883,23 @@ impl Server { /// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on /// `key` with no matching `kind:` exception in +e. The stored mask is /// `kind:`; we glob the hostmask part against the user's prefix. + /// Whether any entry in `list` catches `uid`: a plain `nick!user@host` glob, + /// or the `g:` security-group matching extban. Acting extbans (`m:`/`c:`/ + /// `n:`) never match here — they restrict actions, not join/ban membership. + pub fn ban_list_hit(&self, uid: Uid, list: &[Ban]) -> bool { + let who = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + list.iter().any(|b| { + if b.mask.as_bytes().get(1) == Some(&b':') { + match b.mask.as_bytes().first() { + Some(b'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]), + _ => false, + } + } else { + glob_match(&b.mask, &who) + } + }) + } + pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool { let Some(ch) = self.channels.get(key) else { return false; @@ -1085,6 +1100,10 @@ pub fn normalize_mask(m: &str) -> String { pub fn normalize_ban_mask(m: &str) -> String { let b = m.as_bytes(); if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() { + // the g: security-group extban's argument is a group name, not a host mask + if b[0] == b'g' { + return m.to_string(); + } return format!("{}:{}", &m[..1], normalize_mask(&m[2..])); } normalize_mask(m) diff --git a/src/config.rs b/src/config.rs index eca3d4f..7050aa6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,32 @@ //! oper = god secret //! ``` +/// Tri-state for a security-group criterion: don't-care / must-be / must-not-be. +#[derive(Clone, Copy, PartialEq, Default)] +pub enum Tri { + #[default] + Ignore, + Yes, + No, +} + +/// A UnrealIRCd-style security group (InspIRCd `m_securitygroups`). All criteria +/// are AND-ed: a user is a member iff every set criterion matches. +#[derive(Clone, Default)] +pub struct SecGroup { + pub name: String, + pub public: bool, // shown to non-opers + pub masks: Vec, // positive: match any one nick!user@host glob + pub exclude_masks: Vec, // negative: matching any one vetoes membership + pub tls: Tri, + pub account: Tri, + pub oper: Tri, + pub bot: Tri, + pub webirc: Tri, + pub score_min: Option, // reputation lower bound + pub score_max: Option, // reputation upper bound +} + /// A server-link block: how to authenticate a peer named `name` (and, if /// `autoconnect`, where to dial it). Passwords are the shared link secret. #[derive(Clone)] @@ -80,6 +106,7 @@ pub struct Config { pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host) pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick) pub connflood: Option<(u32, u64)>, // (max conns, per secs) from one IP before refusing + pub sec_groups: Vec, // UnrealIRCd-style security groups } impl Default for Config { @@ -112,6 +139,7 @@ impl Default for Config { vhosts: Vec::new(), aliases: Vec::new(), connflood: None, + sec_groups: Vec::new(), } } } @@ -281,6 +309,45 @@ impl Config { } } } + "securitygroup" | "secgroup" => { + // securitygroup = [public] [tls|insecure] [account|unregistered] + // [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc] + // [mask=]... [exclude=]... [scoremin=N] [scoremax=N] + let mut it = v.split_whitespace(); + if let Some(name) = it.next() { + let mut g = SecGroup { + name: name.to_string(), + ..Default::default() + }; + for tok in it { + let (k, val) = match tok.split_once('=') { + Some((a, b)) => (a, Some(b)), + None => (tok, None), + }; + match (k, val) { + ("public", _) => g.public = true, + ("mask", Some(m)) => g.masks.push(m.to_string()), + ("exclude", Some(m)) | ("exclude-mask", Some(m)) => { + g.exclude_masks.push(m.to_string()) + } + ("tls", _) | ("tls-users", _) => g.tls = Tri::Yes, + ("insecure", _) | ("exclude-tls", _) => g.tls = Tri::No, + ("account", _) | ("registered", _) => g.account = Tri::Yes, + ("unregistered", _) | ("exclude-account", _) => g.account = Tri::No, + ("oper", _) => g.oper = Tri::Yes, + ("exclude-oper", _) => g.oper = Tri::No, + ("bot", _) | ("bmode", _) => g.bot = Tri::Yes, + ("exclude-bot", _) | ("exclude-bmode", _) => g.bot = Tri::No, + ("webirc", _) => g.webirc = Tri::Yes, + ("exclude-webirc", _) => g.webirc = Tri::No, + ("scoremin", Some(n)) => g.score_min = n.parse().ok(), + ("scoremax", Some(n)) => g.score_max = n.parse().ok(), + _ => {} + } + } + c.sec_groups.push(g); + } + } _ => {} } } diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index de447dc..a59f138 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -229,6 +229,15 @@ impl Command for Whois { if let Some(line) = &swhois { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } + // security groups (public ones to all; opers/self see private ones too) + let groups = crate::modules::securitygroups::user_groups(s, tuid, is_self || asker_oper); + if !groups.is_empty() { + s.numeric( + uid, + RPL_WHOISSPECIAL, + &format!(":is in security groups: {}", groups.join(", ")), + ); + } // opers can see through the cloak to the real host/ip if asker_oper && disp != realhost { s.numeric( diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index e55c96f..5a46ee2 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -106,6 +106,7 @@ impl Command for WebIrc { }; let newip = ip.parse::().ok(); if let Some(u) = s.users.get_mut(&uid) { + u.flags.via_webirc = true; // securitygroups: webirc criterion u.host = host.clone(); if let Some(a) = newip { u.addr = SocketAddr::new(a, u.addr.port()); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 2d52671..ba36066 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -13,6 +13,7 @@ pub mod markread; pub mod metadata; pub mod multiline; pub mod reputation; +pub mod securitygroups; pub mod snoop; use crate::command::Command; @@ -43,5 +44,6 @@ pub fn module_commands() -> Vec> { .chain(multiline::commands()) .chain(chathistory::commands()) .chain(reputation::commands()) + .chain(securitygroups::commands()) .collect() } diff --git a/src/modules/securitygroups.rs b/src/modules/securitygroups.rs new file mode 100644 index 0000000..fb3ad0c --- /dev/null +++ b/src/modules/securitygroups.rs @@ -0,0 +1,135 @@ +//! securitygroups — UnrealIRCd-style security groups (InspIRCd `m_securitygroups`). +//! A `securitygroup` config line defines a named set of users by AND-ed criteria +//! (host masks, TLS, account, oper, bot, webirc, reputation score range). Groups +//! drive the `g:` matching extban, the `SECURITYGROUPS` command, and a WHOIS line. +//! Self-contained: the group defs live in `Server.sec_groups`; evaluation is here. + +use crate::channels::glob_match; +use crate::command::{CmdResult, Command}; +use crate::config::{SecGroup, Tri}; +use crate::numeric::ERR_NOSUCHNICK; +use crate::server::Server; +use crate::Uid; + +/// Does `uid`'s identity match `mask` (glob against nick!user@{display,real,ip})? +fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool { + let Some(u) = s.users.get(&uid) else { + return false; + }; + let forms = [ + format!("{}!{}@{}", u.nick, u.ident, u.host_display()), + format!("{}!{}@{}", u.nick, u.ident, u.host), + format!("{}!{}@{}", u.nick, u.ident, u.addr.ip()), + ]; + forms.iter().any(|f| glob_match(mask, f)) +} + +/// True when a tri-state criterion is satisfied by `fact`. +fn tri_ok(want: Tri, fact: bool) -> bool { + match want { + Tri::Yes => fact, + Tri::No => !fact, + Tri::Ignore => true, + } +} + +/// Does `uid` match every criterion of `g`? +fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool { + let Some(u) = s.users.get(&uid) else { + return false; + }; + // masks: an exclude match vetoes; positive masks (if any) require one to match + if g.exclude_masks.iter().any(|m| mask_matches(s, uid, m)) { + return false; + } + if !g.masks.is_empty() && !g.masks.iter().any(|m| mask_matches(s, uid, m)) { + return false; + } + if !tri_ok(g.tls, u.secure) + || !tri_ok(g.account, u.account.is_some()) + || !tri_ok(g.oper, u.flags.oper) + || !tri_ok(g.bot, u.flags.bot) + || !tri_ok(g.webirc, u.flags.via_webirc) + { + return false; + } + if g.score_min.is_some() || g.score_max.is_some() { + let ip = u.addr.ip(); + let score = s + .ext + .get::() + .and_then(|r| r.0.get(&ip)) + .copied() + .unwrap_or(0); + if g.score_min.is_some_and(|m| score < m) || g.score_max.is_some_and(|m| score > m) { + return false; + } + } + true +} + +/// Whether `uid` is a member of the named security group (case-insensitive). +pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool { + s.sec_groups + .iter() + .any(|g| g.name.eq_ignore_ascii_case(name) && matches(s, uid, g)) +} + +/// The names of the groups `uid` is in (only public ones unless `include_private`). +pub fn user_groups(s: &Server, uid: Uid, include_private: bool) -> Vec { + s.sec_groups + .iter() + .filter(|g| (include_private || g.public) && matches(s, uid, g)) + .map(|g| g.name.clone()) + .collect() +} + +pub fn commands() -> Vec> { + vec![Box::new(SecGroupsCmd)] +} + +/// SECURITYGROUPS — `SECURITYGROUPS [nick]`. List the security groups a user is in. +/// You always see your own; opers see everyone's; otherwise only public groups show. +struct SecGroupsCmd; +impl Command for SecGroupsCmd { + fn name(&self) -> &'static str { + "SECURITYGROUPS" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let tuid = match params.first() { + Some(n) => match s.find_nick(n) { + Some(t) => t, + None => { + s.numeric(uid, ERR_NOSUCHNICK, &format!("{n} :No such nick/channel")); + return CmdResult::Fail; + } + }, + None => uid, + }; + let include_private = tuid == uid || s.is_oper(uid); + let groups = user_groups(s, tuid, include_private); + let list = if groups.is_empty() { + "none".to_string() + } else { + groups.join(", ") + }; + let (tnick, anick) = ( + s.users + .get(&tuid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + s.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + ); + s.send( + uid, + format!( + ":{} NOTICE {anick} :{tnick} is in security groups: {list}", + s.name + ), + ); + CmdResult::Ok + } +} diff --git a/src/server.rs b/src/server.rs index 7f4bf35..cb9368d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -137,6 +137,7 @@ pub struct Server { pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick) pub connflood: Option<(u32, u64)>, // (max, secs) connection throttle per IP pub conn_history: HashMap>, // recent connection times per IP (connflood) + pub sec_groups: Vec, // UnrealIRCd-style security groups // labeled-response: while Some((uid, buf)), that client's own responses are // diverted into `buf` instead of the socket, so `on_line` can wrap them with // the command's `label` (single tag, BATCH, or ACK). RefCell because the @@ -193,6 +194,7 @@ impl Server { aliases: cfg.aliases, connflood: cfg.connflood, conn_history: HashMap::new(), + sec_groups: cfg.sec_groups, label_capture: RefCell::new(None), event_tx, conn_counter, diff --git a/src/users.rs b/src/users.rs index d751c8c..8a368f6 100644 --- a/src/users.rs +++ b/src/users.rs @@ -33,6 +33,7 @@ pub struct UserFlags { pub showwhois: bool, // +W (get a notice when someone WHOISes you) pub deny_uncommon: bool, // +c (only users sharing a channel may PM you) pub nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change) + pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups) pub away: Option, // AWAY message, if set } @@ -430,7 +431,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cgmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );