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

@ -1,140 +0,0 @@
use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// AKILL ADD [+expiry] <user@host> <reason> | DEL <user@host|number> | LIST
// [pattern]: manage network bans. Admin-only — a network ban is the heaviest
// hammer services hold.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — AKILL needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => add(me, from, &args[2..], ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db),
_ => ctx.notice(me, from.uid, "Syntax: AKILL ADD [+expiry] <user@host> <reason> | AKILL DEL <user@host|number> | AKILL LIST [pattern]"),
}
}
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
// An optional leading +duration, then the mask, then a free-text reason.
let mut rest = rest;
let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration);
if duration.is_some() {
rest = &rest[1..];
}
let Some((&raw_mask, reason_words)) = rest.split_first() else {
ctx.notice(me, from.uid, "Syntax: AKILL ADD [+expiry] <user@host> <reason>");
return;
};
let Some(mask) = normalize_mask(raw_mask) else {
ctx.notice(me, from.uid, format!("\x02{raw_mask}\x02 isn't a valid \x02user@host\x02 mask."));
return;
};
if reason_words.is_empty() {
ctx.notice(me, from.uid, "Please give a reason: AKILL ADD [+expiry] <user@host> <reason>");
return;
}
let reason = reason_words.join(" ");
// Refuse a mask so broad it would ban most of the network.
if too_wide(&mask) {
ctx.notice(me, from.uid, "That mask is too wide — it would ban almost everyone.");
return;
}
let setter = from.account.unwrap_or(from.nick);
let expires = duration.map(|secs| now() + secs);
match db.akill_add(&mask, setter, &reason, expires) {
Ok(fresh) => {
// The ircd applies the G-line to matching users already online and to
// future connections; duration 0 = permanent.
ctx.add_line("G", &mask, from.nick, duration.unwrap_or(0), &reason);
let word = if fresh { "added" } else { "updated" };
let expiry = if expires.is_some() { " (temporary)" } else { "" };
ctx.notice(me, from.uid, format!("AKILL {word} for \x02{mask}\x02{expiry}."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(arg) = arg else {
ctx.notice(me, from.uid, "Syntax: AKILL DEL <user@host|number>");
return;
};
// A number targets the entry at that position in AKILL LIST; otherwise it's a
// literal mask.
let mask = match arg.parse::<usize>() {
Ok(n) if n >= 1 => match db.akills().get(n - 1) {
Some(a) => a.mask.clone(),
None => {
ctx.notice(me, from.uid, format!("There's no AKILL number \x02{n}\x02."));
return;
}
},
_ => normalize_mask(arg).unwrap_or_else(|| arg.to_string()),
};
match db.akill_del(&mask) {
Ok(true) => {
ctx.del_line("G", &mask);
ctx.notice(me, from.uid, format!("AKILL for \x02{mask}\x02 removed."));
}
Ok(false) => ctx.notice(me, from.uid, format!("No AKILL matches \x02{mask}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn list(me: &str, from: &Sender, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let pat = pattern.map(|p| p.to_ascii_lowercase());
let akills = db.akills();
let mut shown = 0;
for (i, a) in akills.iter().enumerate() {
if let Some(p) = &pat {
if !a.mask.to_ascii_lowercase().contains(p.as_str()) {
continue;
}
}
let expiry = match a.expires {
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
None => String::new(),
};
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 by {}{}{}", i + 1, a.mask, a.setter, a.reason, expiry));
shown += 1;
}
if shown == 0 {
ctx.notice(me, from.uid, "No matching network bans.");
} else {
ctx.notice(me, from.uid, format!("End of AKILL list ({shown} shown)."));
}
}
// Reduce an input to a `user@host` mask: drop any `nick!` prefix, require a
// single `@` with non-empty sides. Returns None if it isn't shaped like a mask.
fn normalize_mask(input: &str) -> Option<String> {
let body = input.rsplit('!').next().unwrap_or(input);
let (user, host) = body.split_once('@')?;
if user.is_empty() || host.is_empty() || host.contains('@') {
return None;
}
Some(format!("{}@{}", user.to_ascii_lowercase(), host.to_ascii_lowercase()))
}
// A mask whose user and host are both pure wildcard would catch nearly everyone.
fn too_wide(mask: &str) -> bool {
let Some((user, host)) = mask.split_once('@') else { return true };
let trivial = |s: &str| s.chars().all(|c| c == '*' || c == '?' || c == '.');
trivial(user) && trivial(host)
}
fn human_secs(secs: u64) -> String {
match secs {
0 => "moments".to_string(),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

18
operserv/src/global.rs Normal file
View file

@ -0,0 +1,18 @@
use fedserv_api::{Priv, Sender, ServiceCtx};
// GLOBAL <message>: send an announcement to every user on the network. Admin-
// only — it reaches everyone, so it's the heaviest voice services have.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — GLOBAL needs the \x02admin\x02 privilege.");
return;
}
let message = args[1..].join(" ");
if message.trim().is_empty() {
ctx.notice(me, from.uid, "Syntax: GLOBAL <message>");
return;
}
// A marker so users can tell an official announcement from a normal notice.
ctx.global(me, format!("[\x02Network\x02] {message}"));
ctx.notice(me, from.uid, "Your announcement has been sent to the whole network.");
}

21
operserv/src/kill.rs Normal file
View file

@ -0,0 +1,21 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx};
// KILL <nick> [reason]: disconnect a user from the network. Admin-only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — KILL needs the \x02admin\x02 privilege.");
return;
}
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: KILL <nick> [reason]");
return;
};
let Some(uid) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("There's no \x02{target}\x02 online."));
return;
};
let by = from.account.unwrap_or(from.nick);
let reason = if args.len() > 2 { args[2..].join(" ") } else { "No reason given".to_string() };
ctx.kill(me, &uid, &format!("({by}) {reason}"));
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been disconnected."));
}

