From d2c1d076fb6ad1f4113397b0a802b01bd2e3505c Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 00:12:29 +0000 Subject: [PATCH] Add OperServ with AKILL network bans The first operator module. AKILL manages network bans as a typed, event-sourced list with lazy expiry, mirroring the patterns the other modules use: - AKILL ADD [+expiry] stores the ban and drives an ircd G-line (applied to matching users already online and to future connections); AKILL DEL lifts it; AKILL LIST [pattern] shows the live list, numbered. Admin-only. - Bans are Global events, so every node holds the list and re-asserts each one at burst with its remaining duration. Expired bans are hidden lazily and dropped at compaction. - Masks are normalised to user@host (a nick! prefix is stripped, both sides lowercased) and an all-wildcard mask is refused. Email (send_email) is already reachable from the api via ServiceCtx, and adds AddLine/DelLine to the action vocabulary for any future X-line kinds. --- Cargo.lock | 8 +++ Cargo.toml | 3 +- api/src/lib.rs | 38 ++++++++++++ inspircd/src/lib.rs | 25 ++++++++ operserv/Cargo.toml | 8 +++ operserv/src/akill.rs | 140 ++++++++++++++++++++++++++++++++++++++++++ operserv/src/lib.rs | 45 ++++++++++++++ src/config.rs | 2 +- src/engine/db.rs | 94 +++++++++++++++++++++++++--- src/engine/mod.rs | 69 +++++++++++++++++++++ src/grpc.rs | 4 +- src/main.rs | 6 ++ 12 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 operserv/Cargo.toml create mode 100644 operserv/src/akill.rs create mode 100644 operserv/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index fbfdd1c..4344468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,7 @@ dependencies = [ "fedserv-inspircd", "fedserv-memoserv", "fedserv-nickserv", + "fedserv-operserv", "fedserv-statserv", "hmac", "pbkdf2", @@ -406,6 +407,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-operserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-statserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 6e2644d..81c7558 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv"] [package] name = "fedserv" @@ -17,6 +17,7 @@ fedserv-botserv = { path = "botserv" } fedserv-memoserv = { path = "memoserv" } fedserv-statserv = { path = "statserv" } fedserv-hostserv = { path = "hostserv" } +fedserv-operserv = { path = "operserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index 39f0f9c..d4e71c8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -86,6 +86,12 @@ pub enum NetAction { Topic { from: String, channel: String, topic: String }, // Invite a user to a channel, sourced from pseudoclient `from`. Invite { from: String, uid: String, channel: String }, + // Add a network ban (X-line — `kind` is the ircd's line type, e.g. "G" for a + // G-line) covering `mask`, applied to matching users already online and to + // future connections. `duration` in seconds, 0 = permanent. + AddLine { kind: String, mask: String, setter: String, duration: u64, reason: String }, + // Lift a network ban previously set with AddLine. + DelLine { kind: String, mask: String }, Raw(String), // Internal only, never serialized to the wire: a registration whose password // still needs its (expensive) key derivation. The link layer runs the @@ -324,6 +330,22 @@ impl ServiceCtx { channel: channel.to_string(), }); } + + // Set a network ban (X-line) at the ircd. `duration` seconds, 0 = permanent. + pub fn add_line(&mut self, kind: &str, mask: &str, setter: &str, duration: u64, reason: &str) { + self.actions.push(NetAction::AddLine { + kind: kind.to_string(), + mask: mask.to_string(), + setter: setter.to_string(), + duration, + reason: reason.to_string(), + }); + } + + // Lift a network ban previously set with `add_line`. + pub fn del_line(&mut self, kind: &str, mask: &str) { + self.actions.push(NetAction::DelLine { kind: kind.to_string(), mask: mask.to_string() }); + } } // --------------------------------------------------------------------------- @@ -386,6 +408,16 @@ pub struct VhostView { pub expires: Option, } +// A network ban (AKILL / G-line) held by OperServ. +#[derive(Debug, Clone)] +pub struct AkillView { + pub mask: String, + pub setter: String, + pub reason: String, + pub ts: u64, + pub expires: Option, +} + #[derive(Debug, Clone)] pub struct BotView { pub nick: String, @@ -632,6 +664,12 @@ pub trait Store { // Inactivity-expiry pins (oper-only, gated on Priv::Admin at the command layer). fn set_account_noexpire(&mut self, account: &str, on: bool) -> Result; fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result; + // Network bans (AKILL / G-lines, oper-only). `akill_add` returns whether the + // mask was newly added (false = an existing entry was refreshed); `akills` + // lists only entries that haven't lazily expired, oldest first. + fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option) -> Result; + fn akill_del(&mut self, mask: &str) -> Result; + fn akills(&self) -> Vec; fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; diff --git a/inspircd/src/lib.rs b/inspircd/src/lib.rs index 27fe962..85b679e 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -318,6 +318,14 @@ impl Protocol for InspIrcd { NetAction::Invite { from, uid, channel } => { vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)] } + // ADDLINE :. A + // duration of 0 is permanent; the ircd applies it to matching users + // already online and propagates it across the network. + NetAction::AddLine { kind, mask, setter, duration, reason } => { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); + vec![self.sourced(format!("ADDLINE {} {} {} {} {} :{}", kind, mask, setter, now, duration, reason))] + } + NetAction::DelLine { kind, mask } => vec![self.sourced(format!("DELLINE {} {}", kind, mask))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![], @@ -485,6 +493,23 @@ mod tests { assert!(!lines[0].contains('\n') && !lines[0].contains('\r')); } + // A network ban serializes to ADDLINE / DELLINE, sourced from our server. + #[test] + fn serializes_network_bans() { + let add = proto().serialize(&NetAction::AddLine { + kind: "G".into(), + mask: "*@evil.host".into(), + setter: "staff".into(), + duration: 3600, + reason: "spamming".into(), + }); + assert_eq!(add.len(), 1); + assert!(add[0].starts_with(":42S ADDLINE G *@evil.host staff "), "{add:?}"); + assert!(add[0].ends_with(" 3600 :spamming"), "{add:?}"); + let del = proto().serialize(&NetAction::DelLine { kind: "G".into(), mask: "*@evil.host".into() }); + assert_eq!(del, vec![":42S DELLINE G *@evil.host".to_string()]); + } + #[test] fn parses_ftopic_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all"); diff --git a/operserv/Cargo.toml b/operserv/Cargo.toml new file mode 100644 index 0000000..8e03833 --- /dev/null +++ b/operserv/Cargo.toml @@ -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" } diff --git a/operserv/src/akill.rs b/operserv/src/akill.rs new file mode 100644 index 0000000..b4d755e --- /dev/null +++ b/operserv/src/akill.rs @@ -0,0 +1,140 @@ +use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// AKILL ADD [+expiry] | DEL | LIST +// [pattern]: manage network bans. Admin-only — a network ban is the heaviest +// hammer services hold. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — AKILL needs the \x02admin\x02 privilege."); + return; + } + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => add(me, from, &args[2..], ctx, db), + Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db), + Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db), + _ => ctx.notice(me, from.uid, "Syntax: AKILL ADD [+expiry] | AKILL DEL | AKILL LIST [pattern]"), + } +} + +fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + // An optional leading +duration, then the mask, then a free-text reason. + let mut rest = rest; + let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration); + if duration.is_some() { + rest = &rest[1..]; + } + let Some((&raw_mask, reason_words)) = rest.split_first() else { + ctx.notice(me, from.uid, "Syntax: AKILL ADD [+expiry] "); + return; + }; + let Some(mask) = normalize_mask(raw_mask) else { + ctx.notice(me, from.uid, format!("\x02{raw_mask}\x02 isn't a valid \x02user@host\x02 mask.")); + return; + }; + if reason_words.is_empty() { + ctx.notice(me, from.uid, "Please give a reason: AKILL ADD [+expiry] "); + return; + } + let reason = reason_words.join(" "); + // Refuse a mask so broad it would ban most of the network. + if too_wide(&mask) { + ctx.notice(me, from.uid, "That mask is too wide — it would ban almost everyone."); + return; + } + let setter = from.account.unwrap_or(from.nick); + let expires = duration.map(|secs| now() + secs); + match db.akill_add(&mask, setter, &reason, expires) { + Ok(fresh) => { + // The ircd applies the G-line to matching users already online and to + // future connections; duration 0 = permanent. + ctx.add_line("G", &mask, from.nick, duration.unwrap_or(0), &reason); + let word = if fresh { "added" } else { "updated" }; + let expiry = if expires.is_some() { " (temporary)" } else { "" }; + ctx.notice(me, from.uid, format!("AKILL {word} for \x02{mask}\x02{expiry}.")); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(arg) = arg else { + ctx.notice(me, from.uid, "Syntax: AKILL DEL "); + return; + }; + // A number targets the entry at that position in AKILL LIST; otherwise it's a + // literal mask. + let mask = match arg.parse::() { + Ok(n) if n >= 1 => match db.akills().get(n - 1) { + Some(a) => a.mask.clone(), + None => { + ctx.notice(me, from.uid, format!("There's no AKILL number \x02{n}\x02.")); + return; + } + }, + _ => normalize_mask(arg).unwrap_or_else(|| arg.to_string()), + }; + match db.akill_del(&mask) { + Ok(true) => { + ctx.del_line("G", &mask); + ctx.notice(me, from.uid, format!("AKILL for \x02{mask}\x02 removed.")); + } + Ok(false) => ctx.notice(me, from.uid, format!("No AKILL matches \x02{mask}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn list(me: &str, from: &Sender, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let pat = pattern.map(|p| p.to_ascii_lowercase()); + let akills = db.akills(); + let mut shown = 0; + for (i, a) in akills.iter().enumerate() { + if let Some(p) = &pat { + if !a.mask.to_ascii_lowercase().contains(p.as_str()) { + continue; + } + } + let expiry = match a.expires { + Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))), + None => String::new(), + }; + ctx.notice(me, from.uid, format!("{}. \x02{}\x02 by {} — {}{}", i + 1, a.mask, a.setter, a.reason, expiry)); + shown += 1; + } + if shown == 0 { + ctx.notice(me, from.uid, "No matching network bans."); + } else { + ctx.notice(me, from.uid, format!("End of AKILL list ({shown} shown).")); + } +} + +// Reduce an input to a `user@host` mask: drop any `nick!` prefix, require a +// single `@` with non-empty sides. Returns None if it isn't shaped like a mask. +fn normalize_mask(input: &str) -> Option { + let body = input.rsplit('!').next().unwrap_or(input); + let (user, host) = body.split_once('@')?; + if user.is_empty() || host.is_empty() || host.contains('@') { + return None; + } + Some(format!("{}@{}", user.to_ascii_lowercase(), host.to_ascii_lowercase())) +} + +// A mask whose user and host are both pure wildcard would catch nearly everyone. +fn too_wide(mask: &str) -> bool { + let Some((user, host)) = mask.split_once('@') else { return true }; + let trivial = |s: &str| s.chars().all(|c| c == '*' || c == '?' || c == '.'); + trivial(user) && trivial(host) +} + +fn human_secs(secs: u64) -> String { + match secs { + 0 => "moments".to_string(), + s if s < 3600 => format!("{}m", s / 60), + s if s < 86_400 => format!("{}h", s / 3600), + s => format!("{}d", s / 86_400), + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs new file mode 100644 index 0000000..eede665 --- /dev/null +++ b/operserv/src/lib.rs @@ -0,0 +1,45 @@ +//! OperServ gives services operators network-wide tools. The first is AKILL: +//! network bans (G-lines) that keep matching users off the whole network, +//! event-sourced so they survive a restart and re-apply at burst, with lazy +//! expiry like the rest of the store. `lib.rs` dispatches; each command is its +//! own file. + +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +#[path = "akill.rs"] +mod akill; + +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") => akill::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 \x02AKILL\x02 or \x02HELP\x02.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "OperServ holds network operator tools. \x02AKILL ADD\x02 [+expiry] , \x02AKILL DEL\x02 , \x02AKILL LIST\x02 [pattern] — network bans (Priv::Admin)."); +} diff --git a/src/config.rs b/src/config.rs index a92ac74..168ffb1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -107,7 +107,7 @@ impl Default for Modules { } fn default_services() -> Vec { - vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string()] + vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string(), "operserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/db.rs b/src/engine/db.rs index 7281528..3050735 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -30,7 +30,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,6 +172,10 @@ pub enum Event { ChannelUsed { channel: String, ts: u64 }, AccountNoExpire { account: String, on: bool }, ChannelNoExpire { channel: String, on: bool }, + // Network bans (OperServ AKILL). Global: a ban covers the whole network, so + // every node holds the list and re-applies it at burst. + AkillAdded { mask: String, setter: String, reason: String, ts: u64, expires: Option }, + AkillRemoved { mask: String }, } // Whether an event replicates across the federation. Account identity is Global @@ -209,7 +213,9 @@ impl Event { | Event::NickGrouped { .. } | Event::NickUngrouped { .. } | Event::AccountSeen { .. } - | Event::AccountNoExpire { .. } => Scope::Global, + | Event::AccountNoExpire { .. } + | Event::AkillAdded { .. } + | Event::AkillRemoved { .. } => Scope::Global, Event::ChannelRegistered { .. } | Event::ChannelDropped { .. } | Event::ChannelMlock { .. } @@ -267,6 +273,18 @@ pub struct Suspension { pub expires: Option, } +// A network ban (AKILL / G-line): a user@host mask, who set it, why, when, and +// an optional absolute-unix-seconds expiry (None = permanent). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Akill { + pub mask: String, + pub setter: String, + pub reason: String, + pub ts: u64, + #[serde(default)] + pub expires: Option, +} + // A memo left for an account (MemoServ). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memo { @@ -828,6 +846,8 @@ pub struct Db { // HostServ node config: the self-serve offer menu, the forbidden-pattern // blocklist, and the auto-vhost template. host_cfg: HostConfig, + // Network bans (OperServ AKILL), in insertion order. + akills: Vec, } // Network-wide HostServ configuration, rebuilt from the event log. @@ -878,11 +898,12 @@ impl Db { let mut grouped = HashMap::new(); let mut bots = HashMap::new(); let mut host_cfg = HostConfig::default(); + let mut akills = Vec::new(); for event in events { - apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, event); + apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut akills, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, akills } } /// Fold an entry authored by another node into the store — the services-side @@ -898,7 +919,7 @@ impl Db { _ => None, }; if let Some(event) = self.log.ingest(entry)? { - apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, event); + apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.akills, event); } if let Some((name, prev_home)) = watched { match (prev_home, self.account(&name).map(|c| c.home.clone())) { @@ -968,6 +989,11 @@ impl Db { if self.host_cfg.template.is_some() { snapshot.push(Event::VhostTemplateSet { template: self.host_cfg.template.clone() }); } + // Compaction is a good moment to forget akills that have already expired. + let now = now(); + for a in self.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) { + snapshot.push(Event::AkillAdded { mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }); + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) @@ -1644,6 +1670,38 @@ impl Db { .collect() } + /// Add (or refresh) a network ban. Returns whether the mask was newly added. + pub fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { + let fresh = !self.akills.iter().any(|a| a.mask.eq_ignore_ascii_case(mask) && a.expires.is_none_or(|e| e > now())); + self.log + .append(Event::AkillAdded { mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }) + .map_err(|_| RegError::Internal)?; + self.akills.retain(|a| !a.mask.eq_ignore_ascii_case(mask)); + self.akills.push(Akill { mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }); + Ok(fresh) + } + + /// Lift a network ban. Returns whether a live (non-expired) one was removed. + pub fn akill_del(&mut self, mask: &str) -> Result { + let existed = self.akills.iter().any(|a| a.mask.eq_ignore_ascii_case(mask) && a.expires.is_none_or(|e| e > now())); + if !existed { + return Ok(false); + } + self.log.append(Event::AkillRemoved { mask: mask.to_string() }).map_err(|_| RegError::Internal)?; + self.akills.retain(|a| !a.mask.eq_ignore_ascii_case(mask)); + Ok(true) + } + + /// The live network bans (expired ones hidden lazily), oldest first. + pub fn akills(&self) -> Vec { + let now = now(); + self.akills + .iter() + .filter(|a| a.expires.is_none_or(|e| e > now)) + .map(|a| AkillView { mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }) + .collect() + } + /// The account's suspension record, if any (shown in INFO even once expired). pub fn suspension(&self, account: &str) -> Option { self.accounts @@ -2284,7 +2342,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool { // Fold one event into the store. Shared by log replay (`open`) and gossip // ingest, so both routes reconstruct identical state. -fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, host_cfg: &mut HostConfig, event: Event) { +fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, host_cfg: &mut HostConfig, akills: &mut Vec, event: Event) { match event { Event::AccountRegistered(a) => { // Resolve a concurrent registration of the same name deterministically: @@ -2540,6 +2598,15 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + // Keyed by mask (case-insensitive): a re-add refreshes in place, so + // replaying over a snapshot stays idempotent. + akills.retain(|a| !a.mask.eq_ignore_ascii_case(&mask)); + akills.push(Akill { mask, setter, reason, ts, expires }); + } + Event::AkillRemoved { mask } => { + akills.retain(|a| !a.mask.eq_ignore_ascii_case(&mask)); + } } } @@ -2772,6 +2839,15 @@ impl Store for Db { fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result { Db::set_channel_noexpire(self, channel, on) } + fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { + Db::akill_add(self, mask, setter, reason, expires) + } + fn akill_del(&mut self, mask: &str) -> Result { + Db::akill_del(self, mask) + } + fn akills(&self) -> Vec { + Db::akills(self) + } fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { Db::register_channel(self, name, founder) } @@ -2975,9 +3051,9 @@ mod tests { ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, }; let converge = |first: &Account, second: &Account| { - let (mut acc, mut ch, mut gr, mut bo, mut hc) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default()); - apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, Event::AccountRegistered(Box::new(first.clone()))); - apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, Event::AccountRegistered(Box::new(second.clone()))); + let (mut acc, mut ch, mut gr, mut bo, mut hc, mut ak) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), Vec::new()); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(first.clone()))); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(second.clone()))); acc["alice"].password_hash.clone() }; // Earlier registration wins, regardless of which claim applies first. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index bff8450..3fe9126 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -653,6 +653,13 @@ impl Engine { // clients in CAP LS (IRCv3 SASL 3.2). // Introduce the registered bots too. out.extend(self.reconcile_bots()); + // Re-assert the network bans over the fresh link, each with its remaining + // duration so the ircd expires it at the right time (permanent = 0). + let now = self.now_secs(); + for a in self.db.akills() { + let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0); + out.push(NetAction::AddLine { kind: "G".to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason }); + } out.push(NetAction::Metadata { target: "*".to_string(), key: "saslmechlist".to_string(), @@ -1511,6 +1518,11 @@ fn audit_summary(event: &db::Event) -> Option { Some(t) => format!("set the vhost template to \x02{t}\x02"), None => "cleared the vhost template".to_string(), }, + AkillAdded { mask, reason, expires, .. } => { + let kind = if expires.is_some() { " (temporary)" } else { "" }; + format!("set a network ban on \x02{mask}\x02{kind} ({reason})") + } + AkillRemoved { mask } => format!("lifted the network ban on \x02{mask}\x02"), AccountNoExpire { account, on } => { let verb = if *on { "pinned" } else { "unpinned" }; format!("{verb} account \x02{account}\x02 against expiry") @@ -3745,6 +3757,63 @@ mod tests { assert!(unreg, "expired channel had +r cleared"); } + // OperServ AKILL: an admin adds/lists/removes network bans, they drive the + // ircd's G-lines, persist for re-assertion at burst, and non-admins are shut + // out entirely. + #[test] + fn operserv_akill_add_list_del_and_burst() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-akill.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + db.register("nobody", "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()); + e.set_log_channel(Some("#services".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() }); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAN".into(), nick: "nobody".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAN".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + + // A non-admin can't even see OperServ exists beyond the refusal. + let denied = os(&mut e, "000AAAAAN", "AKILL ADD *@evil.host being evil"); + assert!(denied.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused: {denied:?}"); + assert!(!denied.iter().any(|a| matches!(a, NetAction::AddLine { .. })), "no ban from a non-admin"); + + // Admin adds a temporary ban: it drives a G-line and is audited. + let out = os(&mut e, "000AAAAAS", "AKILL ADD +1h *@evil.host spamming"); + assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, duration, .. } if kind == "G" && mask == "*@evil.host" && *duration == 3600)), "G-line added: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "#services" && text.contains("*@evil.host"))), "ban audited: {out:?}"); + + // A too-wide mask is refused outright. + assert!(os(&mut e, "000AAAAAS", "AKILL ADD *@* everything").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("too wide"))), "wildcard mask refused"); + + // LIST shows it, numbered. + assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("1.") && text.contains("*@evil.host"))), "listed"); + + // It survives to burst: startup re-asserts the G-line with a remaining + // duration, not the original 3600. + assert!(e.startup_actions().iter().any(|a| matches!(a, NetAction::AddLine { mask, duration, .. } if mask == "*@evil.host" && *duration > 0 && *duration <= 3600)), "re-asserted at burst"); + + // DEL by number removes it and lifts the G-line. + let out = os(&mut e, "000AAAAAS", "AKILL DEL 1"); + assert!(out.iter().any(|a| matches!(a, NetAction::DelLine { kind, mask } if kind == "G" && mask == "*@evil.host")), "G-line lifted: {out:?}"); + assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("No matching"))), "list now empty"); + } + // NOEXPIRE is oper-only. #[test] fn noexpire_command_is_oper_gated() { diff --git a/src/grpc.rs b/src/grpc.rs index e676013..b318c1e 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -164,7 +164,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AccountSeen { .. } | Event::ChannelUsed { .. } | Event::AccountNoExpire { .. } - | Event::ChannelNoExpire { .. } => return None, + | Event::ChannelNoExpire { .. } + | Event::AkillAdded { .. } + | Event::AkillRemoved { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) } diff --git a/src/main.rs b/src/main.rs index 293e56f..1fd19d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use fedserv_chanserv::ChanServ; use fedserv_memoserv::MemoServ; use fedserv_statserv::StatServ; use fedserv_hostserv::HostServ; +use fedserv_operserv::OperServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -83,6 +84,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAG", cfg.server.sid), })); } + if enabled("operserv") { + services.push(Box::new(OperServ { + uid: format!("{}AAAAAH", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),