From 546f6279e76d6cb98b60f890119ec0d222d97379 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 15 Aug 2026 17:06:14 +0000 Subject: [PATCH] snomasks: make +s a parametric snomask mode with the standard category letters (acdfgjklnoqrtuvwx), route each server notice by category, RPL_SNOMASKIS 008; opers default to all and narrow with +s -c etc. --- src/channels.rs | 4 +- src/coremods/core_mode.rs | 79 +++++++++++++++++++++++++++++++++++++++ src/coremods/core_oper.rs | 24 ++++++------ src/mode.rs | 7 ++++ src/modules/connclass.rs | 4 +- src/modules/connectban.rs | 2 +- src/modules/dnsbl.rs | 2 +- src/modules/filter.rs | 4 +- src/modules/globops.rs | 2 +- src/modules/ojoin.rs | 2 +- src/modules/rpc/server.rs | 2 +- src/modules/snoop.rs | 4 +- src/numeric.rs | 1 + src/server.rs | 9 ++++- src/users.rs | 12 +++++- src/xline.rs | 2 +- 16 files changed, 131 insertions(+), 29 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index 01eb7cd..cfe42ae 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -847,7 +847,7 @@ impl Server { .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); - self.snotice(&format!("{nick} used oper override to join {name}")); + self.snotice_c('v', &format!("{nick} used oper override to join {name}")); } let is_new = !self.channels.contains_key(&key); let ch = self @@ -876,7 +876,7 @@ impl Server { .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); - self.snotice(&format!("{who} created channel {name}")); + self.snotice_c('j', &format!("{who} created channel {name}")); } // JOIN broadcast — extended-join clients also get the account + realname diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index 9b8052d..cacdc51 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -236,6 +236,68 @@ pub fn svs_set_chan_modes(s: &mut Server, target: &str, modestring: &str, args: true } +/// Apply a client `+s`/`-s` snomask change. Oper-only. `+s` with a mask edits the +/// subscribed categories (`+cq` adds, `-c` removes, `*` all); `+s` with no mask +/// subscribes to everything; `-s` clears it. Emits `RPL_SNOMASKIS` (008) and returns +/// whether the `s` mode char should appear in the MODE echo. +fn apply_snomask(s: &mut Server, uid: Uid, adding: bool, param: Option<&str>) -> bool { + if !s.is_oper(uid) { + s.numeric( + uid, + crate::numeric::ERR_NOPRIVILEGES, + ":Permission denied - only operators may set a server notice mask", + ); + return false; + } + let mut cats: std::collections::BTreeSet = s + .users + .get(&uid) + .map(|u| u.flags.snomask_cats.chars().collect()) + .unwrap_or_default(); + let all = || crate::users::DEFAULT_SNOMASK.chars().collect::>(); + if !adding { + cats.clear(); + } else { + match param { + None => cats = all(), + Some(p) => { + let mut sign = '+'; + for c in p.chars() { + match c { + '+' => sign = '+', + '-' => sign = '-', + '*' => cats = if sign == '+' { all() } else { Default::default() }, + c if crate::users::DEFAULT_SNOMASK.contains(c) => { + if sign == '+' { + cats.insert(c); + } else { + cats.remove(&c); + } + } + _ => {} // ignore unknown snomask letters + } + } + } + } + } + let mask: String = cats.iter().collect(); + let on = !mask.is_empty(); + if let Some(u) = s.users.get_mut(&uid) { + u.flags.snomask = on; + u.flags.snomask_cats = mask.clone(); + } + s.numeric( + uid, + crate::numeric::RPL_SNOMASKIS, + &format!("+{mask} :Server notice mask"), + ); + if adding { + on + } else { + true + } +} + /// User modes: dispatched to the [`crate::mode`] `UserMode` handler objects. fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) -> CmdResult { let me = s @@ -264,12 +326,29 @@ fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) - let mut sign = '+'; let mut applied = String::new(); let mut last = ' '; + let mut argi = 2usize; // params[2..] are mode arguments (the +s snomask mask) for c in modestring.chars() { if c == '+' || c == '-' { sign = c; continue; } let adding = sign == '+'; + // +s is a parametric snomask mode: it consumes the following mask argument + if c == 's' { + let param = if adding { + let p = params.get(argi).cloned(); + if p.is_some() { + argi += 1; + } + p + } else { + None + }; + if apply_snomask(s, uid, adding, param.as_deref()) { + emit(&mut applied, &mut last, sign, c); + } + continue; + } let Some(handler) = user_mode(c) else { s.numeric( uid, diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 6aadd8d..d39e5c1 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -868,7 +868,7 @@ impl Command for NickLock { u.flags.nick_locked = true; } let by = oper_nick(s, uid); - s.snotice(&format!("{by} used NICKLOCK on {newnick}")); + s.snotice_c('v', &format!("{by} used NICKLOCK on {newnick}")); CmdResult::Ok } } @@ -893,7 +893,7 @@ impl Command for NickUnlock { u.flags.nick_locked = false; } let by = oper_nick(s, uid); - s.snotice(&format!("{by} used NICKUNLOCK on {}", params[0])); + s.snotice_c('v', &format!("{by} used NICKUNLOCK on {}", params[0])); CmdResult::Ok } } @@ -933,7 +933,7 @@ impl Command for Connect { let max_line = s.conf_num("max_line", crate::socketengine::DEFAULT_MAX_LINE); std::thread::spawn(move || crate::socketengine::connect_link(&addr, tx, counter, max_line)); let by = oper_nick(s, uid); - s.snotice(&format!( + s.snotice_c('l', &format!( "{by} used CONNECT to {} ({}:{})", b.name, b.ip, b.port )); @@ -996,7 +996,7 @@ impl Command for ChgHost { }; s.change_host_ident(t, None, Some(¶ms[1])); let by = oper_nick(s, uid); - s.snotice(&format!( + s.snotice_c('v', &format!( "{by} used CHGHOST on {}: {}", params[0], params[1] )); @@ -1048,7 +1048,7 @@ impl Command for ChgIdent { }; s.change_host_ident(t, Some(¶ms[1]), None); let by = oper_nick(s, uid); - s.snotice(&format!( + s.snotice_c('v', &format!( "{by} used CHGIDENT on {}: {}", params[0], params[1] )); @@ -1095,7 +1095,7 @@ impl Command for SaMode { let r = apply_mode(s, uid, params); s.mode_sudo = false; let by = oper_nick(s, uid); - s.snotice(&format!("{by} used SAMODE: {}", params.join(" "))); + s.snotice_c('v', &format!("{by} used SAMODE: {}", params.join(" "))); r } } @@ -1134,7 +1134,7 @@ impl Command for SaTopic { s.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None); s.propagate_topic(uid, chan, &text); let by = oper_nick(s, uid); - s.snotice(&format!("{by} used SATOPIC on {chan}")); + s.snotice_c('v', &format!("{by} used SATOPIC on {chan}")); CmdResult::Ok } } @@ -1195,7 +1195,7 @@ impl Command for SaKick { s.events .push_back(Hook::Part(tuid, key, "kicked".to_string())); let by = oper_nick(s, uid); - s.snotice(&format!("{by} used SAKICK on {victim} in {chan}")); + s.snotice_c('v', &format!("{by} used SAKICK on {victim} in {chan}")); CmdResult::Ok } } @@ -1224,7 +1224,7 @@ impl Command for SaQuit { s.send(tuid, format!("ERROR :Closing link: (SAQUIT: {reason})")); s.remove_user(tuid, &format!("Quit: {reason}")); let by = oper_nick(s, uid); - s.snotice(&format!("{by} used SAQUIT on {}: {reason}", params[0])); + s.snotice_c('v', &format!("{by} used SAQUIT on {}: {reason}", params[0])); CmdResult::Ok } } @@ -1260,7 +1260,7 @@ impl Command for ChgName { } s.notify_peers(t, &line, |c| c.setname); let by = oper_nick(s, uid); - s.snotice(&format!("{by} used CHGNAME on {}: {realname}", params[0])); + s.snotice_c('v', &format!("{by} used CHGNAME on {}: {realname}", params[0])); CmdResult::Ok } } @@ -1317,7 +1317,7 @@ impl Command for ClearChan { } s.channels.retain(|_, c| c.keep_alive()); let by = oper_nick(s, uid); - s.snotice(&format!("{by} used CLEARCHAN on {chan}")); + s.snotice_c('v', &format!("{by} used CLEARCHAN on {chan}")); CmdResult::Ok } } @@ -1434,7 +1434,7 @@ impl Command for SwhoisCmd { } } let by = oper_nick(s, uid); - s.snotice(&format!("{by} used SWHOIS on {}: {text}", params[0])); + s.snotice_c('v', &format!("{by} used SWHOIS on {}: {text}", params[0])); CmdResult::Ok } } diff --git a/src/mode.rs b/src/mode.rs index c07eabc..ac3b998 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -1273,7 +1273,14 @@ fn set_helpop(f: &mut UserFlags, v: bool) { f.helpop = v; } fn set_snomask(f: &mut UserFlags, v: bool) { + // the plain `+s`/`-s` path (services, or a client giving no mask) toggles the + // full default set; the client `+s ` path in apply_user_modes refines it f.snomask = v; + f.snomask_cats = if v { + crate::users::DEFAULT_SNOMASK.to_string() + } else { + String::new() + }; } /// An oper-only boolean flag (+H / +W / +h / +s): only an operator may **set** it; diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs index 4cd197e..d1c43ca 100644 --- a/src/modules/connclass.rs +++ b/src/modules/connclass.rs @@ -257,7 +257,7 @@ fn pick( if let Some(max) = c.limit { if class_count(s, &c.name, uid) >= max { if c.maxconnwarn { - s.snotice(&format!("connect class {} is full ({max})", c.name)); + s.snotice_c('c', &format!("connect class {} is full ({max})", c.name)); } continue; // full — try the next class } @@ -323,7 +323,7 @@ pub fn assign(s: &mut Server, uid: Uid) -> Option { }; let warn = |s: &Server, why: &str| { if class.maxconnwarn { - s.snotice(&format!("connect class {} refused {ip}: {why}", class.name)); + s.snotice_c('c', &format!("connect class {} refused {ip}: {why}", class.name)); } }; if let Some(max) = class.localmax { diff --git a/src/modules/connectban.rs b/src/modules/connectban.rs index 1008c20..c665240 100644 --- a/src/modules/connectban.rs +++ b/src/modules/connectban.rs @@ -107,7 +107,7 @@ pub fn on_connect(s: &mut Server, ip: IpAddr) { ) .to_string(); s.add_xline(XKind::Zline, &glob, dur, &setter, &reason); - s.snotice(&format!( + s.snotice_c('x', &format!( "Connect flooding from IP range {glob} (threshold {threshold})" )); } diff --git a/src/modules/dnsbl.rs b/src/modules/dnsbl.rs index f44eef3..dc72109 100644 --- a/src/modules/dnsbl.rs +++ b/src/modules/dnsbl.rs @@ -77,7 +77,7 @@ fn act(s: &mut Server, uid: Uid, zone: &str, reply: Ipv4Addr) { None => return, }; let action = s.dnsbl_action.clone(); - s.snotice(&format!( + s.snotice_c('d', &format!( "DNSBL: {mask} is listed on {zone} ({reply}); action={action}" )); let reason = format!("{} (listed on {zone})", s.dnsbl_reason); diff --git a/src/modules/filter.rs b/src/modules/filter.rs index 65e45c2..dd05344 100644 --- a/src/modules/filter.rs +++ b/src/modules/filter.rs @@ -48,7 +48,7 @@ impl Module for Filter { Some(u) => (u.prefix(), u.addr.ip().to_string()), None => return ModResult::Deny, }; - s.snotice(&format!( + s.snotice_c('f', &format!( "FILTER: {mask} matched a filter (action={action}): {reason}" )); match action.as_str() { @@ -171,7 +171,7 @@ impl Command for FilterCmd { duration, reason, }); - s.snotice(&format!("{nick} added FILTER {pattern} (action={action})")); + s.snotice_c('f', &format!("{nick} added FILTER {pattern} (action={action})")); } } } diff --git a/src/modules/globops.rs b/src/modules/globops.rs index 42c5ba3..b74b65b 100644 --- a/src/modules/globops.rs +++ b/src/modules/globops.rs @@ -28,7 +28,7 @@ impl Command for GlobopsCmd { return CmdResult::Fail; } let nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); - s.snotice(&format!("GLOBOPS from {nick}: {}", params.join(" "))); + s.snotice_c('g', &format!("GLOBOPS from {nick}: {}", params.join(" "))); CmdResult::Ok } } diff --git a/src/modules/ojoin.rs b/src/modules/ojoin.rs index e753a53..9e516ba 100644 --- a/src/modules/ojoin.rs +++ b/src/modules/ojoin.rs @@ -49,7 +49,7 @@ impl Command for OjoinCmd { if s.conf_bool("ojoin_op", true) { crate::coremods::core_mode::svs_set_chan_modes(s, &chan, "+o", &[nick.clone()]); } - s.snotice(&format!("{nick} used OJOIN to enter {chan}")); + s.snotice_c('v', &format!("{nick} used OJOIN to enter {chan}")); CmdResult::Ok } } diff --git a/src/modules/rpc/server.rs b/src/modules/rpc/server.rs index 9d1c1a9..304e713 100644 --- a/src/modules/rpc/server.rs +++ b/src/modules/rpc/server.rs @@ -61,7 +61,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { diff --git a/src/modules/snoop.rs b/src/modules/snoop.rs index da5dc92..c0ff345 100644 --- a/src/modules/snoop.rs +++ b/src/modules/snoop.rs @@ -18,7 +18,7 @@ impl Module for Snoop { .map(|u| (u.nick.clone(), u.ident.clone(), u.host.clone())); if let Some((nick, ident, host)) = info { eprintln!("[snoop] connect {nick} ({ident}@{host})"); - srv.snotice(&format!("Client connecting: {nick} ({ident}@{host})")); + srv.snotice_c('c', &format!("Client connecting: {nick} ({ident}@{host})")); } } fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) { @@ -30,7 +30,7 @@ impl Module for Snoop { let nick = srv.users.get(&uid).map(|u| u.nick.clone()); eprintln!("[snoop] quit uid={uid} ({reason})"); if let Some(nick) = nick { - srv.snotice(&format!("Client exiting: {nick} ({reason})")); + srv.snotice_c('q', &format!("Client exiting: {nick} ({reason})")); } } } diff --git a/src/numeric.rs b/src/numeric.rs index eff56a5..ddd22d4 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -98,6 +98,7 @@ pub const RPL_WHOISBOT: u16 = 335; // "is a bot" (umode +B) pub const RPL_WHOISACCOUNT: u16 = 330; // " :is logged in as" pub const RPL_WHOISREGNICK: u16 = 307; // "is a registered nick" (identified to an account) pub const RPL_WHOISMODES: u16 = 379; // oper/self-only: "is using modes +" +pub const RPL_SNOMASKIS: u16 = 8; // "+ :Server notice mask" after a +s change pub const ERR_NEEDREGGEDNICK: u16 = 477; // chan +R/+M — must be logged into an account pub const RPL_WHOISHOST: u16 = 378; // oper-only: real host/ip behind a cloak pub const RPL_WHOISSECURE: u16 = 671; // "is using a secure connection" (sslinfo) diff --git a/src/server.rs b/src/server.rs index 015ffba..812905f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -843,12 +843,19 @@ impl Server { } /// Send a server notice to every operator who has snomask (+s) on. + /// A server notice in the general `a` (announcement) category. pub fn snotice(&self, msg: &str) { + self.snotice_c('a', msg); + } + + /// A server notice tagged with snomask category `cat` — only opers whose snomask + /// (`+s`) subscribes to that letter receive it. Logging tees are unconditional. + pub fn snotice_c(&self, cat: char, msg: &str) { self.log_push(msg); let opers: Vec = self .users .iter() - .filter(|(_, u)| u.flags.oper && u.flags.snomask) + .filter(|(_, u)| u.flags.oper && u.flags.snomask_cats.contains(cat)) .map(|(&u, _)| u) .collect(); let jval = self.json_log_value(msg, &opers); diff --git a/src/users.rs b/src/users.rs index 59bb99a..ebaef63 100644 --- a/src/users.rs +++ b/src/users.rs @@ -11,6 +11,12 @@ use crate::server::{Server, VERSION}; use crate::socketengine::OutSink; use crate::Uid; +/// Snomask category letters an oper subscribes to (the standard set): a announce, +/// c connect, d dnsbl, f filter, g globops, j chancreate, k kill, l link, n nick, +/// o oper, q quit, r rehash, t stats, u acctreg, v override, w gateway, x xline. +/// Opers get all of them by default and narrow with `+s -c` etc. +pub const DEFAULT_SNOMASK: &str = "acdfgjklnoqrtuvwx"; + /// User modes and session flags. Kept in one `Default` bag so adding a mode /// doesn't ripple through every `User { .. }` constructor. #[derive(Default)] @@ -27,6 +33,7 @@ pub struct UserFlags { pub reg_only_pm: bool, // +R (only accept PMs from logged-in users) pub ssl_pm: bool, // +z (only accept PMs from TLS users) pub snomask: bool, // +s (oper: receive server notices) + pub snomask_cats: String, // +s snomask category letters this oper is subscribed to pub callerid: bool, // +g (only accept PMs from users on the ACCEPT list) pub showwhois: bool, // +W (get a notice when someone WHOISes you) pub helpop: bool, // +h (helpop: available for help; shown in WHOIS) @@ -351,6 +358,7 @@ impl Server { if let Some(u) = self.users.get_mut(&uid) { u.flags.oper = true; u.flags.snomask = true; // opers get server notices by default + u.flags.snomask_cats = DEFAULT_SNOMASK.to_string(); // all categories } let nick = self .users @@ -359,7 +367,7 @@ impl Server { .unwrap_or_default(); self.numeric(uid, RPL_YOUREOPER, ":You are now an IRC operator"); self.send(uid, format!(":{} MODE {nick} :+os", self.name)); - self.snotice(&format!("{nick} is now an IRC operator")); + self.snotice_c('o', &format!("{nick} is now an IRC operator")); // operprefix: give this oper the ! prefix in every channel they're already in crate::modules::operprefix::grant_all(self, uid); // opermodes: extra umodes on oper-up @@ -438,7 +446,7 @@ impl Server { self.send(t, line.clone()); } if self.conf_bool("seenicks", false) { - self.snotice(&format!("{old} is now known as {newnick}")); + self.snotice_c('n', &format!("{old} is now known as {newnick}")); } // WATCH/MONITOR: the old nick is now gone, the new one is here self.watch_notify_offline(&old); diff --git a/src/xline.rs b/src/xline.rs index 1e0b360..17beff3 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -261,7 +261,7 @@ impl Server { setter: setter.to_string(), expires: if duration == 0 { 0 } else { n + duration }, }); - self.snotice(&format!( + self.snotice_c('x', &format!( "{setter} added a {}-line on {mask}: {reason}", kind.tag() ));