diff --git a/config.example.toml b/config.example.toml index 21de859..346df0e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -47,3 +47,11 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # [[oper]] # account = "yournick" # privs = ["auspex", "suspend", "admin"] + +# Staff audit feed: notable service actions (registrations, drops, vhosts, +# suspensions, akicks, access and bot changes, oper host config) are announced +# to this channel so operators can see who did what. Private material (memo +# bodies, password/verifier data) and cosmetic self-service tweaks are never +# surfaced. Omit the section to disable the feed. +# [log] +# channel = "#services" diff --git a/src/config.rs b/src/config.rs index 0d1eb4c..9f6d785 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,16 @@ pub struct Config { // Services operators: accounts granted privileges. Absent = no opers. #[serde(default)] pub oper: Vec, + // Staff audit feed. Absent = no audit log is emitted. + #[serde(default)] + pub log: Option, +} + +// The staff audit feed: notable service actions are announced to this channel +// so operators can see who did what. +#[derive(Debug, Deserialize, Clone)] +pub struct Log { + pub channel: String, } // One services operator: an account and the privileges it holds. diff --git a/src/engine/db.rs b/src/engine/db.rs index 371b83a..5674690 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -683,6 +683,13 @@ impl EventLog { self.outbound = Some(tx); } + // The events appended at or after `mark`, cloned. Used by the audit feed to + // see exactly what a just-run command changed (the log only ever grows during + // a command, so `[mark..]` is that command's footprint). + fn events_from(&self, mark: usize) -> Vec { + self.entries.get(mark..).into_iter().flatten().map(|e| e.event.clone()).collect() + } + // Our version vector: highest seq applied per origin. fn version_vector(&self) -> HashMap { self.versions.clone() @@ -951,6 +958,16 @@ impl Db { self.log.set_outbound(tx); } + /// The number of entries in the log, a mark to later diff against. + pub fn log_len(&self) -> usize { + self.log.len() + } + + /// The events appended since `mark` (a value from an earlier [`log_len`]). + pub fn events_since(&self, mark: usize) -> Vec { + self.log.events_from(mark) + } + /// Our version vector, advertised to peers so they can send what we lack. pub fn version_vector(&self) -> HashMap { self.log.version_vector() diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 7928936..4de56de 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -121,6 +121,9 @@ pub struct Engine { // In-flight community !votekick/!voteban tallies, keyed by (channel_lc, // target_nick_lc). Ephemeral; expire after VOTE_TTL. votes: HashMap<(String, String), VoteState>, + // Staff audit channel: notable service actions are announced here. None = + // no audit feed. + log_channel: Option, } struct VoteState { @@ -192,6 +195,7 @@ impl Engine { trigger_cache: HashMap::new(), pending_unbans: Vec::new(), votes: HashMap::new(), + log_channel: None, } } @@ -245,6 +249,11 @@ impl Engine { self.sid = sid; } + // The staff channel notable service actions are announced to. + pub fn set_log_channel(&mut self, channel: Option) { + self.log_channel = channel; + } + // Bring the network's bot pseudo-clients in line with the registry: introduce // any new bots, quit any that were deleted. Run at burst and after commands. fn reconcile_bots(&mut self) -> Vec { @@ -1002,6 +1011,8 @@ impl Engine { let account = self.network.account_of(from).map(str::to_string); let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default(); let mut ctx = ServiceCtx::default(); + // Mark the log so we can tell exactly which events this command appends. + let audit_mark = self.db.log_len(); let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs }; if to.starts_with('#') || to.starts_with('&') { // A community !votekick/!voteban is handled on its own; otherwise the @@ -1049,9 +1060,38 @@ impl Engine { // A command may have changed the bot registry (BotServ BOT ADD/DEL). let mut out = ctx.actions; out.extend(self.reconcile_bots()); + // Announce whatever this command changed to the staff audit channel. + out.extend(self.audit_feed(audit_mark, &nick, account.as_deref())); 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() { + return Vec::new(); + } + // Name the actor by nick, plus the account they are identified to when it + // differs — the account is the identity that outlives the nick. + let actor = match account { + 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() + } + // The greet a channel's bot shows when a member logged into `account` joins: // only when greets are enabled for the channel, the member holds channel // access, and has set a non-empty personal greet. @@ -1361,6 +1401,63 @@ impl Engine { } } +// A one-line, human-readable summary of a logged event for the staff audit +// feed, or None for events that are private (memos), cosmetic self-service +// (greets, auto-join, personal settings), or otherwise not worth surfacing. +// Never include secrets: password/verifier material and memo bodies are elided. +fn audit_summary(event: &db::Event) -> Option { + use db::Event::*; + let s = match event { + AccountRegistered(a) => format!("registered account \x02{}\x02", a.name), + AccountDropped { account } => format!("dropped account \x02{account}\x02"), + AccountVerified { account } => format!("verified account \x02{account}\x02"), + AccountPasswordSet { account, .. } => format!("changed the password on \x02{account}\x02"), + AccountEmailSet { account, email } => match email { + Some(_) => format!("set the email on \x02{account}\x02"), + None => format!("cleared the email on \x02{account}\x02"), + }, + CertAdded { account, fp } => format!("added cert \x02{fp}\x02 to \x02{account}\x02"), + CertRemoved { account, fp } => format!("removed cert \x02{fp}\x02 from \x02{account}\x02"), + AccountSuspended { account, reason, .. } => format!("suspended account \x02{account}\x02 ({reason})"), + AccountUnsuspended { account } => format!("unsuspended account \x02{account}\x02"), + NickGrouped { nick, account } => format!("grouped nick \x02{nick}\x02 to \x02{account}\x02"), + NickUngrouped { nick } => format!("ungrouped nick \x02{nick}\x02"), + VhostSet { account, host, expires, .. } => { + let kind = if expires.is_some() { " (temporary)" } else { "" }; + format!("set vhost \x02{host}\x02 on \x02{account}\x02{kind}") + } + VhostDeleted { account } => format!("removed the vhost on \x02{account}\x02"), + ChannelRegistered { name, founder, .. } => format!("registered channel \x02{name}\x02 (founder \x02{founder}\x02)"), + ChannelDropped { name } => format!("dropped channel \x02{name}\x02"), + ChannelFounderSet { channel, founder } => format!("set founder of \x02{channel}\x02 to \x02{founder}\x02"), + ChannelAccessAdd { channel, account, level } => format!("set \x02{account}\x02 to {level} on \x02{channel}\x02"), + ChannelAccessDel { channel, account } => format!("removed \x02{account}\x02 from \x02{channel}\x02 access"), + ChannelAkickAdd { channel, mask, reason } => format!("added akick \x02{mask}\x02 on \x02{channel}\x02 ({reason})"), + ChannelAkickDel { channel, mask } => format!("removed akick \x02{mask}\x02 on \x02{channel}\x02"), + ChannelSuspended { channel, reason, .. } => format!("suspended channel \x02{channel}\x02 ({reason})"), + ChannelUnsuspended { channel } => format!("unsuspended channel \x02{channel}\x02"), + ChannelBotAssigned { channel, bot } => format!("assigned bot \x02{bot}\x02 to \x02{channel}\x02"), + ChannelBotUnassigned { channel } => format!("removed the bot from \x02{channel}\x02"), + BotAdded(b) => format!("added bot \x02{}\x02", b.nick), + BotRemoved { nick } => format!("removed bot \x02{nick}\x02"), + VhostOfferAdded { host } => format!("added vhost offer \x02{host}\x02"), + VhostOfferRemoved { host } => format!("removed vhost offer \x02{host}\x02"), + VhostForbidAdded { pattern } => format!("forbade vhost pattern \x02{pattern}\x02"), + VhostForbidRemoved { pattern } => format!("un-forbade vhost pattern \x02{pattern}\x02"), + VhostTemplateSet { template } => match template { + Some(t) => format!("set the vhost template to \x02{t}\x02"), + None => "cleared the vhost template".to_string(), + }, + // Private, self-service, or cosmetic — not surfaced. + AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | VhostRequested { .. } + | VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. } + | ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelSettingsSet { .. } + | ChannelKickerSet { .. } | ChannelBadwordsSet { .. } | ChannelTriggersSet { .. } + | ChannelTopicSet { .. } => return None, + }; + Some(s) +} + // Rewrite the source uid of an action from `old` to `new`, where the variant // carries a `from`. Used to make fantasy output appear to come from the bot. fn resource_action(a: &mut NetAction, old: &str, new: &str) { @@ -3456,6 +3553,57 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "-r")), "drop clears +r: {out:?}"); } + // Notable service actions are announced to the staff audit channel, sourced + // from the services server; private/cosmetic events are not, and no channel + // means no feed. + #[test] + fn audit_feed_announces_notable_actions() { + use fedserv_chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-audit.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + e.set_sid("42S".into()); + e.set_log_channel(Some("#services".into())); + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + // The audit line is a Notice to the log channel, from the services SID. + let audit = |out: &[NetAction], needle: &str| { + out.iter().any(|a| matches!(a, NetAction::Notice { from, to, text } + if from == "42S" && to == "#services" && text.contains(needle))) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true }); + + // Registering a channel is announced with the actor and the founder. + let out = to_cs(&mut e, "000AAAAAB", "REGISTER #room"); + assert!(audit(&out, "registered channel \x02#room\x02"), "reg announced: {out:?}"); + assert!(audit(&out, "alice"), "names the actor"); + + // A cosmetic mode lock is not surfaced (only its user-facing reply is). + let out = to_cs(&mut e, "000AAAAAB", "MLOCK #room +nt"); + assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "#services")), "mlock not audited: {out:?}"); + + // Dropping the channel is announced. + assert!(audit(&to_cs(&mut e, "000AAAAAB", "DROP #room"), "dropped channel \x02#room\x02"), "drop announced"); + + // With no log channel configured, nothing is emitted to it. + e.set_log_channel(None); + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#other".into(), op: true }); + let out = to_cs(&mut e, "000AAAAAB", "REGISTER #other"); + assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "#services")), "no feed when unset: {out:?}"); + } + // ChanServ moderation: an op can op/kick/ban users; a non-op is refused. #[test] fn chanserv_moderation() { diff --git a/src/main.rs b/src/main.rs index c8641f8..4ffc4e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,6 +105,7 @@ async fn main() -> Result<()> { engine.lock().await.set_irc_out(irc_tx); engine.lock().await.set_opers(cfg.opers()); engine.lock().await.set_sid(cfg.server.sid.clone()); + engine.lock().await.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); if let Some(gossip) = cfg.gossip.clone() { tracing::info!(peers = cfg.peer.len(), "starting gossip");