OperServ: SHUN and CHANKILL
- SHUN <user@host> silences a matching user network-wide without
disconnecting them — another kind ("SHUN") in the generalized X-line
handler (m_shun is loaded), same event-sourced list and lazy expiry as
AKILL/SQLINE/SNLINE.
- CHANKILL <#channel> [reason] AKILLs every distinct member host in one
shot to clear a spam or attack channel; the G-lines the ircd applies
also kill the sessions. Deduped by host, and never bans the operator who
ran it. Both admin-only.
This commit is contained in:
parent
1daebc94a5
commit
1f7591778f
4 changed files with 93 additions and 1 deletions
38
operserv/src/chankill.rs
Normal file
38
operserv/src/chankill.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
// CHANKILL <#channel> [reason]: AKILL every user in a channel by host, clearing
|
||||||
|
// a spam or attack channel in one shot. The G-lines the ircd applies also kill
|
||||||
|
// the matching sessions. Admin-only, and never bans the operator running it.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||||
|
if !from.privs.has(Priv::Admin) {
|
||||||
|
ctx.notice(me, from.uid, "Access denied — CHANKILL needs the \x02admin\x02 privilege.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(&chan) = args.get(1).filter(|c| c.starts_with('#') || c.starts_with('&')) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: CHANKILL <#channel> [reason]");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let members = net.channel_members(chan);
|
||||||
|
if members.is_empty() {
|
||||||
|
ctx.notice(me, from.uid, format!("No one is in \x02{chan}\x02 (or it isn't tracked)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let reason = if args.len() > 2 { args[2..].join(" ") } else { "Channel cleared by services".to_string() };
|
||||||
|
let setter = from.account.unwrap_or(from.nick);
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut banned = 0;
|
||||||
|
for uid in &members {
|
||||||
|
if uid.as_str() == from.uid {
|
||||||
|
continue; // never ban yourself
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
banned += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, format!("CHANKILL on \x02{chan}\x02: \x02{banned}\x02 host(s) AKILL'd."));
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ mod oper;
|
||||||
mod session;
|
mod session;
|
||||||
#[path = "jupe.rs"]
|
#[path = "jupe.rs"]
|
||||||
mod jupe;
|
mod jupe;
|
||||||
|
#[path = "chankill.rs"]
|
||||||
|
mod chankill;
|
||||||
|
|
||||||
pub struct OperServ {
|
pub struct OperServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -58,6 +60,7 @@ impl Service for OperServ {
|
||||||
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("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("SQLINE") => xline::SQLINE.handle(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
|
||||||
|
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.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("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("KILL") => kill::handle(me, from, args, ctx, net),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net),
|
Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net),
|
||||||
|
|
@ -72,6 +75,7 @@ impl Service for OperServ {
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net),
|
Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("JUPE") => jupe::handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("JUPE") => jupe::handle(me, from, args, ctx, db),
|
||||||
|
Some(cmd) if cmd.eq_ignore_ascii_case("CHANKILL") => chankill::handle(me, from, args, ctx, net, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
|
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
|
||||||
None => 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 \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
|
|
@ -80,5 +84,5 @@ impl Service for OperServ {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
|
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
|
||||||
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), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST.");
|
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), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,10 @@ pub const SQLINE: Xline = Xline { kind: "Q", name: "SQLINE", target: "nick", nor
|
||||||
// connecting user's realname (use `.` for spaces, e.g. `.*free.money.*`).
|
// 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: "R", 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 };
|
||||||
|
|
||||||
fn norm_userhost(input: &str) -> Option<String> {
|
fn norm_userhost(input: &str) -> Option<String> {
|
||||||
let body = input.rsplit('!').next().unwrap_or(input);
|
let body = input.rsplit('!').next().unwrap_or(input);
|
||||||
let (user, host) = body.split_once('@')?;
|
let (user, host) = body.split_once('@')?;
|
||||||
|
|
|
||||||
|
|
@ -1565,6 +1565,7 @@ fn ban_kind_label(kind: &str) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
"Q" => "nick ban",
|
"Q" => "nick ban",
|
||||||
"R" => "realname ban",
|
"R" => "realname ban",
|
||||||
|
"SHUN" => "shun",
|
||||||
_ => "network ban",
|
_ => "network ban",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4023,6 +4024,8 @@ mod tests {
|
||||||
assert!(os(&mut e, "000AAAAAS", "STATS").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("1") && text.contains("SQLINE"))), "stats shows the sqline count");
|
assert!(os(&mut e, "000AAAAAS", "STATS").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("1") && text.contains("SQLINE"))), "stats shows the sqline count");
|
||||||
// SNLINE drives a realname R-line.
|
// SNLINE drives a realname R-line.
|
||||||
assert!(os(&mut e, "000AAAAAS", "SNLINE ADD .*free.money.* spambot").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "R" && mask == ".*free.money.*")), "R-line added");
|
assert!(os(&mut e, "000AAAAAS", "SNLINE ADD .*free.money.* spambot").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "R" && mask == ".*free.money.*")), "R-line added");
|
||||||
|
// SHUN drives a SHUN X-line on a user@host mask.
|
||||||
|
assert!(os(&mut e, "000AAAAAS", "SHUN ADD *@noisy.host quiet down").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "SHUN" && mask == "*@noisy.host")), "shun added");
|
||||||
|
|
||||||
// GLOBAL fans out to every user via the $* server glob.
|
// GLOBAL fans out to every user via the $* server glob.
|
||||||
let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5");
|
let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5");
|
||||||
|
|
@ -4090,6 +4093,49 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OperServ CHANKILL: AKILL every host in a channel at once, deduped, never
|
||||||
|
// banning the operator who ran it.
|
||||||
|
#[test]
|
||||||
|
fn operserv_chankill_clears_a_channel() {
|
||||||
|
use fedserv_operserv::OperServ;
|
||||||
|
let path = std::env::temp_dir().join("fedserv-oschankill.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.register("staff", "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() });
|
||||||
|
let conn = |e: &mut Engine, uid: &str, host: &str| e.handle(NetEvent::UserConnect { uid: uid.into(), nick: uid.into(), host: host.into(), ip: "0.0.0.0".into() });
|
||||||
|
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "staffhost".into(), ip: "0.0.0.0".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||||
|
conn(&mut e, "000AAAAA1", "bad1");
|
||||||
|
conn(&mut e, "000AAAAA2", "bad2");
|
||||||
|
conn(&mut e, "000AAAAA3", "bad1"); // same host as A1 → deduped
|
||||||
|
for u in ["000AAAAAS", "000AAAAA1", "000AAAAA2", "000AAAAA3"] {
|
||||||
|
e.handle(NetEvent::Join { uid: u.into(), channel: "#spam".into(), op: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = os(&mut e, "000AAAAAS", "CHANKILL #spam flooding");
|
||||||
|
// Both distinct spammer hosts are G-lined, once each.
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad1")), "bad1 banned: {out:?}");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad2")), "bad2 banned");
|
||||||
|
assert_eq!(out.iter().filter(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad1")).count(), 1, "deduped");
|
||||||
|
// The operator's own host is spared.
|
||||||
|
assert!(!out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@staffhost")), "operator not self-banned");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("2") && text.contains("AKILL"))), "reports 2 hosts");
|
||||||
|
}
|
||||||
|
|
||||||
// OperServ JUPE: hold a server name with a fake server, re-assert it at burst,
|
// OperServ JUPE: hold a server name with a fake server, re-assert it at burst,
|
||||||
// and lift it with a squit. Admin-only.
|
// and lift it with a squit. Admin-only.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue