opers: wire eight more privileges over their raw is_oper gates

Convert the remaining oper bypass/visibility gates to named privileges:
channels/restricted-create, channels/ignore-nonicks, users/ignore-callerid,
users/secret-whois, users/ignore-restrictmsg, servers/use-disabled-commands,
servers/ignore-securelist, servers/ignore-blockamsg. The override class gains
the channel/message/anti-spam bypasses, auspex gains secret-whois +
ignore-callerid, server gains use-disabled-commands; netadmin keeps all via
privs=*. Docs updated; verified restricted-create + ignore-nonicks live.
This commit is contained in:
Jean Chevronnet 2026-08-29 13:11:08 +00:00
parent bbe2682478
commit 5dfedb5091
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
11 changed files with 76 additions and 21 deletions

View file

@ -86,8 +86,22 @@ command. Assign them via `privs=` on a class or type (`*` = all, `-x` removes on
| `channels/auspex` | secret/private (`+s`/`+p`) channels and their members in `/LIST`, `/WHO`, `/WHOIS`, `/NAMES` | | `channels/auspex` | secret/private (`+s`/`+p`) channels and their members in `/LIST`, `/WHO`, `/WHOIS`, `/NAMES` |
| `servers/auspex` | U-lined/services servers otherwise hidden by `hideservices` in `/MAP` & `/LINKS` | | `servers/auspex` | U-lined/services servers otherwise hidden by `hideservices` in `/MAP` & `/LINKS` |
| `channels/override` | join through `+k`/`+b`/`+i`/`+l`/`+z`/`+R`/`+J`, a `CBAN`, and the max-channels cap | | `channels/override` | join through `+k`/`+b`/`+i`/`+l`/`+z`/`+R`/`+J`, a `CBAN`, and the max-channels cap |
| `channels/restricted-create` | create a new channel while `restrictchans` is on |
| `channels/ignore-nonicks` | change nick while on a `+N` (no-nick-change) channel |
| `users/flood` | exemption from the message- and join-flood limits | | `users/flood` | exemption from the message- and join-flood limits |
| `users/ignore-commonchans` | message a `+c` user without sharing a common channel | | `users/ignore-commonchans` | message a `+c` user without sharing a common channel |
| `users/ignore-callerid` | message a `+g` (caller-ID) user without being on their accept list |
| `users/ignore-restrictmsg` | private-message anyone while `restrictmsg` is on |
| `users/secret-whois` | `/WHOIS` a `+W` (showwhois) user without notifying them |
| `servers/use-disabled-commands` | use a command turned off by `disabled_commands` |
| `servers/ignore-securelist` | bypass the `securelist` LIST hold for fresh connections |
| `servers/ignore-blockamsg` | send `/AMSG`-style multi-channel messages the `blockamsg` module blocks |
The built-in `override` class carries the channel/message/anti-spam bypasses
(`channels/restricted-create`, `channels/ignore-nonicks`, `users/ignore-restrictmsg`,
`servers/ignore-securelist`, `servers/ignore-blockamsg`), `auspex` carries the
see-through-privacy set (the three `*/auspex` plus `users/secret-whois` and
`users/ignore-callerid`), and `servers/use-disabled-commands` sits on the `server` class.
## Snomasks ## Snomasks

View file

@ -766,8 +766,10 @@ impl Server {
if crate::modules::denychans::intercept(self, uid, name, is_oper) { if crate::modules::denychans::intercept(self, uid, name, is_oper) {
return; return;
} }
// restrictchans — only opers may create new channels (unless whitelisted) // restrictchans — channels/restricted-create may create new channels (unless whitelisted)
if crate::modules::restrictchans::intercept(self, uid, name, is_oper) { let may_create =
crate::modules::opertypes::has_priv(self, uid, crate::modules::opertypes::privs::CHANNELS_RESTRICTED_CREATE);
if crate::modules::restrictchans::intercept(self, uid, name, may_create) {
return; return;
} }
// channames — forbidden characters in new channel names // channames — forbidden characters in new channel names

View file

@ -438,8 +438,11 @@ impl Command for Whois {
&format!("{nick} {idle} {signon} :seconds idle, signon time"), &format!("{nick} {idle} {signon} :seconds idle, signon time"),
); );
} }
// +W showwhois — tell the target that someone looked them up // +W showwhois — tell the target that someone looked them up (users/secret-whois is silent)
if showwhois && !is_self { if showwhois
&& !is_self
&& !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::USERS_SECRET_WHOIS)
{
let by = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); let by = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
let m = s.trf("{0} did a /WHOIS on you", &[by.as_str()]); let m = s.trf("{0} did a /WHOIS on you", &[by.as_str()]);
s.send( s.send(

View file

@ -153,7 +153,9 @@ fn dm_blocked(s: &Server, uid: Uid, tuid: Uid) -> bool {
if ssl_only && !s.users.get(&uid).map(|u| u.secure).unwrap_or(false) { if ssl_only && !s.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
return true; return true;
} }
if callerid { if callerid
&& !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::USERS_IGNORE_CALLERID)
{
let sender_nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); let sender_nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default();
if !s.is_accepted(tuid, &sender_nick) { if !s.is_accepted(tuid, &sender_nick) {
return true; return true;
@ -566,7 +568,11 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
.get(&tuid) .get(&tuid)
.map(|u| u.flags.callerid) .map(|u| u.flags.callerid)
.unwrap_or(false); .unwrap_or(false);
if target_g && uid != tuid && !s.is_accepted(tuid, &sender_nick) { if target_g
&& uid != tuid
&& !s.is_accepted(tuid, &sender_nick)
&& !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::USERS_IGNORE_CALLERID)
{
let (tnick, sident, shost) = { let (tnick, sident, shost) = {
let t = s.users.get(&tuid); let t = s.users.get(&tuid);
let u = s.users.get(&uid); let u = s.users.get(&uid);

View file

@ -511,8 +511,8 @@ impl Command for Nick {
); );
return CmdResult::Fail; return CmdResult::Fail;
} }
// +N — can't change nick while on a no-nick-change channel (opers bypass) // +N — can't change nick while on a no-nick-change channel (channels/ignore-nonicks bypasses)
if !s.is_oper(uid) { if !crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::CHANNELS_IGNORE_NONICKS) {
let blocked = s let blocked = s
.users .users
.get(&uid) .get(&uid)

View file

@ -50,8 +50,8 @@ impl Module for BlockAmsg {
if params.len() < 2 { if params.len() < 2 {
return ModResult::Passthru; return ModResult::Passthru;
} }
// opers bypass the check entirely // servers/ignore-blockamsg opers bypass the check entirely
if srv.is_oper(uid) { if crate::modules::opertypes::has_priv(srv, uid, crate::modules::opertypes::privs::SERVERS_IGNORE_BLOCKAMSG) {
return ModResult::Passthru; return ModResult::Passthru;
} }

View file

@ -29,8 +29,8 @@ impl Module for Disable {
cmd: &str, cmd: &str,
_params: &[String], _params: &[String],
) -> ModResult { ) -> ModResult {
// opers are never restricted // servers/use-disabled-commands opers bypass the disabled list
if srv.is_oper(uid) { if crate::modules::opertypes::has_priv(srv, uid, crate::modules::opertypes::privs::SERVERS_USE_DISABLED_COMMANDS) {
return ModResult::Passthru; return ModResult::Passthru;
} }
// (re)build the set only when the config generation changes, not per command // (re)build the set only when the config generation changes, not per command

View file

@ -36,6 +36,22 @@ pub mod privs {
pub const USERS_IGNORE_COMMONCHANS: &str = "users/ignore-commonchans"; pub const USERS_IGNORE_COMMONCHANS: &str = "users/ignore-commonchans";
/// join through +k/+b/+i/+l/+z/+R/+J, CBAN and the max-channels cap /// join through +k/+b/+i/+l/+z/+R/+J, CBAN and the max-channels cap
pub const CHANNELS_OVERRIDE: &str = "channels/override"; pub const CHANNELS_OVERRIDE: &str = "channels/override";
/// create a new channel while `restrictchans` is on
pub const CHANNELS_RESTRICTED_CREATE: &str = "channels/restricted-create";
/// change nick while on a +N (no-nick-change) channel
pub const CHANNELS_IGNORE_NONICKS: &str = "channels/ignore-nonicks";
/// message a +g (caller-id) user without being on their ACCEPT list
pub const USERS_IGNORE_CALLERID: &str = "users/ignore-callerid";
/// `/WHOIS` a +W (showwhois) user without notifying them
pub const USERS_SECRET_WHOIS: &str = "users/secret-whois";
/// private-message anyone while `restrictmsg` is on
pub const USERS_IGNORE_RESTRICTMSG: &str = "users/ignore-restrictmsg";
/// use a command turned off by `disabled_commands`
pub const SERVERS_USE_DISABLED_COMMANDS: &str = "servers/use-disabled-commands";
/// bypass the `securelist` LIST hold for fresh connections
pub const SERVERS_IGNORE_SECURELIST: &str = "servers/ignore-securelist";
/// send `/AMSG`-style multi-channel messages the `blockamsg` module blocks
pub const SERVERS_IGNORE_BLOCKAMSG: &str = "servers/ignore-blockamsg";
} }
/// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed /// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed
@ -346,12 +362,12 @@ fn builtin() -> (HashMap<String, ClassDef>, HashMap<String, TypeDef>) {
let mut classes: HashMap<String, ClassDef> = HashMap::default(); let mut classes: HashMap<String, ClassDef> = HashMap::default();
classes.insert("announce".into(), cdef(&["WALLOPS", "GLOBOPS"], &[], "ag")); 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("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"], &["channels/override", "users/flood"], "v")); classes.insert("override".into(), cdef(&["SAJOIN", "SAPART", "SANICK", "SAKICK", "SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN"], &["channels/override", "users/flood", "channels/restricted-create", "channels/ignore-nonicks", "users/ignore-restrictmsg", "servers/ignore-securelist", "servers/ignore-blockamsg"], "v"));
classes.insert("host".into(), cdef(&["CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST", "SETIDENT", "SETIDLE", "SWHOIS"], &[], "")); 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("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], ""));
classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr")); classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &["servers/use-disabled-commands"], "lr"));
// auspex: see through user/channel privacy (real host+IP, geo, secret channels) // auspex: see through user/channel privacy (real host+IP, geo, secret channels)
classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex", "servers/auspex"], "")); classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex", "servers/auspex", "users/secret-whois", "users/ignore-callerid"], ""));
let mut types: HashMap<String, TypeDef> = HashMap::default(); let mut types: HashMap<String, TypeDef> = HashMap::default();
// The WHOIS title line is bold + colour 4 (red) by default; override per type // The WHOIS title line is bold + colour 4 (red) by default; override per type
@ -661,6 +677,19 @@ mod tests {
assert!(!r2.all_commands && r2.commands.contains("KILL") && r2.deny_commands.contains("GLINE")); assert!(!r2.all_commands && r2.commands.contains("KILL") && r2.deny_commands.contains("GLINE"));
} }
#[test]
fn builtin_classes_grant_the_new_privileges() {
let (classes, _) = builtin();
let has = |c: &str, p: &str| classes.get(c).unwrap().privs.contains(&p.to_string());
assert!(has("override", "channels/restricted-create") && has("override", "channels/ignore-nonicks"));
assert!(has("override", "users/ignore-restrictmsg"));
assert!(has("override", "servers/ignore-securelist") && has("override", "servers/ignore-blockamsg"));
assert!(has("auspex", "users/secret-whois") && has("auspex", "users/ignore-callerid"));
assert!(has("server", "servers/use-disabled-commands"));
// netadmin holds every class ⇒ every one of the new privileges resolves in
assert!(resolved("netadmin").all_privs);
}
#[test] #[test]
fn gated_covers_the_dangerous_commands_only() { fn gated_covers_the_dangerous_commands_only() {
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT")); assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));

View file

@ -9,9 +9,10 @@ use crate::server::Server;
use crate::Uid; use crate::Uid;
/// Called from `Server::join`. Returns true when creating `name` should be blocked /// Called from `Server::join`. Returns true when creating `name` should be blocked
/// (the caller returns without joining). Opers and joins to *existing* channels pass. /// (the caller returns without joining). Holders of `channels/restricted-create` and
pub fn intercept(s: &mut Server, uid: Uid, name: &str, is_oper: bool) -> bool { /// joins to *existing* channels pass.
if is_oper || !s.conf_bool("restrictchans", false) { pub fn intercept(s: &mut Server, uid: Uid, name: &str, may_create: bool) -> bool {
if may_create || !s.conf_bool("restrictchans", false) {
return false; return false;
} }
// joining a channel that already exists is always fine // joining a channel that already exists is always fine

View file

@ -29,8 +29,8 @@ impl Module for RestrictMsg {
if target.starts_with('#') { if target.starts_with('#') {
return ModResult::Passthru; return ModResult::Passthru;
} }
// sender opers may message anyone // users/ignore-restrictmsg senders may message anyone
if srv.is_oper(uid) { if crate::modules::opertypes::has_priv(srv, uid, crate::modules::opertypes::privs::USERS_IGNORE_RESTRICTMSG) {
return ModResult::Passthru; return ModResult::Passthru;
} }
let Some(tuid) = srv.find_nick(target) else { let Some(tuid) = srv.find_nick(target) else {

View file

@ -39,7 +39,7 @@ fn rand_name(len: usize) -> String {
/// Is `uid` exempt from the LIST hold? /// Is `uid` exempt from the LIST hold?
fn is_exempt(s: &Server, uid: Uid) -> bool { fn is_exempt(s: &Server, uid: Uid) -> bool {
if s.is_oper(uid) { if crate::modules::opertypes::has_priv(s, uid, crate::modules::opertypes::privs::SERVERS_IGNORE_SECURELIST) {
return true; return true;
} }
if s.conf_bool("securelist_exemptregistered", true) && s.is_logged_in(uid) { if s.conf_bool("securelist_exemptregistered", true) && s.is_logged_in(uid) {