Expand OperServ: SQLINE nick bans, GLOBAL, and KILL

Generalise the network-ban store to carry the ircd X-line kind (defaulted
to the user@host G-line for existing records) so one event-sourced,
lazily-expiring, burst-reasserted list backs every ban type, and drive
ADD/DEL/LIST from one implementation per kind:

- AKILL stays the user@host G-line; SQLINE is a nick Q-line, its mask
  validated as a nick glob (an @ is refused so an AKILL-shaped mask can't
  become an over-broad nick ban). Both are keyed by (kind, mask).
- GLOBAL <message> announces to every user via a $* server-glob notice.
- KILL <nick> [reason] disconnects a user, attributed to the operator.

All four are admin-gated, and OperServ itself stays invisible to non-opers.
Bans and kills go out as typed actions (AddLine/DelLine/KillUser) the ircd
link serialises to ADDLINE/DELLINE/KILL.
This commit is contained in:
Jean Chevronnet 2026-07-14 00:45:52 +00:00
parent 57e104e510
commit 930198b826
No known key found for this signature in database
9 changed files with 351 additions and 189 deletions

View file

@ -179,10 +179,23 @@ pub enum Event {
// An impending-expiry warning email was sent; cleared by the next Seen/Used.
AccountExpiryWarned { account: String },
ChannelExpiryWarned { channel: String },
// Network bans (OperServ AKILL). Global: a ban covers the whole network, so
// every node holds the list and re-applies it at burst.
AkillAdded { mask: String, setter: String, reason: String, ts: u64, expires: Option<u64> },
AkillRemoved { mask: String },
// Network bans (OperServ AKILL / SQLINE). Global: a ban covers the whole
// network, so every node holds the list and re-applies it at burst. `kind`
// is the ircd X-line type and defaults to "G" for records predating it.
AkillAdded {
#[serde(default = "gline_kind")]
kind: String,
mask: String,
setter: String,
reason: String,
ts: u64,
expires: Option<u64>,
},
AkillRemoved {
#[serde(default = "gline_kind")]
kind: String,
mask: String,
},
}
// Whether an event replicates across the federation. Account identity is Global
@ -282,10 +295,13 @@ pub struct Suspension {
pub expires: Option<u64>,
}
// A network ban (AKILL / G-line): a user@host mask, who set it, why, when, and
// an optional absolute-unix-seconds expiry (None = permanent).
// A network ban: the ircd X-line `kind` (e.g. "G" for a user@host G-line, "Q"
// for a nick Q-line), a mask, who set it, why, when, and an optional
// absolute-unix-seconds expiry (None = permanent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Akill {
#[serde(default = "gline_kind")]
pub kind: String,
pub mask: String,
pub setter: String,
pub reason: String,
@ -294,6 +310,11 @@ pub struct Akill {
pub expires: Option<u64>,
}
// The default ban kind for records written before bans carried one: a G-line.
fn gline_kind() -> String {
"G".to_string()
}
// A memo left for an account (MemoServ).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memo {
@ -1004,7 +1025,7 @@ impl Db {
// Compaction is a good moment to forget akills that have already expired.
let now = now();
for a in self.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
snapshot.push(Event::AkillAdded { mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
}
self.log.compact(snapshot)?;
tracing::info!(before, after = self.log.len(), "compacted event log");
@ -1739,25 +1760,27 @@ impl Db {
}
}
/// Add (or refresh) a network ban. Returns whether the mask was newly added.
pub fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
let fresh = !self.akills.iter().any(|a| a.mask.eq_ignore_ascii_case(mask) && a.expires.is_none_or(|e| e > now()));
/// 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<u64>) -> Result<bool, RegError> {
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
let fresh = !self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
self.log
.append(Event::AkillAdded { mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires })
.append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires })
.map_err(|_| RegError::Internal)?;
self.akills.retain(|a| !a.mask.eq_ignore_ascii_case(mask));
self.akills.push(Akill { mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
self.akills.retain(|a| !same(a));
self.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
Ok(fresh)
}
/// Lift a network ban. Returns whether a live (non-expired) one was removed.
pub fn akill_del(&mut self, mask: &str) -> Result<bool, RegError> {
let existed = self.akills.iter().any(|a| a.mask.eq_ignore_ascii_case(mask) && a.expires.is_none_or(|e| e > now()));
/// Lift a `kind` network ban. Returns whether a live one was removed.
pub fn akill_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError> {
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
let existed = self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
if !existed {
return Ok(false);
}
self.log.append(Event::AkillRemoved { mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
self.akills.retain(|a| !a.mask.eq_ignore_ascii_case(mask));
self.log.append(Event::AkillRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
self.akills.retain(|a| !same(a));
Ok(true)
}
@ -1767,7 +1790,7 @@ impl Db {
self.akills
.iter()
.filter(|a| a.expires.is_none_or(|e| e > now))
.map(|a| AkillView { mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires })
.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 })
.collect()
}
@ -2669,14 +2692,14 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.noexpire = on;
}
}
Event::AkillAdded { mask, setter, reason, ts, expires } => {
// Keyed by mask (case-insensitive): a re-add refreshes in place, so
// replaying over a snapshot stays idempotent.
akills.retain(|a| !a.mask.eq_ignore_ascii_case(&mask));
akills.push(Akill { mask, setter, reason, ts, expires });
Event::AkillAdded { kind, mask, setter, reason, ts, expires } => {
// Keyed by (kind, mask), case-insensitive: a re-add refreshes in
// place, so replaying over a snapshot stays idempotent.
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
akills.push(Akill { kind, mask, setter, reason, ts, expires });
}
Event::AkillRemoved { mask } => {
akills.retain(|a| !a.mask.eq_ignore_ascii_case(&mask));
Event::AkillRemoved { kind, mask } => {
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
}
Event::AccountExpiryWarned { account } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
@ -2920,11 +2943,11 @@ impl Store for Db {
fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result<bool, ChanError> {
Db::set_channel_noexpire(self, channel, on)
}
fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
Db::akill_add(self, mask, setter, reason, expires)
fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
Db::akill_add(self, kind, mask, setter, reason, expires)
}
fn akill_del(&mut self, mask: &str) -> Result<bool, RegError> {
Db::akill_del(self, mask)
fn akill_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError> {
Db::akill_del(self, kind, mask)
}
fn akills(&self) -> Vec<AkillView> {
Db::akills(self)

View file

@ -682,7 +682,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: "G".to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason });
out.push(NetAction::AddLine { kind: a.kind, mask: a.mask, setter: a.setter, duration, reason: a.reason });
}
out.push(NetAction::Metadata {
target: "*".to_string(),
@ -1495,6 +1495,14 @@ impl Engine {
}
}
// A human label for a network-ban X-line kind, for the audit feed.
fn ban_kind_label(kind: &str) -> &'static str {
match kind {
"Q" => "nick ban",
_ => "network ban",
}
}
// A rough human span for an expiry deadline in an email ("7 days", "12 hours").
fn human_duration(secs: u64) -> String {
let plural = |n: u64, unit: &str| format!("{n} {unit}{}", if n == 1 { "" } else { "s" });
@ -1553,11 +1561,11 @@ fn audit_summary(event: &db::Event) -> Option<String> {
Some(t) => format!("set the vhost template to \x02{t}\x02"),
None => "cleared the vhost template".to_string(),
},
AkillAdded { mask, reason, expires, .. } => {
let kind = if expires.is_some() { " (temporary)" } else { "" };
format!("set a network ban on \x02{mask}\x02{kind} ({reason})")
AkillAdded { kind, mask, reason, expires, .. } => {
let temp = if expires.is_some() { " (temporary)" } else { "" };
format!("set a {} on \x02{mask}\x02{temp} ({reason})", ban_kind_label(kind))
}
AkillRemoved { mask } => format!("lifted the network ban on \x02{mask}\x02"),
AkillRemoved { kind, mask } => format!("lifted the {} on \x02{mask}\x02", ban_kind_label(kind)),
AccountNoExpire { account, on } => {
let verb = if *on { "pinned" } else { "unpinned" };
format!("{verb} account \x02{account}\x02 against expiry")
@ -3895,6 +3903,60 @@ mod tests {
assert_eq!(again, 0, "warning isn't repeated while still idle");
}
// OperServ SQLINE (nick bans), GLOBAL (announce to all), and KILL (disconnect
// a user): the Q-line, the $* broadcast, and the KILL all reach the ircd, and
// each is admin-gated.
#[test]
fn operserv_sqline_global_and_kill() {
use fedserv_operserv::OperServ;
let path = std::env::temp_dir().join("fedserv-osmore.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.register("staff", "password1", None).unwrap();
db.register("plain", "password1", None).unwrap();
let mut e = Engine::new(
vec![
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
Box::new(OperServ { uid: "42SAAAAAH".into() }),
],
db,
);
e.set_sid("42S".into());
let mut opers = std::collections::HashMap::new();
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
e.set_opers(opers);
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAP".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "spammer".into(), host: "h".into() });
// SQLINE drives a Q-line on the nick mask, tracked separately from AKILLs.
let out = os(&mut e, "000AAAAAS", "SQLINE ADD *bot* botnet");
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "Q" && mask == "*bot*")), "Q-line added: {out:?}");
// A rejected @-shaped mask (that belongs to AKILL, not SQLINE).
assert!(os(&mut e, "000AAAAAS", "SQLINE ADD a@b spam").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("valid"))), "nick mask rejects user@host");
// GLOBAL fans out to every user via the $* server glob.
let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "$*" && text.contains("rebooting in 5"))), "global broadcast: {out:?}");
// KILL disconnects the named user, attributed to the oper.
let out = os(&mut e, "000AAAAAS", "KILL spammer flooding");
assert!(out.iter().any(|a| matches!(a, NetAction::KillUser { uid, reason, .. } if uid == "000AAAAAV" && reason.contains("flooding") && reason.contains("staff"))), "kill issued: {out:?}");
// None of it works for a non-admin: refused, and no network action leaks.
for cmd in ["SQLINE ADD *x* y", "GLOBAL hi", "KILL staff go"] {
let out = os(&mut e, "000AAAAAP", cmd);
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused {cmd}");
assert!(!out.iter().any(|a| matches!(a, NetAction::AddLine { .. } | NetAction::KillUser { .. })), "no ban/kill from non-admin {cmd}");
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "$*")), "no broadcast from non-admin {cmd}");
}
}
// NOEXPIRE is oper-only.
#[test]
fn noexpire_command_is_oper_gated() {