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

8
Cargo.lock generated
View file

@ -336,6 +336,7 @@ dependencies = [
"fedserv-memoserv", "fedserv-memoserv",
"fedserv-nickserv", "fedserv-nickserv",
"fedserv-operserv", "fedserv-operserv",
"fedserv-reportserv",
"fedserv-statserv", "fedserv-statserv",
"hmac", "hmac",
"pbkdf2", "pbkdf2",
@ -431,6 +432,13 @@ dependencies = [
"fedserv-api", "fedserv-api",
] ]
[[package]]
name = "fedserv-reportserv"
version = "0.0.1"
dependencies = [
"fedserv-api",
]
[[package]] [[package]]
name = "fedserv-statserv" name = "fedserv-statserv"
version = "0.0.1" version = "0.0.1"

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv"] members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv"]
[package] [package]
name = "fedserv" name = "fedserv"
@ -20,6 +20,7 @@ fedserv-hostserv = { path = "hostserv" }
fedserv-operserv = { path = "operserv" } fedserv-operserv = { path = "operserv" }
fedserv-diceserv = { path = "diceserv" } fedserv-diceserv = { path = "diceserv" }
fedserv-infoserv = { path = "infoserv" } fedserv-infoserv = { path = "infoserv" }
fedserv-reportserv = { path = "reportserv" }
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -510,6 +510,17 @@ pub struct NewsView {
pub ts: u64, pub ts: u64,
} }
// An abuse report held by ReportServ.
#[derive(Debug, Clone)]
pub struct ReportView {
pub id: u64,
pub reporter: String,
pub target: String,
pub reason: String,
pub ts: u64,
pub open: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BotView { pub struct BotView {
pub nick: String, pub nick: String,
@ -788,6 +799,12 @@ pub trait Store {
fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64; fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64;
fn news_del(&mut self, id: u64) -> bool; fn news_del(&mut self, id: u64) -> bool;
fn news(&self, kind: &str) -> Vec<NewsView>; fn news(&self, kind: &str) -> Vec<NewsView>;
// Abuse reports (ReportServ). `report_file` is rate-limited (None = too soon).
fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option<u64>;
fn report_close(&mut self, id: u64) -> bool;
fn report_del(&mut self, id: u64) -> bool;
fn reports(&self, open_only: bool) -> Vec<ReportView>;
fn report(&self, id: u64) -> Option<ReportView>;
// Runtime operator grants (OperServ OPER), merged with config opers. `expires` // Runtime operator grants (OperServ OPER), merged with config opers. `expires`
// is an absolute unix time (None = permanent); opers_list hides expired ones. // is an absolute unix time (None = permanent); opers_list hides expired ones.
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>); fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>);

8
reportserv/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-reportserv"
version = "0.0.1"
edition = "2021"
description = "ReportServ: users file abuse reports; operators review them."
[dependencies]
fedserv-api = { path = "../api" }

130
reportserv/src/lib.rs Normal file
View file

@ -0,0 +1,130 @@
//! ReportServ lets any user file an abuse report against a nick or channel.
//! Filing is rate-limited; the report is stored and — because it's an event —
//! the engine's audit feed automatically announces it to the staff channel and
//! records it in the searchable action log (OperServ LOGSEARCH), so a report is
//! tied into the same trail as everything else. Operators review the queue with
//! LIST / VIEW / CLOSE / DEL.
use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store};
pub struct ReportServ {
pub uid: String,
}
impl Service for ReportServ {
fn nick(&self) -> &str {
"ReportServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Report Service"
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("REPORT") => report(me, from, &args[1..], ctx, db),
Some("LIST") => list(me, from, args.get(1).copied(), ctx, db),
Some("VIEW") | Some("READ") => view(me, from, args.get(1).copied(), ctx, db),
Some("CLOSE") | Some("RESOLVE") => close(me, from, args.get(1).copied(), ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, args.get(1).copied(), ctx, db),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REPORT\x02 <nick|#channel> <reason> or \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "ReportServ takes abuse reports. \x02REPORT\x02 <nick|#channel> <reason> tells the staff about a problem. Operators review with \x02LIST\x02 [ALL], \x02VIEW\x02 <id>, \x02CLOSE\x02 <id>, \x02DEL\x02 <id>.");
}
fn report(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some((&target, reason_words)) = rest.split_first() else {
ctx.notice(me, from.uid, "Syntax: REPORT <nick|#channel> <reason>");
return;
};
if reason_words.is_empty() {
ctx.notice(me, from.uid, "Please say what the problem is: REPORT <nick|#channel> <reason>");
return;
}
let reason = reason_words.join(" ");
let reporter = from.account.unwrap_or(from.nick);
match db.report_file(reporter, target, &reason) {
Some(id) => ctx.notice(me, from.uid, format!("Thanks — your report (\x02#{id}\x02) about \x02{target}\x02 has been sent to the staff.")),
None => ctx.notice(me, from.uid, "You just filed a report — please wait a moment before filing another."),
}
}
// Reviewing the queue is for operators only.
fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool {
if from.privs.any() {
return true;
}
ctx.notice(me, from.uid, "Access denied — reviewing reports is for services operators.");
false
}
fn list(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let open_only = !arg.is_some_and(|a| a.eq_ignore_ascii_case("ALL"));
let reports = db.reports(open_only);
if reports.is_empty() {
ctx.notice(me, from.uid, if open_only { "No open reports." } else { "No reports." });
return;
}
for r in &reports {
let flag = if r.open { "" } else { " (closed)" };
let short: String = r.reason.chars().take(60).collect();
ctx.notice(me, from.uid, format!("\x02#{}\x02 {}\x02{}\x02: {}{}", r.id, r.reporter, r.target, short, flag));
}
ctx.notice(me, from.uid, format!("{} report(s). \x02VIEW\x02 <id> for detail.", reports.len()));
}
fn view(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(r) = id.and_then(|n| n.parse::<u64>().ok()).and_then(|n| db.report(n)) else {
ctx.notice(me, from.uid, "No such report. Syntax: VIEW <id>");
return;
};
ctx.notice(me, from.uid, format!("Report \x02#{}\x02 ({}):", r.id, if r.open { "open" } else { "closed" }));
ctx.notice(me, from.uid, format!(" By : \x02{}\x02", r.reporter));
ctx.notice(me, from.uid, format!(" About : \x02{}\x02", r.target));
ctx.notice(me, from.uid, format!(" Filed : {}", human_time(r.ts)));
ctx.notice(me, from.uid, format!(" Reason : {}", r.reason));
}
fn close(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(n) = id.and_then(|n| n.parse::<u64>().ok()) else {
ctx.notice(me, from.uid, "Syntax: CLOSE <id>");
return;
};
if db.report_close(n) {
ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 closed."));
} else {
ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 isn't open (or doesn't exist)."));
}
}
fn del(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(n) = id.and_then(|n| n.parse::<u64>().ok()) else {
ctx.notice(me, from.uid, "Syntax: DEL <id>");
return;
};
if db.report_del(n) {
ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 deleted."));
} else {
ctx.notice(me, from.uid, format!("There's no report \x02#{n}\x02."));
}
}

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 // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected. // modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{ 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -188,6 +188,10 @@ pub enum Event {
// News items (OperServ NEWS). The stable `id` makes deletion order-independent. // News items (OperServ NEWS). The stable `id` makes deletion order-independent.
NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 }, NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 },
NewsDeleted { id: 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). // Runtime operator grants (OperServ OPER).
OperGranted { OperGranted {
account: String, account: String,
@ -260,6 +264,9 @@ impl Event {
| Event::AkillRemoved { .. } | Event::AkillRemoved { .. }
| Event::NewsAdded { .. } | Event::NewsAdded { .. }
| Event::NewsDeleted { .. } | Event::NewsDeleted { .. }
| Event::ReportFiled { .. }
| Event::ReportClosed { .. }
| Event::ReportDeleted { .. }
| Event::OperGranted { .. } | Event::OperGranted { .. }
| Event::OperRevoked { .. } | Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. } | Event::SessionExceptionAdded { .. }
@ -362,10 +369,10 @@ pub struct Ignore {
pub expires: Option<u64>, pub expires: Option<u64>,
} }
// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect, // A news item (InfoServ bulletin): a `kind` ("logon" shown to everyone on
// "oper" shown to operators on login), the text, who set it, and when. Carries a // connect, "oper" shown to operators on login), the text, who set it, and when.
// stable id (assigned at add time, embedded in the log) so deletion is order- // Carries a stable id (assigned at add time, embedded in the log) so deletion is
// independent under replay. // order-independent under replay.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct News { pub struct News {
pub id: u64, pub id: u64,
@ -375,6 +382,18 @@ pub struct News {
pub ts: u64, 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 // 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 // bans and the news items. Bundled so `apply` threads one parameter for them
// (and stays within its argument budget as more are added). // (and stays within its argument budget as more are added).
@ -383,6 +402,9 @@ pub struct NetData {
pub akills: Vec<Akill>, pub akills: Vec<Akill>,
pub news: Vec<News>, pub news: Vec<News>,
pub news_seq: u64, 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. // Runtime services operators (OperServ OPER), casefolded account -> grant.
// Merged with the declarative [[oper]] config at the engine. // Merged with the declarative [[oper]] config at the engine.
pub opers: HashMap<String, OperGrant>, pub opers: HashMap<String, OperGrant>,
@ -969,6 +991,8 @@ pub struct Db {
auth_fails: HashMap<String, AuthThrottle>, auth_fails: HashMap<String, AuthThrottle>,
// Last vhost REQUEST time per account (in-memory, anti-spam), unix secs. // Last vhost REQUEST time per account (in-memory, anti-spam), unix secs.
vhost_req_times: HashMap<String, u64>, 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. // Registered service bots (BotServ), keyed by casefolded nick.
bots: HashMap<String, Bot>, bots: HashMap<String, Bot>,
// HostServ node config: the self-serve offer menu, the forbidden-pattern // 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); 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"); 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 /// 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 { 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 }); 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 { for (account, grant) in &self.net.opers {
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires }); 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() 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. /// 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>) { pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); 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 } => { Event::NewsDeleted { id } => {
net.news.retain(|n| n.id != 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 } => { Event::OperGranted { account, privs, expires } => {
net.opers.insert(key(&account), OperGrant { 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> { fn news(&self, kind: &str) -> Vec<NewsView> {
Db::news(self, kind) 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>) { fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>) {
Db::oper_grant(self, account, privs, expires) 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"), Some(_) => format!("set a staff note on \x02{channel}\x02"),
None => format!("cleared the 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"), NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 bulletin"),
NewsDeleted { .. } => "removed a news item".to_string(), 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(", ")), OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")),
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"), OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"), 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"); 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 // InfoServ bulletins over the shared news store: public bulletins greet
// everyone on connect, oper bulletins greet an operator on login. // everyone on connect, oper bulletins greet an operator on login.
#[test] #[test]

View file

@ -173,6 +173,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ChannelOperNoteSet { .. } | Event::ChannelOperNoteSet { .. }
| Event::NewsAdded { .. } | Event::NewsAdded { .. }
| Event::NewsDeleted { .. } | Event::NewsDeleted { .. }
| Event::ReportFiled { .. }
| Event::ReportClosed { .. }
| Event::ReportDeleted { .. }
| Event::OperGranted { .. } | Event::OperGranted { .. }
| Event::OperRevoked { .. } | Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. } | Event::SessionExceptionAdded { .. }

View file

@ -23,6 +23,7 @@ use fedserv_hostserv::HostServ;
use fedserv_operserv::OperServ; use fedserv_operserv::OperServ;
use fedserv_diceserv::DiceServ; use fedserv_diceserv::DiceServ;
use fedserv_infoserv::InfoServ; use fedserv_infoserv::InfoServ;
use fedserv_reportserv::ReportServ;
use fedserv_example::ExampleServ; use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd; use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ; use fedserv_nickserv::NickServ;
@ -101,6 +102,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAJ", cfg.server.sid), uid: format!("{}AAAAAJ", cfg.server.sid),
})); }));
} }
if enabled("reportserv") {
services.push(Box::new(ReportServ {
uid: format!("{}AAAAAK", cfg.server.sid),
}));
}
if enabled("example") { if enabled("example") {
services.push(Box::new(ExampleServ { services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid), uid: format!("{}AAAAAC", cfg.server.sid),