Group the module crates under modules/
The service pseudo-clients and the ircd protocol link sat flat at the repo root, mixed in with the daemon core and the SDK. Move them all under modules/ so the tree separates concerns cleanly: the daemon in src/, the SDK every module links against in api/, and the loadable modules — the pseudo-clients plus the protocol link — in modules/. Workspace members, the daemon's per-crate dependency paths, and each module's api path are updated to match; the docs follow. No code change.
This commit is contained in:
parent
6f76f9722c
commit
ad2a623120
116 changed files with 69 additions and 46 deletions
8
modules/operserv/Cargo.toml
Normal file
8
modules/operserv/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "fedserv-operserv"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
description = "OperServ: network operator tools, starting with AKILL network bans."
|
||||
|
||||
[dependencies]
|
||||
fedserv-api = { path = "../../api" }
|
||||
38
modules/operserv/src/chankill.rs
Normal file
38
modules/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."));
|
||||
}
|
||||
40
modules/operserv/src/defcon.rs
Normal file
40
modules/operserv/src/defcon.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
|
||||
|
||||
// DEFCON [1-5]: read or set the network defence level. 5 is normal; each lower
|
||||
// level adds a restriction — 4 freezes channel registrations, 3 freezes all
|
||||
// registrations, 2 also caps everyone to one session, 1 is a full lockdown that
|
||||
// turns away new connections. Changing it announces the level to the network.
|
||||
// Admin-only.
|
||||
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 — DEFCON needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1) {
|
||||
None => ctx.notice(me, from.uid, format!("The network is at \x02DEFCON {}\x02 — {}.", db.defcon(), describe(db.defcon()))),
|
||||
Some(&arg) => match arg.parse::<u8>() {
|
||||
Ok(level) if (1..=5).contains(&level) => {
|
||||
db.set_defcon(level);
|
||||
ctx.notice(me, from.uid, format!("DEFCON set to \x02{level}\x02 — {}.", describe(level)));
|
||||
// Let everyone know the posture changed.
|
||||
let msg = if level == 5 {
|
||||
"The network is back to normal operations (DEFCON 5).".to_string()
|
||||
} else {
|
||||
format!("Network defence level is now \x02DEFCON {level}\x02: {}.", describe(level))
|
||||
};
|
||||
ctx.global(me, msg);
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: DEFCON [1-5] (5 = normal, 1 = lockdown)"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(level: u8) -> &'static str {
|
||||
match level {
|
||||
5 => "normal operations",
|
||||
4 => "channel registrations frozen",
|
||||
3 => "all registrations frozen",
|
||||
2 => "registrations frozen and sessions capped to one per host",
|
||||
_ => "full lockdown, no new connections",
|
||||
}
|
||||
}
|
||||
18
modules/operserv/src/global.rs
Normal file
18
modules/operserv/src/global.rs
Normal 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.");
|
||||
}
|
||||
80
modules/operserv/src/ignore.rs
Normal file
80
modules/operserv/src/ignore.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// IGNORE ADD [+expiry] <mask> [reason] | DEL <mask> | LIST: services silently
|
||||
// drop commands from a matching user. A mask with an '@' matches nick!*@host
|
||||
// (ident isn't tracked); a bare mask matches the nick. Admin-only, node-local.
|
||||
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 — IGNORE 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, ctx, db),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: IGNORE ADD [+expiry] <mask> [reason] | IGNORE DEL <mask> | IGNORE LIST"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
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((&mask, reason_words)) = rest.split_first() else {
|
||||
ctx.notice(me, from.uid, "Syntax: IGNORE ADD [+expiry] <mask> [reason]");
|
||||
return;
|
||||
};
|
||||
if mask.is_empty() || mask.chars().all(|c| c == '*' || c == '?') {
|
||||
ctx.notice(me, from.uid, "That mask is too wide.");
|
||||
return;
|
||||
}
|
||||
let reason = if reason_words.is_empty() { "No reason given".to_string() } else { reason_words.join(" ") };
|
||||
let expires = duration.map(|secs| now() + secs);
|
||||
db.ignore_add(mask, &reason, expires);
|
||||
let expiry = if expires.is_some() { " (temporary)" } else { "" };
|
||||
ctx.notice(me, from.uid, format!("Now ignoring \x02{mask}\x02{expiry}."));
|
||||
}
|
||||
|
||||
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(mask) = arg else {
|
||||
ctx.notice(me, from.uid, "Syntax: IGNORE DEL <mask>");
|
||||
return;
|
||||
};
|
||||
if db.ignore_del(mask) {
|
||||
ctx.notice(me, from.uid, format!("No longer ignoring \x02{mask}\x02."));
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("\x02{mask}\x02 isn't on the ignore list."));
|
||||
}
|
||||
}
|
||||
|
||||
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let ignores = db.ignores();
|
||||
if ignores.is_empty() {
|
||||
ctx.notice(me, from.uid, "The services ignore list is empty.");
|
||||
return;
|
||||
}
|
||||
for (i, ig) in ignores.iter().enumerate() {
|
||||
let expiry = match ig.expires {
|
||||
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
|
||||
None => String::new(),
|
||||
};
|
||||
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 — {}{}", i + 1, ig.mask, ig.reason, expiry));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of ignore list ({} shown).", ignores.len()));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
53
modules/operserv/src/info.rs
Normal file
53
modules/operserv/src/info.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
|
||||
|
||||
// INFO <target> | INFO ADD <target> <note> | INFO DEL <target>: attach a staff
|
||||
// note to an account or channel (a `#name` is a channel, else an account). The
|
||||
// note is shown to operators in that service's INFO. Admin-only.
|
||||
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 — INFO needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("ADD") | Some("SET") => set(me, from, args.get(2).copied(), args.get(3..).unwrap_or(&[]), ctx, db),
|
||||
Some("DEL") | Some("CLEAR") => set(me, from, args.get(2).copied(), &[], ctx, db),
|
||||
Some(_) => show(me, from, args[1], ctx, db),
|
||||
None => ctx.notice(me, from.uid, "Syntax: INFO <target> | INFO ADD <target> <note> | INFO DEL <target>"),
|
||||
}
|
||||
}
|
||||
|
||||
// `note` empty clears; otherwise it's the joined note words.
|
||||
fn set(me: &str, from: &Sender, target: Option<&str>, note: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(target) = target else {
|
||||
ctx.notice(me, from.uid, "Syntax: INFO ADD <target> <note> | INFO DEL <target>");
|
||||
return;
|
||||
};
|
||||
let text = note.join(" ");
|
||||
let value = if text.trim().is_empty() { None } else { Some(text) };
|
||||
let (ok, kind) = if target.starts_with('#') || target.starts_with('&') {
|
||||
(db.set_channel_note(target, value.clone()), "channel")
|
||||
} else if let Some(account) = db.resolve_account(target).map(str::to_string) {
|
||||
(db.set_account_note(&account, value.clone()), "account")
|
||||
} else {
|
||||
(false, "account")
|
||||
};
|
||||
if !ok {
|
||||
ctx.notice(me, from.uid, format!("There's no {kind} \x02{target}\x02."));
|
||||
} else if value.is_some() {
|
||||
ctx.notice(me, from.uid, format!("Staff note set on \x02{target}\x02."));
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("Staff note on \x02{target}\x02 cleared."));
|
||||
}
|
||||
}
|
||||
|
||||
fn show(me: &str, from: &Sender, target: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let note = if target.starts_with('#') || target.starts_with('&') {
|
||||
db.channel_note(target)
|
||||
} else {
|
||||
db.resolve_account(target).map(str::to_string).and_then(|a| db.account_note(&a))
|
||||
};
|
||||
match note {
|
||||
Some(n) => ctx.notice(me, from.uid, format!("Staff note on \x02{target}\x02: {n}")),
|
||||
None => ctx.notice(me, from.uid, format!("No staff note on \x02{target}\x02.")),
|
||||
}
|
||||
}
|
||||
49
modules/operserv/src/jupe.rs
Normal file
49
modules/operserv/src/jupe.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
|
||||
|
||||
// JUPE <server.name> [reason] | JUPE DEL <server.name> | JUPE LIST: hold a
|
||||
// server name with a fake server so a rogue one can't link (or lift it). Admin-
|
||||
// only. Node-local: the introducing node owns the jupe.
|
||||
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 — JUPE needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1) {
|
||||
Some(&sub) if sub.eq_ignore_ascii_case("DEL") || sub.eq_ignore_ascii_case("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
|
||||
Some(&sub) if sub.eq_ignore_ascii_case("LIST") => list(me, from, ctx, db),
|
||||
Some(&name) if name.contains('.') => {
|
||||
let reason = if args.len() > 2 { args[2..].join(" ") } else { "Juped by services".to_string() };
|
||||
let by = from.account.unwrap_or(from.nick);
|
||||
let sid = db.jupe_add(name, &format!("({by}) {reason}"));
|
||||
ctx.jupe(name, &sid, &format!("({by}) {reason}"));
|
||||
ctx.notice(me, from.uid, format!("\x02{name}\x02 is now juped."));
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: JUPE <server.name> [reason] | JUPE DEL <server.name> | JUPE LIST"),
|
||||
}
|
||||
}
|
||||
|
||||
fn del(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(name) = name else {
|
||||
ctx.notice(me, from.uid, "Syntax: JUPE DEL <server.name>");
|
||||
return;
|
||||
};
|
||||
match db.jupe_del(name) {
|
||||
Some(sid) => {
|
||||
ctx.squit(&sid, "Jupe lifted");
|
||||
ctx.notice(me, from.uid, format!("The jupe on \x02{name}\x02 has been lifted."));
|
||||
}
|
||||
None => ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't juped.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let jupes = db.jupes();
|
||||
if jupes.is_empty() {
|
||||
ctx.notice(me, from.uid, "No servers are juped.");
|
||||
return;
|
||||
}
|
||||
for (name, sid, reason) in &jupes {
|
||||
ctx.notice(me, from.uid, format!(" \x02{name}\x02 ({sid}) — {reason}"));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of jupe list ({} shown).", jupes.len()));
|
||||
}
|
||||
26
modules/operserv/src/kick.rs
Normal file
26
modules/operserv/src/kick.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use fedserv_api::{NetView, Priv, Sender, ServiceCtx};
|
||||
|
||||
// KICK <#channel> <nick> [reason]: remove a user from a channel, sourced from
|
||||
// OperServ so it's clearly a staff action. 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 — KICK 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: KICK <#channel> <nick> [reason]");
|
||||
return;
|
||||
};
|
||||
let Some(&target) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <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() > 3 { args[3..].join(" ") } else { "Removed by services".to_string() };
|
||||
ctx.kick(me, chan, &uid, &format!("({by}) {reason}"));
|
||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 kicked from \x02{chan}\x02."));
|
||||
}
|
||||
21
modules/operserv/src/kill.rs
Normal file
21
modules/operserv/src/kill.rs
Normal 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."));
|
||||
}
|
||||
92
modules/operserv/src/lib.rs
Normal file
92
modules/operserv/src/lib.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! 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 = "xline.rs"]
|
||||
mod xline;
|
||||
#[path = "global.rs"]
|
||||
mod global;
|
||||
#[path = "kill.rs"]
|
||||
mod kill;
|
||||
#[path = "kick.rs"]
|
||||
mod kick;
|
||||
#[path = "mode.rs"]
|
||||
mod mode;
|
||||
#[path = "ignore.rs"]
|
||||
mod ignore;
|
||||
#[path = "stats.rs"]
|
||||
mod stats;
|
||||
#[path = "svs.rs"]
|
||||
mod svs;
|
||||
#[path = "info.rs"]
|
||||
mod info;
|
||||
#[path = "oper.rs"]
|
||||
mod oper;
|
||||
#[path = "session.rs"]
|
||||
mod session;
|
||||
#[path = "jupe.rs"]
|
||||
mod jupe;
|
||||
#[path = "chankill.rs"]
|
||||
mod chankill;
|
||||
#[path = "logsearch.rs"]
|
||||
mod logsearch;
|
||||
#[path = "defcon.rs"]
|
||||
mod defcon;
|
||||
|
||||
pub struct OperServ {
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
impl Service for OperServ {
|
||||
fn nick(&self) -> &str {
|
||||
"OperServ"
|
||||
}
|
||||
fn uid(&self) -> &str {
|
||||
&self.uid
|
||||
}
|
||||
fn gecos(&self) -> &str {
|
||||
"Operator Service"
|
||||
}
|
||||
|
||||
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() {
|
||||
ctx.notice(me, from.uid, "Access denied — OperServ is for services operators.");
|
||||
return;
|
||||
}
|
||||
match args.first().copied() {
|
||||
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("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("CBAN") => xline::CBAN.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("KICK") => kick::handle(me, from, args, ctx, net),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("MODE") => mode::handle(me, from, args, ctx, net),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("IGNORE") => ignore::handle(me, from, args, ctx, db),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("STATS") => stats::handle(me, from, db, ctx),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db),
|
||||
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("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("LOGSEARCH") => logsearch::handle(me, from, args, ctx, net),
|
||||
Some(cmd) if cmd.eq_ignore_ascii_case("DEFCON") => defcon::handle(me, from, args, ctx, db),
|
||||
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 \x02HELP\x02.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \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 — bulletins are on \x02InfoServ\x02), \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, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level).");
|
||||
}
|
||||
23
modules/operserv/src/logsearch.rs
Normal file
23
modules/operserv/src/logsearch.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use fedserv_api::{human_time, NetView, Priv, Sender, ServiceCtx};
|
||||
|
||||
// LOGSEARCH [pattern]: search the recent action log — every kick, kill, ban,
|
||||
// registration/drop, akill, suspension, vhost, note, and so on. A bare id (as
|
||||
// stamped into a kick reason, e.g. from `[#3F]`) or any word from the summary
|
||||
// matches; no argument lists the most recent. Read-only, so any operator who can
|
||||
// see hidden info (Priv::Auspex) may run it.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
|
||||
if !from.privs.has(Priv::Auspex) {
|
||||
ctx.notice(me, from.uid, "Access denied — LOGSEARCH needs the \x02auspex\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
let pattern = args[1..].join(" ");
|
||||
let hits = net.search_incidents(&pattern, 20);
|
||||
if hits.is_empty() {
|
||||
ctx.notice(me, from.uid, "No matching log entries.");
|
||||
return;
|
||||
}
|
||||
for h in &hits {
|
||||
ctx.notice(me, from.uid, format!("[\x02#{}\x02] {} — {}", h.id, human_time(h.ts), h.summary));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of results ({} shown, newest first — refine the search to narrow).", hits.len()));
|
||||
}
|
||||
45
modules/operserv/src/mode.rs
Normal file
45
modules/operserv/src/mode.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use fedserv_api::{chanmode_takes_param, NetView, Priv, Sender, ServiceCtx, STATUS_MODES};
|
||||
|
||||
// MODE <#channel> <modes> [params]: set channel modes as a services override
|
||||
// (forced, so it applies regardless of the current TS). Admin-only. Status-mode
|
||||
// targets may be given as nicks — they're resolved to uids for the ircd.
|
||||
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 — MODE 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: MODE <#channel> <modes> [params]");
|
||||
return;
|
||||
};
|
||||
let Some(&modes) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: MODE <#channel> <modes> [params]");
|
||||
return;
|
||||
};
|
||||
let params = &args[3..];
|
||||
|
||||
// Walk the change alongside its params: each param-taking mode consumes one,
|
||||
// and a status mode's param is a nick we translate to its uid.
|
||||
let (mut adding, mut pi) = (true, 0);
|
||||
let mut out_params: Vec<String> = Vec::new();
|
||||
for m in modes.chars() {
|
||||
match m {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
_ if chanmode_takes_param(m, adding) => {
|
||||
if let Some(&p) = params.get(pi) {
|
||||
pi += 1;
|
||||
if STATUS_MODES.contains(m) {
|
||||
out_params.push(net.uid_by_nick(p).map(str::to_string).unwrap_or_else(|| p.to_string()));
|
||||
} else {
|
||||
out_params.push(p.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let full = if out_params.is_empty() { modes.to_string() } else { format!("{} {}", modes, out_params.join(" ")) };
|
||||
ctx.channel_mode(me, chan, &full);
|
||||
ctx.notice(me, from.uid, format!("Set \x02{modes}\x02 on \x02{chan}\x02."));
|
||||
}
|
||||
74
modules/operserv/src/oper.rs
Normal file
74
modules/operserv/src/oper.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// OPER ADD <account> <priv[,priv…]> [+duration] | OPER DEL <account> | OPER LIST:
|
||||
// manage runtime services operators (merged with the declarative config opers).
|
||||
// The privileges are auspex, suspend, admin; a +duration makes the grant expire.
|
||||
// Admin-only — only an admin grants oper.
|
||||
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 — OPER needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("ADD") | Some("SET") => add(me, from, args.get(2).copied(), args.get(3).copied(), args.get(4).copied(), ctx, db),
|
||||
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
|
||||
Some("LIST") | Some("VIEW") => list(me, from, ctx, db),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: OPER ADD <account> <priv[,priv]> [+duration] | OPER DEL <account> | OPER LIST — privs: auspex, suspend, admin"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(me: &str, from: &Sender, account: Option<&str>, privs: Option<&str>, dur: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let (Some(account), Some(privs)) = (account, privs) else {
|
||||
ctx.notice(me, from.uid, "Syntax: OPER ADD <account> <priv[,priv]> [+duration]");
|
||||
return;
|
||||
};
|
||||
let Some(account) = db.resolve_account(account).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered."));
|
||||
return;
|
||||
};
|
||||
// Keep only recognised privilege names.
|
||||
let names: Vec<String> = privs
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_ascii_lowercase())
|
||||
.filter(|p| matches!(p.as_str(), "auspex" | "suspend" | "admin"))
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
ctx.notice(me, from.uid, "No valid privileges given (auspex, suspend, admin).");
|
||||
return;
|
||||
}
|
||||
let expires = dur.and_then(|d| d.strip_prefix('+')).and_then(parse_duration).map(|secs| now() + secs);
|
||||
db.oper_grant(&account, names.clone(), expires);
|
||||
let window = if expires.is_some() { " (temporary)" } else { "" };
|
||||
ctx.notice(me, from.uid, format!("\x02{account}\x02 is now an operator ({}){window}.", names.join(", ")));
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn del(me: &str, from: &Sender, account: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(account) = account else {
|
||||
ctx.notice(me, from.uid, "Syntax: OPER DEL <account>");
|
||||
return;
|
||||
};
|
||||
let canonical = db.resolve_account(account).map(str::to_string).unwrap_or_else(|| account.to_string());
|
||||
if db.oper_revoke(&canonical) {
|
||||
ctx.notice(me, from.uid, format!("\x02{canonical}\x02 is no longer a runtime operator."));
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't a runtime operator (config opers are set in the daemon config)."));
|
||||
}
|
||||
}
|
||||
|
||||
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let opers = db.opers_list();
|
||||
if opers.is_empty() {
|
||||
ctx.notice(me, from.uid, "No runtime operators (config opers are set in the daemon config).");
|
||||
return;
|
||||
}
|
||||
for (account, privs, expires) in &opers {
|
||||
let window = if expires.is_some() { " (temporary)" } else { "" };
|
||||
ctx.notice(me, from.uid, format!(" \x02{account}\x02 — {}{window}", privs.join(", ")));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of runtime operator list ({} shown).", opers.len()));
|
||||
}
|
||||
76
modules/operserv/src/session.rs
Normal file
76
modules/operserv/src/session.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
|
||||
|
||||
// SESSION LIST <min> | SESSION VIEW <ip>: inspect live per-IP session counts.
|
||||
// EXCEPTION ADD <ip-mask> <limit> [reason] | DEL <ip-mask> | LIST: manage the
|
||||
// per-IP-mask allowances that override the default limit. All admin-only.
|
||||
pub fn handle_session(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 — SESSION needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("LIST") => {
|
||||
let min = args.get(2).and_then(|n| n.parse::<u32>().ok()).unwrap_or(2);
|
||||
let over = net.sessions_over(min);
|
||||
if over.is_empty() {
|
||||
ctx.notice(me, from.uid, format!("No IP has {min} or more sessions."));
|
||||
return;
|
||||
}
|
||||
for (ip, n) in &over {
|
||||
ctx.notice(me, from.uid, format!(" \x02{n}\x02 sessions from \x02{ip}\x02"));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of session list ({} IP(s)).", over.len()));
|
||||
}
|
||||
Some("VIEW") => {
|
||||
let Some(&ip) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SESSION VIEW <ip>");
|
||||
return;
|
||||
};
|
||||
ctx.notice(me, from.uid, format!("\x02{ip}\x02 has \x02{}\x02 live session(s).", net.session_count(ip)));
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SESSION LIST <min> | SESSION VIEW <ip>"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_exception(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 — EXCEPTION needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("ADD") => {
|
||||
let (Some(&mask), Some(limit)) = (args.get(2), args.get(3).and_then(|n| n.parse::<u32>().ok())) else {
|
||||
ctx.notice(me, from.uid, "Syntax: EXCEPTION ADD <ip-mask> <limit> [reason]");
|
||||
return;
|
||||
};
|
||||
let reason = if args.len() > 4 { args[4..].join(" ") } else { "No reason given".to_string() };
|
||||
db.session_except_add(mask, limit, &reason);
|
||||
let note = if limit == 0 { " (unlimited)".to_string() } else { format!(" (limit {limit})") };
|
||||
ctx.notice(me, from.uid, format!("Session exception for \x02{mask}\x02 set{note}."));
|
||||
}
|
||||
Some("DEL") | Some("REMOVE") => {
|
||||
let Some(&mask) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: EXCEPTION DEL <ip-mask>");
|
||||
return;
|
||||
};
|
||||
if db.session_except_del(mask) {
|
||||
ctx.notice(me, from.uid, format!("Session exception for \x02{mask}\x02 removed."));
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("No session exception matches \x02{mask}\x02."));
|
||||
}
|
||||
}
|
||||
Some("LIST") => {
|
||||
let list = db.session_exceptions();
|
||||
if list.is_empty() {
|
||||
ctx.notice(me, from.uid, "No session exceptions.");
|
||||
return;
|
||||
}
|
||||
for (mask, limit, reason) in &list {
|
||||
let lim = if *limit == 0 { "unlimited".to_string() } else { limit.to_string() };
|
||||
ctx.notice(me, from.uid, format!(" \x02{mask}\x02 → {lim} — {reason}"));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("End of exception list ({} shown).", list.len()));
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: EXCEPTION ADD <ip-mask> <limit> [reason] | EXCEPTION DEL <ip-mask> | EXCEPTION LIST"),
|
||||
}
|
||||
}
|
||||
13
modules/operserv/src/stats.rs
Normal file
13
modules/operserv/src/stats.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
use fedserv_api::{Sender, ServiceCtx, Store};
|
||||
|
||||
// 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 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."));
|
||||
}
|
||||
45
modules/operserv/src/svs.rs
Normal file
45
modules/operserv/src/svs.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use fedserv_api::{NetView, Priv, Sender, ServiceCtx};
|
||||
|
||||
// SVSNICK <nick> <newnick>: force a user to change nick. Admin-only.
|
||||
pub fn nick(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 — SVSNICK needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
let (Some(&target), Some(&newnick)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SVSNICK <nick> <newnick>");
|
||||
return;
|
||||
};
|
||||
if newnick.is_empty() || newnick.starts_with('#') || newnick.contains(|c: char| c.is_whitespace() || c == ',') {
|
||||
ctx.notice(me, from.uid, format!("\x02{newnick}\x02 isn't a valid nick."));
|
||||
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;
|
||||
};
|
||||
ctx.force_nick(&uid, newnick);
|
||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been renamed to \x02{newnick}\x02."));
|
||||
}
|
||||
|
||||
// SVSJOIN <nick> <#channel> [key]: force a user into a channel. Admin-only.
|
||||
pub fn join(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 — SVSJOIN needs the \x02admin\x02 privilege.");
|
||||
return;
|
||||
}
|
||||
let (Some(&target), Some(&chan)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SVSJOIN <nick> <#channel> [key]");
|
||||
return;
|
||||
};
|
||||
if !chan.starts_with('#') && !chan.starts_with('&') {
|
||||
ctx.notice(me, from.uid, "SVSJOIN targets a channel.");
|
||||
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;
|
||||
};
|
||||
ctx.force_join(&uid, chan, args.get(3).copied().unwrap_or(""));
|
||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been joined to \x02{chan}\x02."));
|
||||
}
|
||||
184
modules/operserv/src/xline.rs
Normal file
184
modules/operserv/src/xline.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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 };
|
||||
|
||||
// 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 };
|
||||
|
||||
// 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 };
|
||||
|
||||
// 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 };
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn norm_realname(input: &str) -> Option<String> {
|
||||
// A realname regex the ircd evaluates: keep it verbatim (case matters), just
|
||||
// require it non-empty. A single token — use `.`/`\s` for spaces.
|
||||
(!input.is_empty()).then(|| input.to_string())
|
||||
}
|
||||
|
||||
fn norm_channel(input: &str) -> Option<String> {
|
||||
// A channel-name glob: must carry a channel prefix and a non-empty body.
|
||||
if (!input.starts_with('#') && !input.starts_with('&')) || input.len() < 2 || input.contains(|c: char| c.is_whitespace() || c == ',') {
|
||||
return None;
|
||||
}
|
||||
Some(input.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
// A mask whose every meaningful character is a wildcard would match nearly all.
|
||||
// A leading channel prefix is ignored so `#*` counts as too wide.
|
||||
fn too_wide(mask: &str) -> bool {
|
||||
let body = mask.strip_prefix('#').or_else(|| mask.strip_prefix('&')).unwrap_or(mask);
|
||||
body.is_empty() || body.chars().all(|c| c == '*' || c == '?' || c == '.' || c == '@')
|
||||
}
|
||||
|
||||
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