opers: organize privileges and enforce them at flood/join/visibility gates

Add a central privs registry and convert raw is_oper gates to named
privileges: users/flood (message + join flood exempt), channels/override
(the +k/+b/+i/+l/+z/+R/+J / CBAN / max-channels join bypasses),
servers/auspex (hidden services in LINKS/MAP), users/ignore-commonchans
(+c PM gate). The override class now grants channels/override + users/flood;
the auspex class adds servers/auspex. netadmin keeps all via privs=*.
This commit is contained in:
Jean Chevronnet 2026-08-29 02:05:23 +00:00
parent ff380d601d
commit bc412c218e
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 48 additions and 21 deletions

View file

@ -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,

View file

@ -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<String> = s
.servers
.values()

View file

@ -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()

View file

@ -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(),

View file

@ -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);

View file

@ -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<String, ClassDef>, HashMap<String, TypeDef>) {
let mut classes: HashMap<String, ClassDef> = 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<String, TypeDef> = 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);