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
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
8
reportserv/Cargo.toml
Normal 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
130
reportserv/src/lib.rs
Normal 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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 { .. }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue