diff --git a/src/channels.rs b/src/channels.rs index 9fd61b6..ec4c64a 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -731,12 +731,15 @@ impl Server { { return; // unknown user, or already joined } - // IRC operators override the join restrictions below; each bypass sets - // `overrode`, snoticed once the join succeeds. + // opers holding channels/override bypass the join restrictions below; each + // bypass sets `overrode`, snoticed once the join succeeds. `is_oper` still gates + // the +O join *requirement* and the create/badchan module intercepts. let is_oper = self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false); + let can_override = + crate::modules::opertypes::has_priv(self, uid, crate::modules::opertypes::privs::CHANNELS_OVERRIDE); let mut overrode = false; // connectclass max-channels cap (opers exempt) - if !is_oper { + if !can_override { 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( @@ -749,7 +752,7 @@ impl Server { } } // CBAN — a forbidden channel name (opers bypass) - if !is_oper { + if !can_override { if let Some(reason) = self.matched_cban(&key) { self.numeric( uid, @@ -775,7 +778,7 @@ impl Server { if let Some(ch) = self.channels.get(&key) { if let Some(k) = &ch.modes.key { if key_arg != Some(k.as_str()) { - if !is_oper { + if !can_override { self.numeric( uid, ERR_BADCHANNELKEY, @@ -789,7 +792,7 @@ impl Server { // +b — bans block even an invited user, unless a +e exception matches // (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 { + if !can_override { // banredirect: `+b mask$#chan` bounces the user into #chan (once) if let Some(t) = crate::modules::banredirect::redirect_target(self, uid, &key) { let tl = t.to_ascii_lowercase(); @@ -819,7 +822,7 @@ impl Server { && !ch.invites.contains(&uid) && !self.ban_list_hit(uid, &ch.invex) { - if !is_oper { + if !can_override { self.numeric( uid, ERR_INVITEONLYCHAN, @@ -831,7 +834,7 @@ impl Server { } // +z — TLS-connected users only if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) { - if !is_oper { + if !can_override { self.numeric( uid, ERR_SECUREONLYCHAN, @@ -858,7 +861,7 @@ impl Server { .map(|u| u.account.is_none()) .unwrap_or(true) { - if !is_oper { + if !can_override { self.numeric( uid, ERR_NEEDREGGEDNICK, @@ -872,7 +875,7 @@ impl Server { if let Some(secs) = ch.modes.kicknorejoin { if let Some(&kt) = ch.recent_kicks.get(&uid) { if now().saturating_sub(kt) < secs as u64 { - if !is_oper { + if !can_override { self.numeric( uid, ERR_DELAYREJOIN, @@ -896,7 +899,7 @@ impl Server { .limit .is_some_and(|l| (ch.members.len() + ch.rmembers.len()) as u32 >= l); let redirect = ch.modes.redirect.clone(); - if full && is_oper { + if full && can_override { overrode = true; } else if full { match redirect { @@ -927,7 +930,7 @@ impl Server { } } // +j join flood — once tripped, the channel locks new joins out for 60s (opers exempt) - if !is_oper && self.channels.contains_key(&key) && self.joinflood_check(&key) { + if !can_override && self.channels.contains_key(&key) && self.joinflood_check(&key) { self.numeric( uid, ERR_UNAVAILRESOURCE, diff --git a/src/coremods/core_extra.rs b/src/coremods/core_extra.rs index d07b39e..ef5d669 100644 --- a/src/coremods/core_extra.rs +++ b/src/coremods/core_extra.rs @@ -401,7 +401,8 @@ impl Command for Map { &format!("{} ({} users)", s.name, s.users.len()), ); // hideservices: services (U-lined) servers are hidden from non-opers. - let hide_svc = s.conf_bool("hideservices", false) && !s.is_oper(uid); + let hide_svc = s.conf_bool("hideservices", false) + && !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::SERVERS_AUSPEX); let mut peers: Vec = s .servers .values() diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index baaeaff..22de721 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -75,7 +75,8 @@ impl Command for Links { &format!("{} {} :0 {}", s.name, s.name, s.server_desc), ); // hideservices: services (U-lined) servers are hidden from non-opers. - let hide_svc = s.conf_bool("hideservices", false) && !s.is_oper(uid); + let hide_svc = s.conf_bool("hideservices", false) + && !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::SERVERS_AUSPEX); let mut rows: Vec<(String, String)> = s .servers .values() diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 6df0868..fd3402a 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -136,7 +136,9 @@ fn dm_blocked(s: &Server, uid: Uid, tuid: Uid) -> bool { ), None => return true, }; - if deny_uncommon && !s.is_oper(uid) { + if deny_uncommon + && !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::USERS_IGNORE_COMMONCHANS) + { let common = match (s.users.get(&uid), s.users.get(&tuid)) { (Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(), _ => false, @@ -485,7 +487,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) .map(|u| u.flags.deny_uncommon) .unwrap_or(false) && uid != tuid - && !s.is_oper(uid) + && !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::USERS_IGNORE_COMMONCHANS) { let common = match (s.users.get(&uid), s.users.get(&tuid)) { (Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(), diff --git a/src/modules/flood.rs b/src/modules/flood.rs index 578ea63..bab05d0 100644 --- a/src/modules/flood.rs +++ b/src/modules/flood.rs @@ -44,8 +44,9 @@ impl Module for Flood { let Some(u) = srv.users.get_mut(&uid) else { return ModResult::Passthru; }; - if u.flags.oper { - return ModResult::Passthru; // opers bypass flood limits + // opers holding users/flood bypass the message-rate limit + if crate::modules::opertypes::user_has_priv(u, crate::modules::opertypes::privs::USERS_FLOOD) { + return ModResult::Passthru; } let st = u.ext.get_or_insert_with(FloodState::default); st.times.retain(|&t| now.saturating_sub(t) < window); diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs index b691f58..45e2164 100644 --- a/src/modules/opertypes.rs +++ b/src/modules/opertypes.rs @@ -19,6 +19,25 @@ use crate::server::Server; use crate::users::DEFAULT_SNOMASK; use crate::Uid; +/// Canonical operator privilege names, grouped by domain. Every gate calls +/// [`has_priv`]/[`user_has_priv`] with one of these constants, so the strings live +/// in one place instead of drifting as scattered literals. Config `class privs="…"` +/// uses the same strings (space/comma separated; `*` grants all). +pub mod privs { + /// see a user's real host+IP and geo, and +i users you share no channel with + pub const USERS_AUSPEX: &str = "users/auspex"; + /// see secret/private (+s/+p) channels in LIST / WHO / WHOIS + pub const CHANNELS_AUSPEX: &str = "channels/auspex"; + /// see U-lined/services servers otherwise hidden by `hideservices` + pub const SERVERS_AUSPEX: &str = "servers/auspex"; + /// exempt from message-flood and join-flood limits + pub const USERS_FLOOD: &str = "users/flood"; + /// message a +c user without sharing a common channel + pub const USERS_IGNORE_COMMONCHANS: &str = "users/ignore-commonchans"; + /// join through +k/+b/+i/+l/+z/+R/+J, CBAN and the max-channels cap + pub const CHANNELS_OVERRIDE: &str = "channels/override"; +} + /// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed /// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title. pub struct OperType { @@ -247,12 +266,12 @@ fn builtin() -> (HashMap, HashMap) { let mut classes: HashMap = HashMap::default(); classes.insert("announce".into(), cdef(&["WALLOPS", "GLOBOPS"], &[], "ag")); classes.insert("ban".into(), cdef(&["KILL", "KLINE", "GLINE", "ZLINE", "QLINE", "ELINE", "RLINE", "SHUN", "CBAN", "CHECK", "NICKLOCK", "NICKUNLOCK"], &[], "kx")); - classes.insert("override".into(), cdef(&["SAJOIN", "SAPART", "SANICK", "SAKICK", "SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN"], &["override"], "v")); + classes.insert("override".into(), cdef(&["SAJOIN", "SAPART", "SANICK", "SAKICK", "SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN"], &["channels/override", "users/flood"], "v")); classes.insert("host".into(), cdef(&["CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST", "SETIDENT", "SETIDLE", "SWHOIS"], &[], "")); classes.insert("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], "")); classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr")); // auspex: see through user/channel privacy (real host+IP, geo, secret channels) - classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex"], "")); + classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex", "servers/auspex"], "")); let mut types: HashMap = HashMap::default(); // The WHOIS title line is bold + colour 4 (red) by default; override per type @@ -458,7 +477,7 @@ mod tests { let admin = resolved("admin"); assert!(admin.commands.contains("KILL") && admin.commands.contains("SAJOIN") && admin.commands.contains("CHGHOST")); - assert!(admin.privs.contains("override")); + assert!(admin.privs.contains("channels/override") && admin.privs.contains("users/flood")); assert!(!admin.commands.contains("DIE"), "admin can't DIE"); assert!(!admin.commands.contains("SVSNICK"), "admin isn't a services admin"); assert!(admin.all_snomasks);