Add a staff audit feed for notable service actions

A [log] channel now receives a one-line announcement of each notable
state change a service command makes — registrations, drops, vhosts,
suspensions, akicks, access and founder changes, bot and oper host-config
changes — naming the actor (by nick, plus account when it differs) and
what changed. The feed reads the typed event log directly: dispatch marks
the log before running a command and diffs it after, so each announcement
is exactly that command's footprint, sourced from the services server so
it reaches the channel without a pseudo-client having to be present.

Private and cosmetic events are deliberately never surfaced: memo bodies,
password/verifier material, greets, auto-join, personal and channel
cosmetic settings. Omitting [log] disables the feed.
This commit is contained in:
Jean Chevronnet 2026-07-13 23:48:23 +00:00
parent 34892dbba7
commit 698d604954
No known key found for this signature in database
5 changed files with 184 additions and 0 deletions

View file

@ -26,6 +26,16 @@ pub struct Config {
// Services operators: accounts granted privileges. Absent = no opers.
#[serde(default)]
pub oper: Vec<Oper>,
// Staff audit feed. Absent = no audit log is emitted.
#[serde(default)]
pub log: Option<Log>,
}
// 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.

View file

@ -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<Event> {
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<String, u64> {
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<Event> {
self.log.events_from(mark)
}
/// Our version vector, advertised to peers so they can send what we lack.
pub fn version_vector(&self) -> HashMap<String, u64> {
self.log.version_vector()

View file

@ -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<String>,
}
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<String>) {
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<NetAction> {
@ -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<NetAction> {
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<String> {
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() {

View file

@ -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");