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:
parent
57e104e510
commit
930198b826
9 changed files with 351 additions and 189 deletions
157
operserv/src/xline.rs
Normal file
157
operserv/src/xline.rs
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue