From ef34c04c8d940e0337cfc58bd4475eee2f5c42e9 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 13:47:10 +0000 Subject: [PATCH] operserv: type the X-line kind as an enum at the command/store boundary, wire token in storage --- api/src/lib.rs | 63 ++++++++++++++++++++++++++++---- modules/operserv/src/chankill.rs | 6 +-- modules/operserv/src/stats.rs | 8 ++-- modules/operserv/src/xline.rs | 14 +++---- src/engine/db/mod.rs | 2 +- src/engine/db/network.rs | 24 +++++++----- src/engine/db/store.rs | 4 +- src/engine/db/tests.rs | 8 ++-- src/engine/mod.rs | 12 ++---- 9 files changed, 96 insertions(+), 45 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 1bb12b8..9bcd629 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -551,9 +551,9 @@ impl ServiceCtx { } // Set a network ban (X-line) at the ircd. `duration` seconds, 0 = permanent. - pub fn add_line(&mut self, kind: &str, mask: &str, setter: &str, duration: u64, reason: &str) { + pub fn add_line(&mut self, kind: XlineKind, mask: &str, setter: &str, duration: u64, reason: &str) { self.actions.push(NetAction::AddLine { - kind: kind.to_string(), + kind: kind.wire().to_string(), mask: mask.to_string(), setter: setter.to_string(), duration, @@ -562,8 +562,8 @@ impl ServiceCtx { } // Lift a network ban previously set with `add_line`. - pub fn del_line(&mut self, kind: &str, mask: &str) { - self.actions.push(NetAction::DelLine { kind: kind.to_string(), mask: mask.to_string() }); + pub fn del_line(&mut self, kind: XlineKind, mask: &str) { + self.actions.push(NetAction::DelLine { kind: kind.wire().to_string(), mask: mask.to_string() }); } // Disconnect a user, sourced from pseudoclient `from`. @@ -787,7 +787,7 @@ pub struct VhostView { // A network ban held by OperServ. `kind` is the ircd X-line type ("G", "Q", …). #[derive(Debug, Clone)] pub struct AkillView { - pub kind: String, + pub kind: XlineKind, pub mask: String, pub setter: String, pub reason: String, @@ -795,6 +795,55 @@ pub struct AkillView { pub expires: Option, } +// A network-ban type OperServ manages, identified by the ircd's X-line token. +// The wire (`NetAction::AddLine`), storage, and gossip stay the string token +// (a transparent passthrough — the ircd knows more line types than we model); +// this types OperServ's own command surface so a kind can't be mistyped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum XlineKind { + Gline, // "G" — user@host ban (AKILL) + Qline, // "Q" — nick ban (SQLINE) + Rline, // "R" — realname ban (SNLINE) + Shun, // "SHUN" + Cban, // "CBAN" — channel-name ban +} + +impl XlineKind { + /// The ircd X-line token, as stored/gossiped and sent on the wire. + pub fn wire(self) -> &'static str { + match self { + Self::Gline => "G", + Self::Qline => "Q", + Self::Rline => "R", + Self::Shun => "SHUN", + Self::Cban => "CBAN", + } + } + + /// Parse a stored/wire token; `None` for a kind we don't model. + pub fn from_wire(s: &str) -> Option { + match s { + "G" => Some(Self::Gline), + "Q" => Some(Self::Qline), + "R" => Some(Self::Rline), + "SHUN" => Some(Self::Shun), + "CBAN" => Some(Self::Cban), + _ => None, + } + } + + /// A human label for notices/audit lines. + pub fn label(self) -> &'static str { + match self { + Self::Gline => "network ban", + Self::Qline => "nick ban", + Self::Rline => "realname ban", + Self::Shun => "shun", + Self::Cban => "channel ban", + } + } +} + // A registration-ban target for OperServ FORBID. The persistence/gossip layer // stores the wire token (`wire()`); the Store API is typed so a call site can't // pass a mistyped kind like "NCK" (it wouldn't compile). @@ -1167,8 +1216,8 @@ pub trait Store { // Network bans (AKILL / G-lines, oper-only). `akill_add` returns whether the // mask was newly added (false = an existing entry was refreshed); `akills` // lists only entries that haven't lazily expired, oldest first. - fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option) -> Result; - fn akill_del(&mut self, kind: &str, mask: &str) -> Result; + fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result; + fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result; fn akills(&self) -> Vec; // Registration bans (OperServ FORBID). fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result; diff --git a/modules/operserv/src/chankill.rs b/modules/operserv/src/chankill.rs index bb32b05..1d442e0 100644 --- a/modules/operserv/src/chankill.rs +++ b/modules/operserv/src/chankill.rs @@ -1,4 +1,4 @@ -use echo_api::{NetView, Priv, Sender, ServiceCtx, Store}; +use echo_api::{NetView, Priv, Sender, ServiceCtx, Store, XlineKind}; use std::collections::HashSet; // CHANKILL <#channel> [reason]: AKILL every user in a channel by host, clearing @@ -29,8 +29,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: let Some(host) = net.host_of(uid) else { continue }; if seen.insert(host.to_string()) { let mask = format!("*@{host}"); - let _ = db.akill_add("G", &mask, setter, &reason, None); - ctx.add_line("G", &mask, from.nick, 0, &reason); + let _ = db.akill_add(XlineKind::Gline, &mask, setter, &reason, None); + ctx.add_line(XlineKind::Gline, &mask, from.nick, 0, &reason); banned += 1; } } diff --git a/modules/operserv/src/stats.rs b/modules/operserv/src/stats.rs index 0bff9a4..9606cf5 100644 --- a/modules/operserv/src/stats.rs +++ b/modules/operserv/src/stats.rs @@ -1,13 +1,13 @@ -use echo_api::{Sender, ServiceCtx, Store}; +use echo_api::{Sender, ServiceCtx, Store, XlineKind}; // STATS: an at-a-glance summary of the enforcement state OperServ holds — how // many network bans of each kind and how many services ignores are live. // Read-only, so any operator may run it (the module is already oper-gated). pub fn handle(me: &str, from: &Sender, db: &mut dyn Store, ctx: &mut ServiceCtx) { let akills = db.akills(); - let glines = akills.iter().filter(|a| a.kind == "G").count(); - let qlines = akills.iter().filter(|a| a.kind == "Q").count(); - let rlines = akills.iter().filter(|a| a.kind == "R").count(); + let glines = akills.iter().filter(|a| a.kind == XlineKind::Gline).count(); + let qlines = akills.iter().filter(|a| a.kind == XlineKind::Qline).count(); + let rlines = akills.iter().filter(|a| a.kind == XlineKind::Rline).count(); let ignores = db.ignores().len(); ctx.notice(me, from.uid, format!("Live network bans: \x02{glines}\x02 AKILL, \x02{qlines}\x02 SQLINE, \x02{rlines}\x02 SNLINE. Services ignores: \x02{ignores}\x02.")); } diff --git a/modules/operserv/src/xline.rs b/modules/operserv/src/xline.rs index a267632..2784b82 100644 --- a/modules/operserv/src/xline.rs +++ b/modules/operserv/src/xline.rs @@ -1,11 +1,11 @@ -use echo_api::{parse_duration, Sender, ServiceCtx, Store}; +use echo_api::{parse_duration, Sender, ServiceCtx, Store, XlineKind}; use std::time::{SystemTime, UNIX_EPOCH}; // A network-ban command family (AKILL, SQLINE, …): the ircd X-line `kind`, the // user-facing command `name`, and the mask shape it accepts. One implementation // drives ADD / DEL / LIST for every kind so they stay consistent. pub struct Xline { - pub kind: &'static str, + pub kind: XlineKind, pub name: &'static str, pub target: &'static str, // e.g. "user@host" or "nick" — shown in syntax pub normalize: fn(&str) -> Option, @@ -114,22 +114,22 @@ impl Xline { } // AKILL: a user@host G-line. Strips any nick! prefix, lowercases both sides. -pub const AKILL: Xline = Xline { kind: "G", name: "AKILL", target: "user@host", normalize: norm_userhost }; +pub const AKILL: Xline = Xline { kind: XlineKind::Gline, name: "AKILL", target: "user@host", normalize: norm_userhost }; // SQLINE: a nick Q-line. The mask is a nick glob (no '@'); wildcards allowed. -pub const SQLINE: Xline = Xline { kind: "Q", name: "SQLINE", target: "nick", normalize: norm_nick }; +pub const SQLINE: Xline = Xline { kind: XlineKind::Qline, name: "SQLINE", target: "nick", normalize: norm_nick }; // SNLINE: a realname R-line. The mask is a regex the ircd matches against a // connecting user's realname (use `.` for spaces, e.g. `.*free.money.*`). -pub const SNLINE: Xline = Xline { kind: "R", name: "SNLINE", target: "realname-regex", normalize: norm_realname }; +pub const SNLINE: Xline = Xline { kind: XlineKind::Rline, name: "SNLINE", target: "realname-regex", normalize: norm_realname }; // SHUN: a user@host shun. A matching user stays connected but the ircd silently // drops their commands — a quieter alternative to an AKILL. -pub const SHUN: Xline = Xline { kind: "SHUN", name: "SHUN", target: "user@host", normalize: norm_userhost }; +pub const SHUN: Xline = Xline { kind: XlineKind::Shun, name: "SHUN", target: "user@host", normalize: norm_userhost }; // CBAN: a channel-name ban. Users can't join (or create) a channel matching the // mask, which is a channel glob like `#warez*`. -pub const CBAN: Xline = Xline { kind: "CBAN", name: "CBAN", target: "#channel", normalize: norm_channel }; +pub const CBAN: Xline = Xline { kind: XlineKind::Cban, name: "CBAN", target: "#channel", normalize: norm_channel }; fn norm_userhost(input: &str) -> Option { let body = input.rsplit('!').next().unwrap_or(input); diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index b39940a..0cefcbb 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -40,7 +40,7 @@ pub(crate) use event::{apply, Scope}; // echo-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use echo_api::{ - AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind, }; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 1fbe755..b7e887d 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -2,36 +2,42 @@ use super::*; impl Db { /// Add (or refresh) a `kind` network ban. Returns whether it was newly added. - pub fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { - let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask); + pub fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { + // Storage/gossip keep the ircd's line token; the API is typed. + let wire = kind.wire(); + let same = |a: &Akill| a.kind == wire && a.mask.eq_ignore_ascii_case(mask); let fresh = !self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); self.log - .append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }) + .append(Event::AkillAdded { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }) .map_err(|_| RegError::Internal)?; self.net.akills.retain(|a| !same(a)); - self.net.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }); + self.net.akills.push(Akill { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }); Ok(fresh) } /// Lift a `kind` network ban. Returns whether a live one was removed. - pub fn akill_del(&mut self, kind: &str, mask: &str) -> Result { - let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask); + pub fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result { + let wire = kind.wire(); + let same = |a: &Akill| a.kind == wire && a.mask.eq_ignore_ascii_case(mask); let existed = self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); if !existed { return Ok(false); } - self.log.append(Event::AkillRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; + self.log.append(Event::AkillRemoved { kind: wire.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; self.net.akills.retain(|a| !same(a)); Ok(true) } - /// The live network bans (expired ones hidden lazily), oldest first. + /// The live network bans (expired ones hidden lazily), oldest first. A stored + /// kind we don't model (peer-gossiped) is skipped from the typed view. pub fn akills(&self) -> Vec { let now = now(); self.net.akills .iter() .filter(|a| a.expires.is_none_or(|e| e > now)) - .map(|a| AkillView { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }) + .filter_map(|a| { + XlineKind::from_wire(&a.kind).map(|kind| AkillView { kind, mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }) + }) .collect() } diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index b715b1f..99cefd3 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -209,10 +209,10 @@ impl Store for Db { fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result { Db::set_channel_noexpire(self, channel, on) } - fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { + fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { Db::akill_add(self, kind, mask, setter, reason, expires) } - fn akill_del(&mut self, kind: &str, mask: &str) -> Result { + fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result { Db::akill_del(self, kind, mask) } fn akills(&self) -> Vec { diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 9495e5b..b87dba3 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -609,7 +609,7 @@ db.group_set_flags("!grp", "carol", "").unwrap(); db.group_register("!other", "bob").unwrap(); db.group_set_flags("!other", "alice", "").unwrap(); - db.akill_add("G", "*@evil", "oper", "spam", None).unwrap(); + db.akill_add(XlineKind::Gline, "*@evil", "oper", "spam", None).unwrap(); db.forbid_add(ForbidKind::Nick, "bad*", "oper", "squat").unwrap(); db.vhost_offer_add("cool.vhost").unwrap(); db.vhost_forbid_add("(?i)admin").unwrap(); @@ -640,8 +640,8 @@ db.del_vhost("bob").unwrap(); db.suspend_account("carol", "oper", "oops", None).unwrap(); db.unsuspend_account("carol").unwrap(); - db.akill_add("Q", "spam*", "oper", "nick", None).unwrap(); - db.akill_del("Q", "spam*").unwrap(); + db.akill_add(XlineKind::Qline, "spam*", "oper", "nick", None).unwrap(); + db.akill_del(XlineKind::Qline, "spam*").unwrap(); db.forbid_add(ForbidKind::Chan, "#evil*", "oper", "bad").unwrap(); db.forbid_del(ForbidKind::Chan, "#evil*").unwrap(); let nid = db.news_add("logon", "temp notice", "oper"); @@ -703,7 +703,7 @@ a.set_greet("alice", "hi there").unwrap(); a.set_account_kill("alice", false).unwrap(); a.suspend_account("bob", "op", "bad", None).unwrap(); - a.akill_add("G", "*@evil", "op", "spam", None).unwrap(); + a.akill_add(XlineKind::Gline, "*@evil", "op", "spam", None).unwrap(); a.forbid_add(ForbidKind::Nick, "bad*", "op", "squat").unwrap(); a.group_register("!g", "alice").unwrap(); a.group_set_flags("!g", "bob", "").unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 71a91ac..95b962d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1044,7 +1044,7 @@ impl Engine { let now = self.now_secs(); for a in self.db.akills() { let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0); - out.push(NetAction::AddLine { kind: a.kind, mask: a.mask, setter: a.setter, duration, reason: a.reason }); + out.push(NetAction::AddLine { kind: a.kind.wire().to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason }); } // Re-introduce our juped servers over the fresh link. for (name, sid, reason) in self.db.jupes() { @@ -1524,14 +1524,10 @@ impl Engine { } // A human label for a network-ban X-line kind, for the audit feed. +// The audit feed labels the ban kind from the persisted (string) event token; an +// unmodelled/peer kind falls back to the generic label. fn ban_kind_label(kind: &str) -> &'static str { - match kind { - "Q" => "nick ban", - "R" => "realname ban", - "SHUN" => "shun", - "CBAN" => "channel ban", - _ => "network ban", - } + echo_api::XlineKind::from_wire(kind).map(|k| k.label()).unwrap_or("network ban") } // A rough human span for an expiry deadline in an email ("7 days", "12 hours").