View file

@ -1,13 +1,16 @@
//! OperServ gives services operators network-wide tools. The first is AKILL:
//! network bans (G-lines) that keep matching users off the whole network,
//! event-sourced so they survive a restart and re-apply at burst, with lazy
//! expiry like the rest of the store. `lib.rs` dispatches; each command is its
//! own file.
//! OperServ gives services operators network-wide tools: AKILL/SQLINE network
//! bans (event-sourced, lazily expiring, re-asserted at burst), a GLOBAL
//! announcement to every user, and KILL to disconnect one. `lib.rs` dispatches;
//! each command family is its own file.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
#[path = "akill.rs"]
mod akill;
#[path = "xline.rs"]
mod xline;
#[path = "global.rs"]
mod global;
#[path = "kill.rs"]
mod kill;
pub struct OperServ {
pub uid: String,
@ -24,7 +27,7 @@ impl Service for OperServ {
"Operator Service"
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) {
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
// Every OperServ command is operator-only: reveal nothing to others.
if !from.privs.any() {
@ -32,14 +35,17 @@ impl Service for OperServ {
return;
}
match args.first().copied() {
Some(cmd) if cmd.eq_ignore_ascii_case("AKILL") => akill::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("AKILL") => xline::AKILL.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SQLINE") => xline::SQLINE.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02AKILL\x02 or \x02HELP\x02.")),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "OperServ holds network operator tools. \x02AKILL ADD\x02 [+expiry] <user@host> <reason>, \x02AKILL DEL\x02 <user@host|number>, \x02AKILL LIST\x02 [pattern] — network bans (Priv::Admin).");
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user).");
}

157
operserv/src/xline.rs Normal file
View file

