diff --git a/echoircd.conf.example b/echoircd.conf.example index 76ff73f..b5cc3b5 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -136,6 +136,10 @@ amu_target = both # --- autodrop (m_autodrop): silently drop a not-yet-registered client that sends # any of these commands (HTTP scanners blurt GET/POST before NICK/USER): # autodrop_commands = GET POST HEAD CONNECT PUT DELETE OPTIONS TRACE PATCH +# --- hidemode (m_hidemode): hide changes to a mode from members below a rank +# (the setter, opers and links always see it). Repeatable, +# `hidemode = ` (owner|admin|op|halfop|voice). e.g. hide bans: +# hidemode = b op # --- hidelist (m_hidelist): list modes (+b/+e/+I/…) are viewable by members by # default; this restricts a given list to a minimum rank. Repeatable, # `hidelist = ` (rank: owner|admin|op|halfop|voice). Opers see diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index 03152b8..2a6d60d 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -113,6 +113,8 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let mut applied = String::new(); let mut last = ' '; let mut echoed: Vec = Vec::new(); + // (sign, letter, displayed-param) per applied change — for hidemode filtering + let mut changes: Vec<(char, char, Option)> = Vec::new(); for c in modestring.chars() { if c == '+' || c == '-' { sign = c; @@ -138,6 +140,7 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { }; if let Applied::Yes(echo) = handler.apply(s, target, &key, uid, adding, param.as_deref()) { emit(&mut applied, &mut last, sign, c); + changes.push((sign, c, echo.clone())); if let Some(p) = echo { echoed.push(p); } @@ -150,11 +153,21 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { } else { format!(" {}", echoed.join(" ")) }; - s.to_channel( - &key, - &format!(":{prefix} MODE {target} {applied}{pstr}"), - None, - ); + // hidemode: if any changed mode is configured hidden, deliver per-recipient + // so members below the required rank don't see it; the setter, opers and + // linked servers always get the full line. + if changes + .iter() + .any(|(_, c, _)| crate::modules::hidemode::hidden_rank(s, *c).is_some()) + { + crate::modules::hidemode::broadcast(s, &key, target, uid, &prefix, &changes); + } else { + s.to_channel( + &key, + &format!(":{prefix} MODE {target} {applied}{pstr}"), + None, + ); + } s.propagate_from_user(uid, &format!("MODE {target} {applied}{pstr}")); // links } diff --git a/src/modules/hidemode.rs b/src/modules/hidemode.rs new file mode 100644 index 0000000..d8f8810 --- /dev/null +++ b/src/modules/hidemode.rs @@ -0,0 +1,90 @@ +//! hidemode — hide specific mode changes from channel members below a rank, so +//! ordinary users don't see e.g. bans being set/removed. Config, repeatable: +//! `hidemode = ` (rank: owner|admin|op|halfop|voice). The setter, +//! opers and linked servers always see the full change. Reference: InspIRCd's +//! `m_hidemode`. Original native Rust. + +use crate::channels::{RANK_ADMIN, RANK_HALFOP, RANK_OP, RANK_OWNER, RANK_VOICE}; +use crate::server::Server; +use crate::Uid; + +fn rank_value(name: &str) -> u8 { + match name.to_ascii_lowercase().as_str() { + "owner" | "founder" | "q" => RANK_OWNER, + "admin" | "protect" | "a" => RANK_ADMIN, + "op" | "o" => RANK_OP, + "halfop" | "h" => RANK_HALFOP, + "voice" | "v" => RANK_VOICE, + _ => RANK_OP, + } +} + +/// The minimum rank needed to *see* changes to `modechar`, if it's configured +/// hidden. `None` ⇒ the mode is visible to everyone (the common case). +pub fn hidden_rank(s: &Server, modechar: char) -> Option { + for line in s.conf_all("hidemode") { + let mut it = line.split_whitespace(); + if let (Some(mc), Some(rank)) = (it.next(), it.next()) { + if mc.chars().next() == Some(modechar) { + return Some(rank_value(rank)); + } + } + } + None +} + +/// Render a subset of changes back into a ` ` pair. +fn render(changes: &[&(char, char, Option)]) -> (String, Vec) { + let mut modes = String::new(); + let mut last = ' '; + let mut params = Vec::new(); + for (sign, letter, param) in changes { + if *sign != last { + modes.push(*sign); + last = *sign; + } + modes.push(*letter); + if let Some(p) = param { + params.push(p.clone()); + } + } + (modes, params) +} + +/// Deliver a MODE change per-recipient, dropping any hidden mode from the line sent +/// to members below its required rank. Called from `apply_mode` only when at least +/// one changed mode is hidden. +pub fn broadcast( + s: &Server, + key: &str, + target: &str, + setter: Uid, + prefix: &str, + changes: &[(char, char, Option)], +) { + let members: Vec = s + .channels + .get(key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + let privileged = m == setter || s.is_oper(m); + let visible: Vec<&(char, char, Option)> = changes + .iter() + .filter(|(_, c, _)| match hidden_rank(s, *c) { + None => true, + Some(req) => privileged || s.rank(m, key) >= req, + }) + .collect(); + if visible.is_empty() { + continue; + } + let (modes, params) = render(&visible); + let pstr = if params.is_empty() { + String::new() + } else { + format!(" {}", params.join(" ")) + }; + s.send(m, format!(":{prefix} MODE {target} {modes}{pstr}")); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index c84b2dc..b75e7eb 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -34,6 +34,7 @@ pub mod geoip; pub mod globops; pub mod hashident; pub mod hidelist; +pub mod hidemode; pub mod hidewhois; pub mod irccloudtags; pub mod jsonlog;