diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index c492e8b..63295ce 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -506,6 +506,15 @@ impl Command for Kick { ); return CmdResult::Fail; } + // servprotect (+k): a network service can't be kicked + if s.uid_servprotected(tuid) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You cannot kick a network service"), + ); + return CmdResult::Fail; + } // can't kick someone who out-ranks you if s.rank(uid, &key) < s.rank(tuid, &key) { s.numeric( diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index cacdc51..6cf0d2d 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -28,6 +28,7 @@ pub fn svs_set_user_modes(s: &mut Server, tuid: Uid, modestring: &str) { let mut sign = '+'; let mut applied = String::new(); let mut last = ' '; + s.mode_sudo = true; // services authority — allows server-only modes like +k for c in modestring.chars() { if c == '+' || c == '-' { sign = c; @@ -40,6 +41,7 @@ pub fn svs_set_user_modes(s: &mut Server, tuid: Uid, modestring: &str) { } } } + s.mode_sudo = false; if !applied.is_empty() { let nick = s .users diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index d39e5c1..00c747b 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -186,6 +186,11 @@ impl Command for Kill { ); return CmdResult::Fail; }; + // servprotect (+k): a network service can't be killed + if s.uid_servprotected(tuid) { + s.numeric(uid, ERR_NOPRIVILEGES, ":You cannot KILL a network service"); + return CmdResult::Fail; + } // operlevels: a lower-level oper can't KILL a higher-level oper if let Some(reason) = crate::modules::operlevels::deny_kill(s, uid, tuid) { s.numeric(uid, ERR_NOPRIVILEGES, &format!(":{reason}")); @@ -417,6 +422,7 @@ impl Command for SaNick { ); return CmdResult::Fail; }; + if s.uid_servprotected(tuid) { s.numeric(uid, ERR_NOPRIVILEGES, ":Cannot use an SA command on a network service"); return CmdResult::Fail; } let newnick = ¶ms[1]; if !valid_nick(newnick, s.conf_num("maxnick", 30usize)) { s.numeric( @@ -1174,6 +1180,10 @@ impl Command for SaKick { ); return CmdResult::Fail; } + if s.uid_servprotected(tuid) { + s.numeric(uid, ERR_NOPRIVILEGES, ":Cannot use an SA command on a network service"); + return CmdResult::Fail; + } let reason = params .get(2) .cloned() @@ -1221,6 +1231,7 @@ impl Command for SaQuit { .get(1) .cloned() .unwrap_or_else(|| "Services forced quit".to_string()); + if s.uid_servprotected(tuid) { s.numeric(uid, ERR_NOPRIVILEGES, ":Cannot use an SA command on a network service"); return CmdResult::Fail; } s.send(tuid, format!("ERROR :Closing link: (SAQUIT: {reason})")); s.remove_user(tuid, &format!("Quit: {reason}")); let by = oper_nick(s, uid); diff --git a/src/mode.rs b/src/mode.rs index ac3b998..0360b83 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -1165,6 +1165,7 @@ static USER_MODES: &[&(dyn UserMode + Sync)] = &[ &HIDEOPER, ®DEAF, ®ISTERED, + &SERVPROTECT, &SSLPM, &SNOMASK, &CALLERID, @@ -1406,6 +1407,26 @@ impl UserMode for RegisteredMode { } } +/// `+k` — servprotect. Set only by a linked server / services (under `mode_sudo`); +/// a client can never toggle it. Marks the user as protected from KILL/KICK/SA*. +struct ServProtect; +static SERVPROTECT: ServProtect = ServProtect; +impl UserMode for ServProtect { + fn letter(&self) -> char { + 'k' + } + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { + if !s.mode_sudo { + return false; // server/services-only + } + if let Some(u) = s.users.get_mut(&uid) { + u.flags.servprotect = adding; + return true; + } + false + } +} + /// `+s` — server-notice (snomask) receiver. Oper-only to set; anyone may drop it. static SNOMASK: OperFlag = OperFlag { ch: 's', diff --git a/src/users.rs b/src/users.rs index ebaef63..c105f55 100644 --- a/src/users.rs +++ b/src/users.rs @@ -39,6 +39,7 @@ pub struct UserFlags { pub helpop: bool, // +h (helpop: available for help; shown in WHOIS) pub deny_uncommon: bool, // +c (only users sharing a channel may PM you) pub nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change) + pub servprotect: bool, // +k (services-only: can't be KILLed/KICKed/SA-commanded) pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups) pub via_websocket: bool, // connected over the WebSocket transport (ws://, wss://) pub away: Option, // AWAY message, if set @@ -62,6 +63,9 @@ impl UserFlags { if self.bot { s.push('B'); } + if self.servprotect { + s.push('k'); + } if self.deaf { s.push('D'); } @@ -353,6 +357,27 @@ impl Server { self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false) } + /// Whether the user `uid` is servprotected (+k) — a service that must not be + /// KILLed / KICKed / SA-commanded. + pub fn uid_servprotected(&self, uid: Uid) -> bool { + self.users + .get(&uid) + .map(|u| u.flags.servprotect) + .unwrap_or(false) + } + + /// Whether the nick `n` (local user or a remote services pseudo-client) is + /// servprotected (+k). Remote users carry their modes as a letter string. + pub fn nick_servprotected(&self, n: &str) -> bool { + if let Some(uid) = self.find_nick(n) { + return self.uid_servprotected(uid); + } + self.find_remote(n) + .and_then(|(uuid, _)| self.remote_users.get(&uuid)) + .map(|ru| ru.modes.contains('k')) + .unwrap_or(false) + } + /// Grant IRC-operator status and tell the user. pub fn oper_up(&mut self, uid: Uid) { if let Some(u) = self.users.get_mut(&uid) { @@ -485,7 +510,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRzWhc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKXwD", + "{} echoircd-{VERSION} iowxsgBkDIHrRzWhc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKXwD", self.name ), );