@ -0,0 +1,157 @@
use fedserv_api::{parse_duration, Sender, ServiceCtx, Store};
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 name: &'static str,
pub target: &'static str, // e.g. "user@host" or "nick" — shown in syntax
pub normalize: fn(&str) -> Option<String>,
}
impl Xline {
pub fn handle(&self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => self.add(me, from, &args[2..], ctx, db),
Some("DEL") | Some("REMOVE") => self.del(me, from, args.get(2).copied(), ctx, db),
Some("LIST") | Some("VIEW") => self.list(me, from, args.get(2).copied(), ctx, db),
_ => ctx.notice(me, from.uid, format!("Syntax: {0} ADD [+expiry] <{1}> <reason> | {0} DEL <{1}|number> | {0} LIST [pattern]", self.name, self.target)),
}
}
fn add(&self, me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
// An optional leading +duration, then the mask, then a free-text reason.
let mut rest = rest;
let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration);
if duration.is_some() {
rest = &rest[1..];
}
let Some((&raw, reason_words)) = rest.split_first() else {
ctx.notice(me, from.uid, format!("Syntax: {} ADD [+expiry] <{}> <reason>", self.name, self.target));
return;
};
let Some(mask) = (self.normalize)(raw) else {
ctx.notice(me, from.uid, format!("\x02{raw}\x02 isn't a valid \x02{}\x02 mask.", self.target));
return;
};
if reason_words.is_empty() {
ctx.notice(me, from.uid, "Please give a reason.");
return;
}
if too_wide(&mask) {
ctx.notice(me, from.uid, "That mask is too wide — it would match almost everyone.");
return;
}
let reason = reason_words.join(" ");
let setter = from.account.unwrap_or(from.nick);
let expires = duration.map(|secs| now() + secs);
match db.akill_add(self.kind, &mask, setter, &reason, expires) {
Ok(fresh) => {
ctx.add_line(self.kind, &mask, from.nick, duration.unwrap_or(0), &reason);
let word = if fresh { "added" } else { "updated" };
let expiry = if expires.is_some() { " (temporary)" } else { "" };
ctx.notice(me, from.uid, format!("{} {word} for \x02{mask}\x02{expiry}.", self.name));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(&self, me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(arg) = arg else {
ctx.notice(me, from.uid, format!("Syntax: {} DEL <{}|number>", self.name, self.target));
return;
};
// A number targets the nth entry of this kind in the list; else a mask.
let mask = match arg.parse::<usize>() {
Ok(n) if n >= 1 => match self.mine(db).get(n - 1) {
Some(mask) => mask.clone(),
None => {
ctx.notice(me, from.uid, format!("There's no {} number \x02{n}\x02.", self.name));
return;
}
},
_ => (self.normalize)(arg).unwrap_or_else(|| arg.to_string()),
};
match db.akill_del(self.kind, &mask) {
Ok(true) => {
ctx.del_line(self.kind, &mask);
ctx.notice(me, from.uid, format!("{} for \x02{mask}\x02 removed.", self.name));
}
Ok(false) => ctx.notice(me, from.uid, format!("No {} matches \x02{mask}\x02.", self.name)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn list(&self, me: &str, from: &Sender, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let pat = pattern.map(|p| p.to_ascii_lowercase());
let mut shown = 0;
for (i, a) in db.akills().iter().filter(|a| a.kind == self.kind).enumerate() {
if let Some(p) = &pat {
if !a.mask.to_ascii_lowercase().contains(p.as_str()) {
continue;
}
}
let expiry = match a.expires {
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
None => String::new(),
};
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 by {}{}{}", i + 1, a.mask, a.setter, a.reason, expiry));
shown += 1;
}
if shown == 0 {
ctx.notice(me, from.uid, format!("No matching {} entries.", self.name));
} else {
ctx.notice(me, from.uid, format!("End of {} list ({shown} shown).", self.name));
}
}
// The live masks of this kind, in list order (for DEL by number).
fn mine(&self, db: &dyn Store) -> Vec<String> {
db.akills().into_iter().filter(|a| a.kind == self.kind).map(|a| a.mask).collect()
}
}
// 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 };
// 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 };
fn norm_userhost(input: &str) -> Option<String> {
let body = input.rsplit('!').next().unwrap_or(input);
let (user, host) = body.split_once('@')?;
if user.is_empty() || host.is_empty() || host.contains('@') {
return None;
}
Some(format!("{}@{}", user.to_ascii_lowercase(), host.to_ascii_lowercase()))
}
fn norm_nick(input: &str) -> Option<String> {
// A nick mask, not a hostmask: reject an '@' so a user typo can't become an
// over-broad ban, and keep it non-empty.
if input.is_empty() || input.contains('@') || input.contains('!') {
return None;
}
Some(input.to_ascii_lowercase())
}
// A mask whose every meaningful character is a wildcard would match nearly all.
fn too_wide(mask: &str) -> bool {
let trivial = |s: &str| s.chars().all(|c| c == '*' || c == '?' || c == '.' || c == '@');
trivial(mask)
}
fn human_secs(secs: u64) -> String {
match secs {
0 => "moments".to_string(),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}