x-lines: eline (ban exempt), shun (drop commands), qline (reserved nicks)

This commit is contained in:
Jean Chevronnet 2026-08-08 20:16:05 +00:00
parent b994faa0e7
commit cc3240c04f
4 changed files with 139 additions and 13 deletions

View file

@ -31,6 +31,9 @@ pub fn commands() -> Vec<Box<dyn Command>> {
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<Uid> {
match s.find_nick(nick) {

View file

@ -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(

View file

@ -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 {

View file

@ -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<u64> {
}
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<String> {
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<String> {
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<String> {
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