Add ReportServ, wired into the audit trail

Any user can REPORT <nick|#channel> <reason>; filing is rate-limited per
reporter. Reports are an event-sourced, federated queue operators review
with LIST / VIEW / CLOSE / DEL. The interconnection: because a filed report
is an event, the engine's audit feed already announces it to the staff
channel and records it in the searchable action log — so OperServ
LOGSEARCH finds a report by any word in it, tying reports into the same
trail as bans, kicks, and everything else. Opt-in (add "reportserv").
This commit is contained in:
Jean Chevronnet 2026-07-14 04:02:51 +00:00
parent 8d495a948e
commit 5b023c22a4
No known key found for this signature in database
9 changed files with 346 additions and 9 deletions

View file

@ -30,7 +30,7 @@ use super::scram::{self, Hash};
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -188,6 +188,10 @@ pub enum Event {
// News items (OperServ NEWS). The stable `id` makes deletion order-independent.
NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 },
NewsDeleted { id: u64 },
// Abuse reports (ReportServ). Global so any node's operators see the queue.
ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 },
ReportClosed { id: u64 },
ReportDeleted { id: u64 },
// Runtime operator grants (OperServ OPER).
OperGranted {
account: String,
@ -260,6 +264,9 @@ impl Event {
| Event::AkillRemoved { .. }
| Event::NewsAdded { .. }
| Event::NewsDeleted { .. }
| Event::ReportFiled { .. }
| Event::ReportClosed { .. }
| Event::ReportDeleted { .. }
| Event::OperGranted { .. }
| Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. }
@ -362,10 +369,10 @@ pub struct Ignore {
pub expires: Option<u64>,
}
// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect,
// "oper" shown to operators on login), the text, who set it, and when. Carries a
// stable id (assigned at add time, embedded in the log) so deletion is order-
// independent under replay.
// A news item (InfoServ bulletin): a `kind` ("logon" shown to everyone on
// connect, "oper" shown to operators on login), the text, who set it, and when.
// Carries a stable id (assigned at add time, embedded in the log) so deletion is
// order-independent under replay.
#[derive(Debug, Clone)]
pub struct News {
pub id: u64,
@ -375,6 +382,18 @@ pub struct News {
pub ts: u64,
}
// An abuse report (ReportServ): who filed it, the nick/channel it's about, why,
// when, and whether it's still open. Stable id like News.
#[derive(Debug, Clone)]
pub struct Report {
pub id: u64,
pub reporter: String,
pub target: String,
pub reason: String,
pub ts: u64,
pub open: bool,
}
// The network-wide lists that aren't accounts/channels/grouped/bots: the network
// bans and the news items. Bundled so `apply` threads one parameter for them
// (and stays within its argument budget as more are added).
@ -383,6 +402,9 @@ pub struct NetData {
pub akills: Vec<Akill>,
pub news: Vec<News>,
pub news_seq: u64,
// Abuse reports (ReportServ), oldest first.
pub reports: Vec<Report>,
pub report_seq: u64,
// Runtime services operators (OperServ OPER), casefolded account -> grant.
// Merged with the declarative [[oper]] config at the engine.
pub opers: HashMap<String, OperGrant>,
@ -969,6 +991,8 @@ pub struct Db {
auth_fails: HashMap<String, AuthThrottle>,
// Last vhost REQUEST time per account (in-memory, anti-spam), unix secs.
vhost_req_times: HashMap<String, u64>,
// Last ReportServ REPORT time per reporter (in-memory, anti-spam), unix secs.
report_times: HashMap<String, u64>,
// Registered service bots (BotServ), keyed by casefolded nick.
bots: HashMap<String, Bot>,
// HostServ node config: the self-serve offer menu, the forbidden-pattern
@ -1052,7 +1076,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
}
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false }
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false }
}
/// Fold an entry authored by another node into the store — the services-side
@ -1149,6 +1173,12 @@ impl Db {
for n in &self.net.news {
snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts });
}
for r in &self.net.reports {
snapshot.push(Event::ReportFiled { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts });
if !r.open {
snapshot.push(Event::ReportClosed { id: r.id });
}
}
for (account, grant) in &self.net.opers {
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
}
@ -2151,6 +2181,61 @@ impl Db {
self.jupes.iter().map(|j| (j.name.clone(), j.sid.clone(), j.reason.clone())).collect()
}
/// File an abuse report, rate-limited per reporter. Returns the new report's
/// id, or None if the reporter filed one too recently.
pub fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option<u64> {
const COOLDOWN: u64 = 30;
let now = now();
let key = reporter.to_ascii_lowercase();
if self.report_times.get(&key).is_some_and(|&t| now.saturating_sub(t) < COOLDOWN) {
return None;
}
self.report_times.insert(key, now);
let id = self.net.report_seq;
let _ = self.log.append(Event::ReportFiled { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now });
self.net.report_seq = id + 1;
self.net.reports.push(Report { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now, open: true });
Some(id)
}
/// Close (resolve) a report. Returns whether an open one was closed.
pub fn report_close(&mut self, id: u64) -> bool {
let closed = self.net.reports.iter().any(|r| r.id == id && r.open);
if closed {
let _ = self.log.append(Event::ReportClosed { id });
if let Some(r) = self.net.reports.iter_mut().find(|r| r.id == id) {
r.open = false;
}
}
closed
}
/// Delete a report entirely. Returns whether one existed.
pub fn report_del(&mut self, id: u64) -> bool {
let existed = self.net.reports.iter().any(|r| r.id == id);
if existed {
let _ = self.log.append(Event::ReportDeleted { id });
self.net.reports.retain(|r| r.id != id);
}
existed
}
/// The reports, newest first. `open_only` hides closed ones.
pub fn reports(&self, open_only: bool) -> Vec<ReportView> {
self.net
.reports
.iter()
.rev()
.filter(|r| !open_only || r.open)
.map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open })
.collect()
}
/// A single report by id, if present.
pub fn report(&self, id: u64) -> Option<ReportView> {
self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open })
}
/// Add a services ignore, replacing any existing entry for the same mask.
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
@ -3129,6 +3214,20 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
Event::NewsDeleted { id } => {
net.news.retain(|n| n.id != id);
}
Event::ReportFiled { id, reporter, target, reason, ts } => {
net.report_seq = net.report_seq.max(id + 1);
if !net.reports.iter().any(|r| r.id == id) {
net.reports.push(Report { id, reporter, target, reason, ts, open: true });
}
}
Event::ReportClosed { id } => {
if let Some(r) = net.reports.iter_mut().find(|r| r.id == id) {
r.open = false;
}
}
Event::ReportDeleted { id } => {
net.reports.retain(|r| r.id != id);
}
Event::OperGranted { account, privs, expires } => {
net.opers.insert(key(&account), OperGrant { privs, expires });
}
@ -3437,6 +3536,21 @@ impl Store for Db {
fn news(&self, kind: &str) -> Vec<NewsView> {
Db::news(self, kind)
}
fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option<u64> {
Db::report_file(self, reporter, target, reason)
}
fn report_close(&mut self, id: u64) -> bool {
Db::report_close(self, id)
}
fn report_del(&mut self, id: u64) -> bool {
Db::report_del(self, id)
}
fn reports(&self, open_only: bool) -> Vec<ReportView> {
Db::reports(self, open_only)
}
fn report(&self, id: u64) -> Option<ReportView> {
Db::report(self, id)
}
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>) {
Db::oper_grant(self, account, privs, expires)
}

View file

@ -1694,8 +1694,11 @@ fn audit_summary(event: &db::Event) -> Option<String> {
Some(_) => format!("set a staff note on \x02{channel}\x02"),
None => format!("cleared the staff note on \x02{channel}\x02"),
},
NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"),
NewsDeleted { .. } => "removed a news item".to_string(),
NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 bulletin"),
NewsDeleted { .. } => "removed a bulletin".to_string(),
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
ReportClosed { id } => format!("closed report #{id}"),
ReportDeleted { id } => format!("deleted report #{id}"),
OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")),
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"),
@ -4565,6 +4568,53 @@ mod tests {
assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing");
}
// ReportServ: any user files an abuse report; it lands in the queue AND flows
// through the audit trail so OperServ LOGSEARCH finds it. Reviewing is oper-only.
#[test]
fn reportserv_files_and_is_searchable() {
use fedserv_operserv::OperServ;
use fedserv_reportserv::ReportServ;
let path = std::env::temp_dir().join("fedserv-reportserv.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() }),
Box::new(ReportServ { uid: "42SAAAAAK".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));
e.set_opers(opers);
let rs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAK".into(), text: t.into() });
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], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
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: "000AAAAAU".into(), nick: "victim".into(), host: "h".into(), ip: "0.0.0.0".into() });
// An ordinary (unidentified) user files a report.
assert!(has(&rs(&mut e, "000AAAAAU", "REPORT spammer flooding the lobby"), "has been sent"), "report accepted");
// The report is in the queue and — via the audit trail — searchable.
assert!(has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "oper sees the queue");
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH flooding"), "flooding"), "report searchable via LOGSEARCH");
// Detail, then close.
assert!(has(&rs(&mut e, "000AAAAAS", "VIEW 0"), "flooding the lobby"), "view detail");
assert!(has(&rs(&mut e, "000AAAAAS", "CLOSE 0"), "closed"), "close");
assert!(!has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "closed reports drop off the open list");
// Non-opers can't review; and filing is rate-limited.
assert!(has(&rs(&mut e, "000AAAAAU", "LIST"), "Access denied"), "review is oper-only");
assert!(has(&rs(&mut e, "000AAAAAU", "REPORT someone again"), "wait a moment"), "rate-limited");
}
// InfoServ bulletins over the shared news store: public bulletins greet
// everyone on connect, oper bulletins greet an operator on login.
#[test]