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]