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:
parent
ff380d601d
commit
bc412c218e
6 changed files with 48 additions and 21 deletions
|
|
@ -731,12 +731,15 @@ impl Server {
|
||||||
{
|
{
|
||||||
return; // unknown user, or already joined
|
return; // unknown user, or already joined
|
||||||
}
|
}
|
||||||
// IRC operators override the join restrictions below; each bypass sets
|
// opers holding channels/override bypass the join restrictions below; each
|
||||||
// `overrode`, snoticed once the join succeeds.
|
// 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 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;
|
let mut overrode = false;
|
||||||
// connectclass max-channels cap (opers exempt)
|
// connectclass max-channels cap (opers exempt)
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
if let Some(max) = crate::modules::connclass::max_chans(self, uid) {
|
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 {
|
if self.users.get(&uid).map(|u| u.channels.len()).unwrap_or(0) >= max {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
|
|
@ -749,7 +752,7 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CBAN — a forbidden channel name (opers bypass)
|
// CBAN — a forbidden channel name (opers bypass)
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
if let Some(reason) = self.matched_cban(&key) {
|
if let Some(reason) = self.matched_cban(&key) {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -775,7 +778,7 @@ impl Server {
|
||||||
if let Some(ch) = self.channels.get(&key) {
|
if let Some(ch) = self.channels.get(&key) {
|
||||||
if let Some(k) = &ch.modes.key {
|
if let Some(k) = &ch.modes.key {
|
||||||
if key_arg != Some(k.as_str()) {
|
if key_arg != Some(k.as_str()) {
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_BADCHANNELKEY,
|
ERR_BADCHANNELKEY,
|
||||||
|
|
@ -789,7 +792,7 @@ impl Server {
|
||||||
// +b — bans block even an invited user, unless a +e exception matches
|
// +b — bans block even an invited user, unless a +e exception matches
|
||||||
// (both honour the g: security-group extban)
|
// (both honour the g: security-group extban)
|
||||||
if self.ban_list_hit(uid, &ch.bans) && !self.ban_list_hit(uid, &ch.excepts) {
|
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)
|
// banredirect: `+b mask$#chan` bounces the user into #chan (once)
|
||||||
if let Some(t) = crate::modules::banredirect::redirect_target(self, uid, &key) {
|
if let Some(t) = crate::modules::banredirect::redirect_target(self, uid, &key) {
|
||||||
let tl = t.to_ascii_lowercase();
|
let tl = t.to_ascii_lowercase();
|
||||||
|
|
@ -819,7 +822,7 @@ impl Server {
|
||||||
&& !ch.invites.contains(&uid)
|
&& !ch.invites.contains(&uid)
|
||||||
&& !self.ban_list_hit(uid, &ch.invex)
|
&& !self.ban_list_hit(uid, &ch.invex)
|
||||||
{
|
{
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_INVITEONLYCHAN,
|
ERR_INVITEONLYCHAN,
|
||||||
|
|
@ -831,7 +834,7 @@ impl Server {
|
||||||
}
|
}
|
||||||
// +z — TLS-connected users only
|
// +z — TLS-connected users only
|
||||||
if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
|
if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_SECUREONLYCHAN,
|
ERR_SECUREONLYCHAN,
|
||||||
|
|
@ -858,7 +861,7 @@ impl Server {
|
||||||
.map(|u| u.account.is_none())
|
.map(|u| u.account.is_none())
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
{
|
{
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_NEEDREGGEDNICK,
|
ERR_NEEDREGGEDNICK,
|
||||||
|
|
@ -872,7 +875,7 @@ impl Server {
|
||||||
if let Some(secs) = ch.modes.kicknorejoin {
|
if let Some(secs) = ch.modes.kicknorejoin {
|
||||||
if let Some(&kt) = ch.recent_kicks.get(&uid) {
|
if let Some(&kt) = ch.recent_kicks.get(&uid) {
|
||||||
if now().saturating_sub(kt) < secs as u64 {
|
if now().saturating_sub(kt) < secs as u64 {
|
||||||
if !is_oper {
|
if !can_override {
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_DELAYREJOIN,
|
ERR_DELAYREJOIN,
|
||||||
|
|
@ -896,7 +899,7 @@ impl Server {
|
||||||
.limit
|
.limit
|
||||||
.is_some_and(|l| (ch.members.len() + ch.rmembers.len()) as u32 >= l);
|
.is_some_and(|l| (ch.members.len() + ch.rmembers.len()) as u32 >= l);
|
||||||
let redirect = ch.modes.redirect.clone();
|
let redirect = ch.modes.redirect.clone();
|
||||||
if full && is_oper {
|
if full && can_override {
|
||||||
overrode = true;
|
overrode = true;
|
||||||
} else if full {
|
} else if full {
|
||||||
match redirect {
|
match redirect {
|
||||||
|
|
@ -927,7 +930,7 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// +j join flood — once tripped, the channel locks new joins out for 60s (opers exempt)
|
// +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(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
ERR_UNAVAILRESOURCE,
|
ERR_UNAVAILRESOURCE,
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,8 @@ impl Command for Map {
|
||||||
&format!("{} ({} users)", s.name, s.users.len()),
|
&format!("{} ({} users)", s.name, s.users.len()),
|
||||||
);
|
);
|
||||||
// hideservices: services (U-lined) servers are hidden from non-opers.
|
// 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
|
let mut peers: Vec<String> = s
|
||||||
.servers
|
.servers
|
||||||
.values()
|
.values()
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ impl Command for Links {
|
||||||
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
||||||
);
|
);
|
||||||
// hideservices: services (U-lined) servers are hidden from non-opers.
|
// 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
|
let mut rows: Vec<(String, String)> = s
|
||||||
.servers
|
.servers
|
||||||
.values()
|
.values()
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ fn dm_blocked(s: &Server, uid: Uid, tuid: Uid) -> bool {
|
||||||
),
|
),
|
||||||
None => return true,
|
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)) {
|
let common = match (s.users.get(&uid), s.users.get(&tuid)) {
|
||||||
(Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(),
|
(Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(),
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|
@ -485,7 +487,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
.map(|u| u.flags.deny_uncommon)
|
.map(|u| u.flags.deny_uncommon)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
&& uid != tuid
|
&& 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)) {
|
let common = match (s.users.get(&uid), s.users.get(&tuid)) {
|
||||||
(Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(),
|
(Some(a), Some(b)) => a.channels.intersection(&b.channels).next().is_some(),
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,9 @@ impl Module for Flood {
|
||||||
let Some(u) = srv.users.get_mut(&uid) else {
|
let Some(u) = srv.users.get_mut(&uid) else {
|
||||||
return ModResult::Passthru;
|
return ModResult::Passthru;
|
||||||
};
|
};
|
||||||
if u.flags.oper {
|
// opers holding users/flood bypass the message-rate limit
|
||||||
return ModResult::Passthru; // opers bypass flood limits
|
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);
|
let st = u.ext.get_or_insert_with(FloodState::default);
|
||||||
st.times.retain(|&t| now.saturating_sub(t) < window);
|
st.times.retain(|&t| now.saturating_sub(t) < window);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,25 @@ use crate::server::Server;
|
||||||
use crate::users::DEFAULT_SNOMASK;
|
use crate::users::DEFAULT_SNOMASK;
|
||||||
use crate::Uid;
|
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
|
/// 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.
|
/// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title.
|
||||||
pub struct OperType {
|
pub struct OperType {
|
||||||
|
|
@ -247,12 +266,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"], &["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("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"], &[], "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"], ""));
|
classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex", "servers/auspex"], ""));
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -458,7 +477,7 @@ mod tests {
|
||||||
|
|
||||||
let admin = resolved("admin");
|
let admin = resolved("admin");
|
||||||
assert!(admin.commands.contains("KILL") && admin.commands.contains("SAJOIN") && admin.commands.contains("CHGHOST"));
|
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("DIE"), "admin can't DIE");
|
||||||
assert!(!admin.commands.contains("SVSNICK"), "admin isn't a services admin");
|
assert!(!admin.commands.contains("SVSNICK"), "admin isn't a services admin");
|
||||||
assert!(admin.all_snomasks);
|
assert!(admin.all_snomasks);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue