From eb7f9f25c44c32717970f9624ae69537ccdcb698 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 20:16:05 +0000 Subject: [PATCH] x-lines: eline (ban exempt), shun (drop commands), qline (reserved nicks) --- src/coremods/core_oper.rs | 45 +++++++++++++++++++ src/coremods/core_user.rs | 11 +++++ src/ircd.rs | 5 +++ src/xline.rs | 91 +++++++++++++++++++++++++++++++++------ 4 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index c365a57..2346232 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -31,6 +31,9 @@ pub fn commands() -> Vec> { Box::new(Kline), Box::new(Gline), Box::new(Zline), + Box::new(Eline), + Box::new(Shun), + Box::new(Qline), Box::new(ChgHost), Box::new(ChgIdent), Box::new(SetHost), @@ -626,6 +629,48 @@ impl Command for Zline { } } +/// ELINE — exempt a `user@host` / ip glob from all K/G/Z-lines. +struct Eline; +impl Command for Eline { + fn name(&self) -> &'static str { + "ELINE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Eline) + } +} + +/// SHUN — let a `user@host` connect but silently drop their commands. +struct Shun; +impl Command for Shun { + fn name(&self) -> &'static str { + "SHUN" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Shun) + } +} + +/// QLINE — reserve/forbid a nick glob (opers bypass it). +struct Qline; +impl Command for Qline { + fn name(&self) -> &'static str { + "QLINE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Qline) + } +} + /// Resolve a nick to a uid, sending ERR_NOSUCHNICK if it's unknown. fn oper_target(s: &mut Server, uid: Uid, nick: &str) -> Option { match s.find_nick(nick) { diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 523c38e..4735773 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -350,6 +350,17 @@ impl Command for Nick { ); return CmdResult::Fail; } + // Q-line: a reserved nick is refused (opers and services bypass) + if !s.is_oper(uid) { + if let Some(reason) = s.matched_qline(newnick) { + s.numeric( + uid, + ERR_ERRONEUSNICKNAME, + &format!("{newnick} :Nickname is reserved: {reason}"), + ); + return CmdResult::Fail; + } + } if let Some(other) = s.find_nick(newnick) { if other != uid { s.numeric( diff --git a/src/ircd.rs b/src/ircd.rs index 9ca4f5b..4e26ad0 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -182,6 +182,11 @@ impl Ircd { /// transparently captured when a labeled command wraps this call. fn dispatch(&mut self, uid: Uid, msg: &message::Message, registered: bool) { let cmd = msg.command.as_str(); + // SHUN: a shunned user stays connected but their commands are silently + // dropped — except keepalive and quit, so they still time out cleanly. + if registered && !matches!(cmd, "PING" | "PONG" | "QUIT") && self.server.user_shunned(uid) { + return; + } // module pre-command gate for m in &mut self.modules { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { diff --git a/src/xline.rs b/src/xline.rs index 3f3706f..5b3a2ef 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -12,6 +12,9 @@ pub enum XKind { Kline, // user@host, this server Gline, // user@host, "global" (locally the same until services span it) Zline, // an IP address + Eline, // user@host / ip EXEMPT from K/G/Z-lines + Shun, // user@host allowed to connect but whose commands are dropped + Qline, // a reserved/forbidden nick glob } impl XKind { @@ -20,6 +23,9 @@ impl XKind { XKind::Kline => "K", XKind::Gline => "G", XKind::Zline => "Z", + XKind::Eline => "E", + XKind::Shun => "SHUN", + XKind::Qline => "Q", } } } @@ -54,20 +60,79 @@ pub fn parse_duration(s: &str) -> Option { } impl Server { - /// The reason a `user@host` / `ip` is banned by an active x-line, if any. - pub fn matched_xline(&self, ident: &str, host: &str, ip: &str) -> Option { - let uh = format!("{ident}@{host}"); + /// Whether an active x-line of `kind` matches this `user@host` / `ip`. + fn xmatch(&self, kind: XKind, uh: &str, ip: &str) -> bool { let n = now(); - for x in &self.xlines { - if x.expires != 0 && x.expires <= n { - continue; - } - let hit = match x.kind { - XKind::Zline => glob_match(&x.mask, ip), - _ => glob_match(&x.mask, &uh), - }; - if hit { - return Some(format!("{}-lined: {}", x.kind.tag(), x.reason)); + self.xlines.iter().any(|x| { + x.kind == kind + && (x.expires == 0 || x.expires > n) + && match kind { + XKind::Zline => glob_match(&x.mask, ip), + _ => glob_match(&x.mask, uh), + } + }) + } + + /// True if this `user@host` / `ip` is E-lined (exempt from all bans). + pub fn is_exempt(&self, ident: &str, host: &str, ip: &str) -> bool { + let uh = format!("{ident}@{host}"); + self.xmatch(XKind::Eline, &uh, ip) + } + + /// True if this `user@host` is SHUN'd (connected but silenced) and not exempt. + pub fn is_shunned(&self, ident: &str, host: &str, ip: &str) -> bool { + if self.is_exempt(ident, host, ip) { + return false; + } + let uh = format!("{ident}@{host}"); + self.xmatch(XKind::Shun, &uh, ip) + } + + /// True if the connected user `uid` is currently SHUN'd. + pub fn user_shunned(&self, uid: Uid) -> bool { + self.users + .get(&uid) + .map(|u| self.is_shunned(&u.ident, &u.host, &u.addr.ip().to_string())) + .unwrap_or(false) + } + + /// The reason nick `nick` is Q-lined (reserved/forbidden), if any. + pub fn matched_qline(&self, nick: &str) -> Option { + let n = now(); + self.xlines + .iter() + .find(|x| { + x.kind == XKind::Qline + && (x.expires == 0 || x.expires > n) + && glob_match(&x.mask, nick) + }) + .map(|x| x.reason.clone()) + } + + /// The reason a `user@host` / `ip` is banned by an active x-line, if any. + /// An E-line (exemption) overrides every K/G/Z-line. + pub fn matched_xline(&self, ident: &str, host: &str, ip: &str) -> Option { + if self.is_exempt(ident, host, ip) { + return None; + } + let uh = format!("{ident}@{host}"); + for kind in [XKind::Kline, XKind::Gline, XKind::Zline] { + if self.xmatch(kind, &uh, ip) { + let n = now(); + let reason = self + .xlines + .iter() + .find(|x| { + x.kind == kind + && (x.expires == 0 || x.expires > n) + && match kind { + XKind::Zline => glob_match(&x.mask, ip), + _ => glob_match(&x.mask, &uh), + } + }) + .map(|x| x.reason.clone()) + .unwrap_or_default(); + return Some(format!("{}-lined: {reason}", kind.tag())); } } None