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:
Jean Chevronnet 2026-07-14 14:19:43 +00:00
parent 6f76f9722c
commit ad2a623120
No known key found for this signature in database
116 changed files with 69 additions and 46 deletions

View 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" }

View 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."));
}

View 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",
}
}

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.");
}

View 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)
}

View 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.")),
}
}

View 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()));
}

View 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."));
}

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

@ -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).");
}

View 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()));
}

View 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."));
}

View 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()));
}

View 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"),
}
}

View 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."));
}

View 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."));
}

View 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)
}