diff --git a/src/config.rs b/src/config.rs index d909d5d..bd231ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,7 @@ pub struct OperBlock { pub password: String, pub level: u32, pub fingerprint: Option, + pub oper_type: Option, } /// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's @@ -233,14 +234,18 @@ impl Config { password: p.to_string(), level: 0, fingerprint: None, + oper_type: None, }; // 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 { if let Some(fp) = tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp=")) { 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::() { b.level = l; } diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 59f2119..c3f092f 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -298,12 +298,20 @@ impl Command for Whois { 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 (the type's title, else plain "IRC operator"); + // hidden by +H unless the asker is an 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( uid, 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 diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 3709d72..3eaa444 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -128,6 +128,7 @@ impl Command for Oper { return CmdResult::Fail; }; 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, so the user must be on a matching certificate. if let Some(want_fp) = &block.fingerprint { @@ -152,14 +153,16 @@ impl Command for Oper { if hash == "*" { s.oper_up(uid); crate::modules::operlevels::set(s, uid, level); + crate::modules::opertypes::apply(s, uid, otype.as_deref()); return CmdResult::Ok; } // 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. if crate::modules::password_hash::is_slow(&hash) { + let ot = otype.clone(); let ok = s.spawn_crypto(move || { 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 { 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) { s.oper_up(uid); crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection + crate::modules::opertypes::apply(s, uid, otype.as_deref()); CmdResult::Ok } else { s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); diff --git a/src/ircd.rs b/src/ircd.rs index efe9d5a..f870d9b 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -59,6 +59,7 @@ pub enum Event { uid: Uid, ok: bool, level: u32, + oper_type: Option, }, /// A background MKPASSWD hash finished (KDFs run off the core thread). MkpasswdResult { @@ -235,10 +236,11 @@ impl Ircd { crate::modules::ident::on_result(&mut self.server, uid, ident); 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 { self.server.oper_up(uid); 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) { self.server .numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index beae6a5..53e7054 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -54,6 +54,7 @@ pub mod network_icon; pub mod ojoin; pub mod operlevels; pub mod operprefix; +pub mod opertypes; pub mod password_hash; pub mod permchannels; pub mod profilelink; @@ -112,6 +113,7 @@ pub fn default_modules() -> Vec> { Box::new(autoop::AutoOp), Box::new(autodrop::AutoDrop), Box::new(operprefix::OperPrefix), + Box::new(opertypes::OperTypes), Box::new(permchannels::PermChannels::default()), Box::new(chathistory::ChatHistoryGc), Box::new(account_registration::AcctRegGc), diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs new file mode 100644 index 0000000..5c40db4 --- /dev/null +++ b/src/modules/opertypes.rs @@ -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=`. Enforced through `on_pre_command`; an +//! oper with no type keeps full access (legacy). Five types ship built-in. +//! +//! Config (repeatable): +//! class = commands=A,B privs=x,y snomasks=abc +//! opertype = classes=c1,c2 [commands=..] [privs=..] modes=+iw snomasks=+cg \ +//! [vhost=host.name] [title=Nice_Title] [level=N] +//! oper = type= [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, + pub all_privs: bool, + pub privs: HashSet, +} + +/// 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::()) { + 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 { + s.users.get(&uid).and_then(|u| u.ext.get::()).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, + all_privs: bool, + privs: HashSet, + modes: String, + snomasks: Option, // Some(letters) auto-set; None = keep the oper-up default + all_snomasks: bool, + vhost: Option, + level: Option, +} + +thread_local! { + static TYPES: RefCell<(u64, HashMap)> = + RefCell::new((u64::MAX, HashMap::default())); +} + +fn with_resolved(s: &Server, f: impl FnOnce(&HashMap) -> 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, + all_privs: bool, + privs: Vec, + all_snomasks: bool, + snomasks: String, +} + +#[derive(Default, Clone)] +struct TypeDef { + title: String, + all_classes: bool, + classes: Vec, + all_commands: bool, + commands: Vec, + all_privs: bool, + privs: Vec, + modes: String, + all_snomasks: bool, + snomasks: String, + vhost: Option, + level: Option, +} + +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, 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("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 = 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 { + 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) -> Resolved { + let mut all_commands = td.all_commands || td.all_classes; + let mut commands: HashSet = td.commands.iter().cloned().collect(); + let mut all_privs = td.all_privs || td.all_classes; + let mut privs: HashSet = td.privs.iter().cloned().collect(); + let mut all_sno = td.all_snomasks; + let mut sno = td.snomasks.clone(); + + let names: Vec = 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)); + } +}