opertypes: oper classes + types — reusable capability classes and named roles (WHOIS title, auto usermodes/snomasks/vhost + level on oper-up, per-type command enforcement via on_pre_command); ships 5 built-in (helpop/globop/admin/servadmin/netadmin); oper blocks gain type=<id>; a typeless oper keeps full access
This commit is contained in:
parent
f3ce0e61ef
commit
01516d5fdc
6 changed files with 509 additions and 5 deletions
|
|
@ -40,6 +40,7 @@ pub struct OperBlock {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub level: u32,
|
pub level: u32,
|
||||||
pub fingerprint: Option<String>,
|
pub fingerprint: Option<String>,
|
||||||
|
pub oper_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's
|
/// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's
|
||||||
|
|
@ -233,14 +234,18 @@ impl Config {
|
||||||
password: p.to_string(),
|
password: p.to_string(),
|
||||||
level: 0,
|
level: 0,
|
||||||
fingerprint: None,
|
fingerprint: None,
|
||||||
|
oper_type: None,
|
||||||
};
|
};
|
||||||
// trailing tokens (any order): a number is the operlevel, a
|
// trailing tokens (any order): a number is the operlevel, a
|
||||||
// `fp=`/`certfp=` token is the required TLS cert fingerprint.
|
// `fp=`/`certfp=` token is the required TLS cert fingerprint,
|
||||||
|
// a `type=` token names the oper type (see modules::opertypes).
|
||||||
for tok in it {
|
for tok in it {
|
||||||
if let Some(fp) =
|
if let Some(fp) =
|
||||||
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
|
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
|
||||||
{
|
{
|
||||||
b.fingerprint = Some(fp.to_ascii_lowercase());
|
b.fingerprint = Some(fp.to_ascii_lowercase());
|
||||||
|
} else if let Some(t) = tok.strip_prefix("type=") {
|
||||||
|
b.oper_type = Some(t.to_string());
|
||||||
} else if let Ok(l) = tok.parse::<u32>() {
|
} else if let Ok(l) = tok.parse::<u32>() {
|
||||||
b.level = l;
|
b.level = l;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,12 +298,20 @@ impl Command for Whois {
|
||||||
s.numeric(uid, RPL_WHOISCHANNELS, &format!("{nick} :{line}"));
|
s.numeric(uid, RPL_WHOISCHANNELS, &format!("{nick} :{line}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 313: is an IRC operator (hidden by +H unless the asker is an oper)
|
// 313: is a/an <oper type> (the type's title, else plain "IRC operator");
|
||||||
|
// hidden by +H unless the asker is an oper.
|
||||||
if oper && (!hideoper || asker_oper) {
|
if oper && (!hideoper || asker_oper) {
|
||||||
|
let title = crate::modules::opertypes::title_of(s, tuid)
|
||||||
|
.unwrap_or_else(|| "IRC operator".to_string());
|
||||||
|
let article = if title.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) {
|
||||||
|
"an"
|
||||||
|
} else {
|
||||||
|
"a"
|
||||||
|
};
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
RPL_WHOISOPERATOR,
|
RPL_WHOISOPERATOR,
|
||||||
&format!("{nick} :is an IRC operator"),
|
&format!("{nick} :is {article} {title}"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 320: oper-set SWHOIS line. No redundant target-nick param — just the
|
// 320: oper-set SWHOIS line. No redundant target-nick param — just the
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ impl Command for Oper {
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
};
|
};
|
||||||
let (hash, level) = (block.password.clone(), block.level);
|
let (hash, level) = (block.password.clone(), block.level);
|
||||||
|
let otype = block.oper_type.clone();
|
||||||
// fingerprint login: the block demands a specific TLS client-cert SHA-256
|
// fingerprint login: the block demands a specific TLS client-cert SHA-256
|
||||||
// fingerprint, so the user must be on a matching certificate.
|
// fingerprint, so the user must be on a matching certificate.
|
||||||
if let Some(want_fp) = &block.fingerprint {
|
if let Some(want_fp) = &block.fingerprint {
|
||||||
|
|
@ -152,14 +153,16 @@ impl Command for Oper {
|
||||||
if hash == "*" {
|
if hash == "*" {
|
||||||
s.oper_up(uid);
|
s.oper_up(uid);
|
||||||
crate::modules::operlevels::set(s, uid, level);
|
crate::modules::operlevels::set(s, uid, level);
|
||||||
|
crate::modules::opertypes::apply(s, uid, otype.as_deref());
|
||||||
return CmdResult::Ok;
|
return CmdResult::Ok;
|
||||||
}
|
}
|
||||||
// a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread
|
// a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread
|
||||||
// (result arrives as OperAuth), so it can't freeze the server or be a DoS.
|
// (result arrives as OperAuth), so it can't freeze the server or be a DoS.
|
||||||
if crate::modules::password_hash::is_slow(&hash) {
|
if crate::modules::password_hash::is_slow(&hash) {
|
||||||
|
let ot = otype.clone();
|
||||||
let ok = s.spawn_crypto(move || {
|
let ok = s.spawn_crypto(move || {
|
||||||
let ok = crate::modules::password_hash::verify(&hash, &pass);
|
let ok = crate::modules::password_hash::verify(&hash, &pass);
|
||||||
crate::ircd::Event::OperAuth { uid, ok, level }
|
crate::ircd::Event::OperAuth { uid, ok, level, oper_type: ot }
|
||||||
});
|
});
|
||||||
if !ok {
|
if !ok {
|
||||||
s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again");
|
s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again");
|
||||||
|
|
@ -171,6 +174,7 @@ impl Command for Oper {
|
||||||
if crate::modules::password_hash::verify(&hash, &pass) {
|
if crate::modules::password_hash::verify(&hash, &pass) {
|
||||||
s.oper_up(uid);
|
s.oper_up(uid);
|
||||||
crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection
|
crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection
|
||||||
|
crate::modules::opertypes::apply(s, uid, otype.as_deref());
|
||||||
CmdResult::Ok
|
CmdResult::Ok
|
||||||
} else {
|
} else {
|
||||||
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ pub enum Event {
|
||||||
uid: Uid,
|
uid: Uid,
|
||||||
ok: bool,
|
ok: bool,
|
||||||
level: u32,
|
level: u32,
|
||||||
|
oper_type: Option<String>,
|
||||||
},
|
},
|
||||||
/// A background MKPASSWD hash finished (KDFs run off the core thread).
|
/// A background MKPASSWD hash finished (KDFs run off the core thread).
|
||||||
MkpasswdResult {
|
MkpasswdResult {
|
||||||
|
|
@ -235,10 +236,11 @@ impl Ircd {
|
||||||
crate::modules::ident::on_result(&mut self.server, uid, ident);
|
crate::modules::ident::on_result(&mut self.server, uid, ident);
|
||||||
self.try_register(uid); // ident may have been the last hold
|
self.try_register(uid); // ident may have been the last hold
|
||||||
}
|
}
|
||||||
Event::OperAuth { uid, ok, level } => {
|
Event::OperAuth { uid, ok, level, oper_type } => {
|
||||||
if ok {
|
if ok {
|
||||||
self.server.oper_up(uid);
|
self.server.oper_up(uid);
|
||||||
crate::modules::operlevels::set(&mut self.server, uid, level);
|
crate::modules::operlevels::set(&mut self.server, uid, level);
|
||||||
|
crate::modules::opertypes::apply(&mut self.server, uid, oper_type.as_deref());
|
||||||
} else if self.server.users.contains_key(&uid) {
|
} else if self.server.users.contains_key(&uid) {
|
||||||
self.server
|
self.server
|
||||||
.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ pub mod network_icon;
|
||||||
pub mod ojoin;
|
pub mod ojoin;
|
||||||
pub mod operlevels;
|
pub mod operlevels;
|
||||||
pub mod operprefix;
|
pub mod operprefix;
|
||||||
|
pub mod opertypes;
|
||||||
pub mod password_hash;
|
pub mod password_hash;
|
||||||
pub mod permchannels;
|
pub mod permchannels;
|
||||||
pub mod profilelink;
|
pub mod profilelink;
|
||||||
|
|
@ -112,6 +113,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||||
Box::new(autoop::AutoOp),
|
Box::new(autoop::AutoOp),
|
||||||
Box::new(autodrop::AutoDrop),
|
Box::new(autodrop::AutoDrop),
|
||||||
Box::new(operprefix::OperPrefix),
|
Box::new(operprefix::OperPrefix),
|
||||||
|
Box::new(opertypes::OperTypes),
|
||||||
Box::new(permchannels::PermChannels::default()),
|
Box::new(permchannels::PermChannels::default()),
|
||||||
Box::new(chathistory::ChatHistoryGc),
|
Box::new(chathistory::ChatHistoryGc),
|
||||||
Box::new(account_registration::AcctRegGc),
|
Box::new(account_registration::AcctRegGc),
|
||||||
|
|
|
||||||
483
src/modules/opertypes.rs
Normal file
483
src/modules/opertypes.rs
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
//! opertypes — oper classes + types. A `class` is a reusable
|
||||||
|
//! capability bundle (commands / privs / snomasks); an `opertype` is a named role
|
||||||
|
//! (the WHOIS title) built from classes plus auto usermodes / snomasks / vhost. An
|
||||||
|
//! `oper` block selects one with `type=<id>`. Enforced through `on_pre_command`; an
|
||||||
|
//! oper with no type keeps full access (legacy). Five types ship built-in.
|
||||||
|
//!
|
||||||
|
//! Config (repeatable):
|
||||||
|
//! class = <id> commands=A,B privs=x,y snomasks=abc
|
||||||
|
//! opertype = <id> classes=c1,c2 [commands=..] [privs=..] modes=+iw snomasks=+cg \
|
||||||
|
//! [vhost=host.name] [title=Nice_Title] [level=N]
|
||||||
|
//! oper = <name> <pass> type=<id> [fp=..]
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::map::HashMap;
|
||||||
|
use crate::module::{ModResult, Module};
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::users::DEFAULT_SNOMASK;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
pub title: String,
|
||||||
|
pub all_commands: bool,
|
||||||
|
pub commands: HashSet<String>,
|
||||||
|
pub all_privs: bool,
|
||||||
|
pub privs: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands an oper type gates. Anything outside this set (OPERMOTD, MKPASSWD,
|
||||||
|
/// ALLTIME, WHOIS, …) is open to every oper.
|
||||||
|
const GATED: &[&str] = &[
|
||||||
|
"KILL", "KLINE", "GLINE", "ZLINE", "QLINE", "ELINE", "RLINE", "SHUN", "CBAN",
|
||||||
|
"CHECK", "NICKLOCK", "NICKUNLOCK", "SAJOIN", "SAPART", "SANICK", "SAKICK",
|
||||||
|
"SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN", "SVSNICK", "SVSJOIN", "SVSPART",
|
||||||
|
"SVSMODE", "SVSLOGIN", "SVSLOGOUT", "CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST",
|
||||||
|
"SETIDENT", "SETIDLE", "SWHOIS", "WALLOPS", "GLOBOPS", "CONNECT", "SQUIT",
|
||||||
|
"DIE", "RESTART",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn gated(cmd: &str) -> bool {
|
||||||
|
GATED.contains(&cmd.to_ascii_uppercase().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OperTypes;
|
||||||
|
|
||||||
|
impl Module for OperTypes {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"opertypes"
|
||||||
|
}
|
||||||
|
fn on_pre_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str, _params: &[String]) -> ModResult {
|
||||||
|
if !srv.is_oper(uid) || !gated(cmd) {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
let up = cmd.to_ascii_uppercase();
|
||||||
|
let (allowed, title) = match srv.users.get(&uid).and_then(|u| u.ext.get::<OperType>()) {
|
||||||
|
None => (true, String::new()), // legacy full-access oper (no type)
|
||||||
|
Some(t) => (t.all_commands || t.commands.contains(&up), t.title.clone()),
|
||||||
|
};
|
||||||
|
if allowed {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
srv.numeric(
|
||||||
|
uid,
|
||||||
|
crate::numeric::ERR_NOPRIVILEGES,
|
||||||
|
&format!(":Permission denied — your \x02{title}\x02 oper type may not use {up}"),
|
||||||
|
);
|
||||||
|
ModResult::Deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WHOIS title of a typed oper, if any (read by core_info's 313).
|
||||||
|
pub fn title_of(s: &Server, uid: Uid) -> Option<String> {
|
||||||
|
s.users.get(&uid).and_then(|u| u.ext.get::<OperType>()).map(|t| t.title.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then
|
||||||
|
/// store the grant + title. A missing type (or an unknown id) leaves the oper with
|
||||||
|
/// full access, so `oper` blocks without `type=` keep working.
|
||||||
|
pub fn apply(s: &mut Server, uid: Uid, type_id: Option<&str>) {
|
||||||
|
let Some(id) = type_id.map(|t| t.to_ascii_lowercase()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(r) = with_resolved(s, |m| m.get(&id).cloned()) else {
|
||||||
|
s.snotice_c('o', &format!("oper type '{id}' is not defined — granting full access"));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !r.modes.is_empty() {
|
||||||
|
crate::coremods::core_mode::svs_set_user_modes(s, uid, &r.modes);
|
||||||
|
}
|
||||||
|
if r.all_snomasks {
|
||||||
|
set_snomask(s, uid, DEFAULT_SNOMASK);
|
||||||
|
} else if let Some(letters) = &r.snomasks {
|
||||||
|
set_snomask(s, uid, letters);
|
||||||
|
}
|
||||||
|
if let Some(h) = &r.vhost {
|
||||||
|
s.change_host_ident(uid, None, Some(h));
|
||||||
|
}
|
||||||
|
if let Some(lvl) = r.level {
|
||||||
|
crate::modules::operlevels::set(s, uid, lvl);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.ext.set(OperType {
|
||||||
|
title: r.title.clone(),
|
||||||
|
all_commands: r.all_commands,
|
||||||
|
commands: r.commands.clone(),
|
||||||
|
all_privs: r.all_privs,
|
||||||
|
privs: r.privs.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_snomask(s: &mut Server, uid: Uid, letters: &str) {
|
||||||
|
let cats: String = letters.chars().filter(|c| DEFAULT_SNOMASK.contains(*c)).collect();
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.flags.snomask = !cats.is_empty();
|
||||||
|
u.flags.snomask_cats = cats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolved types (built-in + config), rebuilt on rehash -------------------
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Resolved {
|
||||||
|
title: String,
|
||||||
|
all_commands: bool,
|
||||||
|
commands: HashSet<String>,
|
||||||
|
all_privs: bool,
|
||||||
|
privs: HashSet<String>,
|
||||||
|
modes: String,
|
||||||
|
snomasks: Option<String>, // Some(letters) auto-set; None = keep the oper-up default
|
||||||
|
all_snomasks: bool,
|
||||||
|
vhost: Option<String>,
|
||||||
|
level: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static TYPES: RefCell<(u64, HashMap<String, Resolved>)> =
|
||||||
|
RefCell::new((u64::MAX, HashMap::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_resolved<R>(s: &Server, f: impl FnOnce(&HashMap<String, Resolved>) -> R) -> R {
|
||||||
|
TYPES.with(|cell| {
|
||||||
|
if cell.borrow().0 != s.config_gen {
|
||||||
|
let fresh = build_types(s);
|
||||||
|
*cell.borrow_mut() = (s.config_gen, fresh);
|
||||||
|
}
|
||||||
|
f(&cell.borrow().1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct ClassDef {
|
||||||
|
all_commands: bool,
|
||||||
|
commands: Vec<String>,
|
||||||
|
all_privs: bool,
|
||||||
|
privs: Vec<String>,
|
||||||
|
all_snomasks: bool,
|
||||||
|
snomasks: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct TypeDef {
|
||||||
|
title: String,
|
||||||
|
all_classes: bool,
|
||||||
|
classes: Vec<String>,
|
||||||
|
all_commands: bool,
|
||||||
|
commands: Vec<String>,
|
||||||
|
all_privs: bool,
|
||||||
|
privs: Vec<String>,
|
||||||
|
modes: String,
|
||||||
|
all_snomasks: bool,
|
||||||
|
snomasks: String,
|
||||||
|
vhost: Option<String>,
|
||||||
|
level: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef {
|
||||||
|
ClassDef {
|
||||||
|
commands: commands.iter().map(|c| c.to_string()).collect(),
|
||||||
|
privs: privs.iter().map(|p| p.to_string()).collect(),
|
||||||
|
snomasks: sno.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str, all_sno: bool, level: u32) -> TypeDef {
|
||||||
|
TypeDef {
|
||||||
|
title: title.to_string(),
|
||||||
|
all_classes,
|
||||||
|
classes: classes.iter().map(|s| s.to_string()).collect(),
|
||||||
|
modes: modes.to_string(),
|
||||||
|
snomasks: sno.to_string(),
|
||||||
|
all_snomasks: all_sno,
|
||||||
|
level: Some(level),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The five ships-with-echoIRCd classes + types.
|
||||||
|
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("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"));
|
||||||
|
|
||||||
|
let mut types: HashMap<String, TypeDef> = HashMap::default();
|
||||||
|
// title classes all modes sno all* level
|
||||||
|
types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10));
|
||||||
|
types.insert("globop".into(), tdef("GlobOp", &["announce"], false, "+iw", "acgoq", false, 20));
|
||||||
|
types.insert("admin".into(), tdef("Administrator", &["announce", "ban", "override", "host"], false, "+iw", "", true, 50));
|
||||||
|
types.insert("servadmin".into(), tdef("Services Administrator", &["announce", "ban", "override", "host", "services"], false, "+iw", "", true, 70));
|
||||||
|
types.insert("netadmin".into(), tdef("Network Administrator", &[], true, "+iw", "", true, 100));
|
||||||
|
(classes, types)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_types(s: &Server) -> HashMap<String, Resolved> {
|
||||||
|
let (mut classes, mut types) = builtin();
|
||||||
|
for line in s.conf_all("class") {
|
||||||
|
let mut it = line.split_whitespace();
|
||||||
|
let Some(id) = it.next() else { continue };
|
||||||
|
let mut cd = classes.get(id).cloned().unwrap_or_default();
|
||||||
|
for tok in it {
|
||||||
|
if let Some((k, v)) = tok.split_once('=') {
|
||||||
|
apply_class_kv(&mut cd, k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classes.insert(id.to_string(), cd);
|
||||||
|
}
|
||||||
|
for line in s.conf_all("opertype") {
|
||||||
|
let mut it = line.split_whitespace();
|
||||||
|
let Some(id) = it.next() else { continue };
|
||||||
|
let mut td = types.get(id).cloned().unwrap_or_default();
|
||||||
|
for tok in it {
|
||||||
|
if let Some((k, v)) = tok.split_once('=') {
|
||||||
|
apply_type_kv(&mut td, k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if td.title.is_empty() {
|
||||||
|
td.title = id.to_string();
|
||||||
|
}
|
||||||
|
types.insert(id.to_string(), td);
|
||||||
|
}
|
||||||
|
types.iter().map(|(id, td)| (id.clone(), resolve(td, &classes))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_class_kv(cd: &mut ClassDef, k: &str, v: &str) {
|
||||||
|
match k {
|
||||||
|
"commands" | "cmds" => {
|
||||||
|
if v == "*" {
|
||||||
|
cd.all_commands = true;
|
||||||
|
} else {
|
||||||
|
cd.commands.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_uppercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"privs" => {
|
||||||
|
if v == "*" {
|
||||||
|
cd.all_privs = true;
|
||||||
|
} else {
|
||||||
|
cd.privs.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"snomasks" | "snomask" => {
|
||||||
|
if v.contains('*') {
|
||||||
|
cd.all_snomasks = true;
|
||||||
|
} else {
|
||||||
|
cd.snomasks.push_str(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // usermodes/chanmodes allowlist: accepted but not yet enforced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_type_kv(td: &mut TypeDef, k: &str, v: &str) {
|
||||||
|
match k {
|
||||||
|
"classes" => {
|
||||||
|
if v == "*" {
|
||||||
|
td.all_classes = true;
|
||||||
|
} else {
|
||||||
|
td.classes.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"commands" | "cmds" => {
|
||||||
|
if v == "*" {
|
||||||
|
td.all_commands = true;
|
||||||
|
} else {
|
||||||
|
td.commands.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_uppercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"privs" => {
|
||||||
|
if v == "*" {
|
||||||
|
td.all_privs = true;
|
||||||
|
} else {
|
||||||
|
td.privs.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"modes" | "usermodes" => td.modes = v.to_string(),
|
||||||
|
"snomasks" | "snomask" => {
|
||||||
|
if v.contains('*') {
|
||||||
|
td.all_snomasks = true;
|
||||||
|
} else {
|
||||||
|
td.snomasks.push_str(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"vhost" | "host" => td.vhost = Some(v.to_string()),
|
||||||
|
"title" => td.title = v.replace('_', " "),
|
||||||
|
"level" => {
|
||||||
|
if let Ok(l) = v.parse() {
|
||||||
|
td.level = Some(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // maxchans etc.: accepted, not yet enforced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved {
|
||||||
|
let mut all_commands = td.all_commands || td.all_classes;
|
||||||
|
let mut commands: HashSet<String> = td.commands.iter().cloned().collect();
|
||||||
|
let mut all_privs = td.all_privs || td.all_classes;
|
||||||
|
let mut privs: HashSet<String> = td.privs.iter().cloned().collect();
|
||||||
|
let mut all_sno = td.all_snomasks;
|
||||||
|
let mut sno = td.snomasks.clone();
|
||||||
|
|
||||||
|
let names: Vec<String> = if td.all_classes {
|
||||||
|
classes.keys().cloned().collect()
|
||||||
|
} else {
|
||||||
|
td.classes.clone()
|
||||||
|
};
|
||||||
|
for name in names {
|
||||||
|
if let Some(cd) = classes.get(&name) {
|
||||||
|
all_commands |= cd.all_commands;
|
||||||
|
commands.extend(cd.commands.iter().cloned());
|
||||||
|
all_privs |= cd.all_privs;
|
||||||
|
privs.extend(cd.privs.iter().cloned());
|
||||||
|
all_sno |= cd.all_snomasks;
|
||||||
|
sno.push_str(&cd.snomasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snomasks = if all_sno {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut seen = String::new();
|
||||||
|
for ch in sno.chars() {
|
||||||
|
if DEFAULT_SNOMASK.contains(ch) && !seen.contains(ch) {
|
||||||
|
seen.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(!seen.is_empty()).then_some(seen)
|
||||||
|
};
|
||||||
|
|
||||||
|
Resolved {
|
||||||
|
title: td.title.clone(),
|
||||||
|
all_commands,
|
||||||
|
commands,
|
||||||
|
all_privs,
|
||||||
|
privs,
|
||||||
|
modes: td.modes.clone(),
|
||||||
|
snomasks,
|
||||||
|
all_snomasks: all_sno,
|
||||||
|
vhost: td.vhost.clone(),
|
||||||
|
level: td.level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_types_grant_the_right_commands() {
|
||||||
|
let (classes, types) = builtin();
|
||||||
|
let r = |id: &str| resolve(&types[id], &classes);
|
||||||
|
// Network Administrator: everything.
|
||||||
|
assert!(r("netadmin").all_commands);
|
||||||
|
// Administrator: bans + overrides, but not services or server control.
|
||||||
|
let admin = r("admin");
|
||||||
|
assert!(!admin.all_commands);
|
||||||
|
assert!(admin.commands.contains("KILL"));
|
||||||
|
assert!(admin.commands.contains("SAMODE"));
|
||||||
|
assert!(!admin.commands.contains("CONNECT"));
|
||||||
|
assert!(!admin.commands.contains("SVSNICK"));
|
||||||
|
// Services Administrator: Administrator + services.
|
||||||
|
let sa = r("servadmin");
|
||||||
|
assert!(sa.commands.contains("KILL"));
|
||||||
|
assert!(sa.commands.contains("SVSNICK"));
|
||||||
|
assert!(!sa.commands.contains("CONNECT"));
|
||||||
|
// GlobOp: announce only.
|
||||||
|
let g = r("globop");
|
||||||
|
assert!(g.commands.contains("GLOBOPS"));
|
||||||
|
assert!(!g.commands.contains("KILL"));
|
||||||
|
// Help Operator: no gated commands.
|
||||||
|
assert!(r("helpop").commands.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gated_covers_privileged_commands_only() {
|
||||||
|
assert!(gated("kill") && gated("CONNECT") && gated("SamODE"));
|
||||||
|
assert!(!gated("WHOIS") && !gated("MKPASSWD") && !gated("PRIVMSG"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_opertype_overrides_and_composes() {
|
||||||
|
let (mut classes, mut types) = builtin();
|
||||||
|
// a custom class + type layered on top, as config would add
|
||||||
|
classes.insert("readonly".into(), cdef(&["CHECK"], &[], "t"));
|
||||||
|
let mut td = TypeDef { title: "Watcher".into(), ..Default::default() };
|
||||||
|
apply_type_kv(&mut td, "classes", "readonly");
|
||||||
|
apply_type_kv(&mut td, "commands", "GLOBOPS");
|
||||||
|
types.insert("watcher".into(), td);
|
||||||
|
let r = resolve(&types["watcher"], &classes);
|
||||||
|
assert_eq!(r.title, "Watcher");
|
||||||
|
assert!(r.commands.contains("CHECK") && r.commands.contains("GLOBOPS"));
|
||||||
|
assert!(!r.commands.contains("KILL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn resolved(id: &str) -> Resolved {
|
||||||
|
let (classes, types) = builtin();
|
||||||
|
resolve(types.get(id).expect("builtin type"), &classes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_types_grant_the_right_capabilities() {
|
||||||
|
let helpop = resolved("helpop");
|
||||||
|
assert_eq!(helpop.title, "Help Operator");
|
||||||
|
assert!(!helpop.all_commands);
|
||||||
|
assert!(helpop.commands.is_empty(), "helpop gets no gated commands");
|
||||||
|
assert_eq!(helpop.modes, "+ih");
|
||||||
|
|
||||||
|
let globop = resolved("globop");
|
||||||
|
assert!(globop.commands.contains("GLOBOPS") && globop.commands.contains("WALLOPS"));
|
||||||
|
assert!(!globop.commands.contains("KILL"), "globop can't KILL");
|
||||||
|
|
||||||
|
let admin = resolved("admin");
|
||||||
|
assert!(admin.commands.contains("KILL") && admin.commands.contains("SAJOIN") && admin.commands.contains("CHGHOST"));
|
||||||
|
assert!(admin.privs.contains("override"));
|
||||||
|
assert!(!admin.commands.contains("DIE"), "admin can't DIE");
|
||||||
|
assert!(!admin.commands.contains("SVSNICK"), "admin isn't a services admin");
|
||||||
|
assert!(admin.all_snomasks);
|
||||||
|
|
||||||
|
let servadmin = resolved("servadmin");
|
||||||
|
assert!(servadmin.commands.contains("SVSNICK") && servadmin.commands.contains("KILL"));
|
||||||
|
assert!(!servadmin.commands.contains("DIE"), "servadmin can't DIE");
|
||||||
|
|
||||||
|
let netadmin = resolved("netadmin");
|
||||||
|
assert_eq!(netadmin.title, "Network Administrator");
|
||||||
|
assert!(netadmin.all_commands, "netadmin gets everything");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gated_covers_the_dangerous_commands_only() {
|
||||||
|
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));
|
||||||
|
assert!(!gated("whois") && !gated("opermotd") && !gated("mkpasswd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config `opertype`/`class` extends or overrides the built-ins by id.
|
||||||
|
#[test]
|
||||||
|
fn config_class_and_type_parse() {
|
||||||
|
let mut cd = ClassDef::default();
|
||||||
|
apply_class_kv(&mut cd, "commands", "kill,gline");
|
||||||
|
apply_class_kv(&mut cd, "snomasks", "kx");
|
||||||
|
assert!(cd.commands.contains(&"KILL".to_string()) && cd.commands.contains(&"GLINE".to_string()));
|
||||||
|
assert_eq!(cd.snomasks, "kx");
|
||||||
|
|
||||||
|
let mut td = TypeDef::default();
|
||||||
|
apply_type_kv(&mut td, "classes", "*");
|
||||||
|
apply_type_kv(&mut td, "title", "Big_Boss");
|
||||||
|
apply_type_kv(&mut td, "level", "99");
|
||||||
|
assert!(td.all_classes);
|
||||||
|
assert_eq!(td.title, "Big Boss");
|
||||||
|
assert_eq!(td.level, Some(99));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue