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:
parent
8d495a948e
commit
5b023c22a4
9 changed files with 346 additions and 9 deletions
126
src/engine/db.rs
126
src/engine/db.rs
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue