From d2a8d0ef05ad10b033fa9ff43d252ec0f8730684 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 02:09:45 +0000 Subject: [PATCH] Stamp a traceable incident id into every kick and kill A single choke-point in handle() gives each outgoing Kick / KillUser a short id, appends it to the reason the user sees ([#3F]), and records a searchable summary in a bounded per-node incident ring on the live network view. So a bot flood-kick, a fantasy kick, a votekick, and an operator KICK are all logged the same way, and the id in a kick reason ties back to the log entry. Exposed via NetView::search_incidents (id-exact or summary substring, newest first) for the LOGSEARCH command to come. --- api/src/lib.rs | 13 +++++++++ src/engine/mod.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++ src/engine/state.rs | 62 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 5c439bd..14b0a3d 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -861,6 +861,19 @@ pub trait NetView { // Session limiting: live sessions from an IP, and IPs at/over a threshold. fn session_count(&self, ip: &str) -> u32; fn sessions_over(&self, min: u32) -> Vec<(String, u32)>; + // Search the recent moderation/action incident log (newest first, capped at + // `limit`). An empty pattern returns the most recent; otherwise matches the + // id or a case-insensitive substring of the summary. + fn search_incidents(&self, pattern: &str, limit: usize) -> Vec; +} + +// One recorded action from the incident log: a short id (also stamped into the +// action's reason), when it happened, and a human summary. +#[derive(Debug, Clone)] +pub struct IncidentView { + pub id: String, + pub ts: u64, + pub summary: String, } // A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 552a561..81248f9 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -905,9 +905,36 @@ impl Engine { }; self.track_accounts(&evout); out.extend(evout); + // Give every user-removal a traceable incident id, stamped into its reason + // and recorded in the searchable log (OperServ LOGSEARCH). + self.stamp_incidents(&mut out); out } + // Mint an incident id for each kick/kill in `actions`, append `[#id]` to its + // reason, and record a searchable summary. One choke-point so every removal — + // from a bot kicker, a fantasy command, a vote, or an operator — is logged. + fn stamp_incidents(&mut self, actions: &mut [NetAction]) { + let now = self.now_secs(); + for a in actions.iter_mut() { + match a { + NetAction::Kick { channel, uid, reason, .. } => { + let target = self.network.nick_of(uid).unwrap_or(uid).to_string(); + let summary = format!("kicked \x02{target}\x02 from \x02{channel}\x02: {reason}"); + let id = self.network.record_incident(summary, now); + reason.push_str(&format!(" [#{id}]")); + } + NetAction::KillUser { uid, reason, .. } => { + let target = self.network.nick_of(uid).unwrap_or(uid).to_string(); + let summary = format!("killed \x02{target}\x02: {reason}"); + let id = self.network.record_incident(summary, now); + reason.push_str(&format!(" [#{id}]")); + } + _ => {} + } + } + } + // Remove kicker bans whose BANEXPIRE has elapsed, sourced from ChanServ. fn sweep_unbans(&mut self) -> Vec { if self.pending_unbans.is_empty() { @@ -4093,6 +4120,48 @@ mod tests { } } + // Every user-removal gets a traceable incident id stamped into its reason and + // recorded in the searchable log. + #[test] + fn removals_get_incident_ids() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-incident.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(OperServ { uid: "42SAAAAAH".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "troll".into(), host: "h".into(), ip: "0.0.0.0".into() }); + + // The kick reason carries a bracketed id, and the incident is searchable. + let out = os(&mut e, "000AAAAAS", "KICK #room troll flooding"); + let reason = out.iter().find_map(|a| match a { + NetAction::Kick { reason, .. } => Some(reason.clone()), + _ => None, + }).expect("a kick was issued"); + assert!(reason.contains("flooding") && reason.contains("[#"), "id stamped into reason: {reason}"); + // The id in the reason and the incident log agree, and search finds it. + let id = reason.split("[#").nth(1).and_then(|s| s.split(']').next()).unwrap().to_string(); + let hits = e.network.search_incidents(&id, 10); + assert_eq!(hits.len(), 1, "searchable by id"); + assert!(hits[0].summary.contains("troll"), "summary names the target: {}", hits[0].summary); + assert!(e.network.search_incidents("kicked", 10).iter().any(|i| i.summary.contains("troll")), "searchable by keyword"); + } + // OperServ CHANKILL: AKILL every host in a channel at once, deduped, never // banning the operator who ran it. #[test] diff --git a/src/engine/state.rs b/src/engine/state.rs index c731230..78c7c38 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -1,9 +1,12 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::time::{SystemTime, UNIX_EPOCH}; // The read-only network view a module sees; re-exported so the engine keeps // naming it locally. -pub use fedserv_api::{NetView, SeenView}; +pub use fedserv_api::{IncidentView, NetView, SeenView}; + +// The most recent moderation/action incidents kept for LOGSEARCH. +const INCIDENT_CAP: usize = 10_000; // Live network view, rebuilt from the uplink's burst each connect (ephemeral — // unlike the account store, which persists). @@ -22,6 +25,33 @@ pub struct Network { stats: BTreeMap, // Live session count per connecting IP, for OperServ session limiting. sessions: HashMap, + // Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH. + incidents: VecDeque, + incident_seq: u64, +} + +// One recorded action: a short id (also stamped into the action's reason), the +// unix time it happened, and a human summary. +struct Incident { + id: String, + ts: u64, + summary: String, +} + +// A short uppercase base-36 incident code from a monotonic counter. +fn incident_code(seq: u64) -> String { + const C: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + if seq == 0 { + return "0".to_string(); + } + let mut n = seq; + let mut out = Vec::new(); + while n > 0 { + out.push(C[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap() } pub struct User { @@ -78,6 +108,31 @@ impl Network { v } + // Record an action in the incident log and return its short id (to stamp into + // the action's reason). The ring is bounded, dropping the oldest. + pub fn record_incident(&mut self, summary: String, now: u64) -> String { + let id = incident_code(self.incident_seq); + self.incident_seq += 1; + self.incidents.push_back(Incident { id: id.clone(), ts: now, summary }); + while self.incidents.len() > INCIDENT_CAP { + self.incidents.pop_front(); + } + id + } + + // Incidents matching `pattern` (id-exact or summary-substring, case- + // insensitive; empty = all), newest first, capped at `limit`. + pub fn search_incidents(&self, pattern: &str, limit: usize) -> Vec { + let p = pattern.to_ascii_lowercase(); + self.incidents + .iter() + .rev() + .filter(|i| p.is_empty() || i.id.eq_ignore_ascii_case(&p) || i.summary.to_ascii_lowercase().contains(&p)) + .take(limit) + .map(|i| IncidentView { id: i.id.clone(), ts: i.ts, summary: i.summary.clone() }) + .collect() + } + // Resolve a nick to its uid (case-insensitive). Checks real users first, // then our own service bots. pub fn uid_by_nick(&self, nick: &str) -> Option<&str> { @@ -307,4 +362,7 @@ impl NetView for Network { fn sessions_over(&self, min: u32) -> Vec<(String, u32)> { Network::sessions_over(self, min) } + fn search_incidents(&self, pattern: &str, limit: usize) -> Vec { + Network::search_incidents(self, pattern, limit) + } }