From a7aacf347e7497c4630c8464065ad080aaffd738 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 02:16:59 +0000 Subject: [PATCH] =?UTF-8?q?OperServ:=20LOGSEARCH=20=E2=80=94=20search=20th?= =?UTF-8?q?e=20action=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOGSEARCH [pattern] searches the incident log newest-first: match a bare id (as stamped into a kick reason), or any word from the summary; no argument lists the most recent. Auspex-gated (read-only investigation). To make it comprehensive, the command audit trail now records every notable action to the same incident log (not just to the audit channel, and regardless of whether one is configured) — so registrations, drops, akills, suspensions, vhosts, notes, and the like are all findable alongside the kicks and kills stamped at the removal choke-point. --- operserv/src/lib.rs | 5 ++- operserv/src/logsearch.rs | 23 ++++++++++ src/engine/mod.rs | 95 +++++++++++++++++++++++++++++---------- 3 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 operserv/src/logsearch.rs diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs index 8d2a890..878ba74 100644 --- a/operserv/src/lib.rs +++ b/operserv/src/lib.rs @@ -33,6 +33,8 @@ mod session; mod jupe; #[path = "chankill.rs"] mod chankill; +#[path = "logsearch.rs"] +mod logsearch; pub struct OperServ { pub uid: String, @@ -76,6 +78,7 @@ impl Service for OperServ { 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("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.")), @@ -84,5 +87,5 @@ impl Service for OperServ { } 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), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST."); + ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log)."); } diff --git a/operserv/src/logsearch.rs b/operserv/src/logsearch.rs new file mode 100644 index 0000000..2386bf7 --- /dev/null +++ b/operserv/src/logsearch.rs @@ -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())); +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index d28e1bf..714807e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -631,13 +631,13 @@ impl Engine { .collect() } - // Announce a line to the staff audit channel, if one is configured, sourced - // from the services server. Used for actions that aren't tied to a command. - fn audit(&self, text: String) { - if let Some(channel) = &self.log_channel { - if !self.sid.is_empty() { - self.emit_irc(NetAction::Notice { from: self.sid.clone(), to: channel.clone(), text }); - } + // Record a line to the searchable incident log and, if an audit channel is + // configured, announce it there. For actions not tied to a command (expiry). + fn audit(&mut self, text: String) { + let now = self.now_secs(); + self.network.record_incident(text.clone(), now); + if let (Some(channel), false) = (&self.log_channel, self.sid.is_empty()) { + self.emit_irc(NetAction::Notice { from: self.sid.clone(), to: channel.clone(), text }); } } @@ -1251,13 +1251,13 @@ impl Engine { out } - // Announce the state changes a just-run command made (the events appended - // since `mark`) to the staff audit channel, sourced from the services server - // so it reaches the channel without a pseudo-client having to be present. - // Private and purely cosmetic events are deliberately not surfaced. - fn audit_feed(&self, mark: usize, nick: &str, account: Option<&str>) -> Vec { - let Some(channel) = self.log_channel.clone() else { return Vec::new() }; - if self.sid.is_empty() { + // Record the state changes a just-run command made (the events appended since + // `mark`) to the searchable incident log, and — when an audit channel is + // configured — announce each to it, sourced from the services server. Private + // and purely cosmetic events are deliberately not surfaced. + fn audit_feed(&mut self, mark: usize, nick: &str, account: Option<&str>) -> Vec { + let summaries: Vec = self.db.events_since(mark).iter().filter_map(audit_summary).collect(); + if summaries.is_empty() { return Vec::new(); } // Name the actor by nick, plus the account they are identified to when it @@ -1266,16 +1266,16 @@ impl Engine { Some(a) if !a.eq_ignore_ascii_case(nick) => format!("\x02{nick}\x02 ({a})"), _ => format!("\x02{nick}\x02"), }; - self.db - .events_since(mark) - .iter() - .filter_map(audit_summary) - .map(|summary| NetAction::Notice { - from: self.sid.clone(), - to: channel.clone(), - text: format!("{actor} {summary}"), - }) - .collect() + let now = self.now_secs(); + let mut out = Vec::new(); + for summary in summaries { + let line = format!("{actor} {summary}"); + self.network.record_incident(line.clone(), now); + if let (Some(channel), false) = (&self.log_channel, self.sid.is_empty()) { + out.push(NetAction::Notice { from: self.sid.clone(), to: channel.clone(), text: line }); + } + } + out } // The greet a channel's bot shows when a member logged into `account` joins: @@ -4120,6 +4120,53 @@ mod tests { } } + // LOGSEARCH finds recorded actions — both a kick (stamped) and an akill (from + // the command audit trail); gated on auspex. + #[test] + fn operserv_logsearch_finds_actions() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-logsearch.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("mod", "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).with(fedserv_api::Priv::Auspex)); + opers.insert("mod".to_string(), Privs::default().with(fedserv_api::Priv::Suspend)); + e.set_opers(opers); + let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + let has = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); + + 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: "000AAAAAM".into(), nick: "mod".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAM".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() }); + + os(&mut e, "000AAAAAS", "AKILL ADD *@evil.host spamming"); + os(&mut e, "000AAAAAS", "KICK #room troll flooding"); + + // The akill (from the command audit trail) is searchable. + assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH evil.host"), "evil.host"), "akill searchable"); + // The kick (stamped at the choke-point) is searchable. + assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH troll"), "troll"), "kick searchable"); + // A bare LOGSEARCH lists recent entries. + assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH"), "End of results"), "lists recent"); + // A nonsense pattern finds nothing. + assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH zzzznope"), "No matching"), "empty result"); + // An oper without auspex is refused. + assert!(has(&os(&mut e, "000AAAAAM", "LOGSEARCH troll"), "auspex"), "auspex-gated"); + } + // Every user-removal gets a traceable incident id stamped into its reason and // recorded in the searchable log. #[test]