operserv: SPAMFILTER — persisted content filters pushed to the ircd m_filter via metadata, re-asserted at burst
All checks were successful
CI / check (push) Successful in 3m56s
All checks were successful
CI / check (push) Successful in 3m56s
This commit is contained in:
parent
9908548978
commit
0c3e09ea00
9 changed files with 267 additions and 0 deletions
|
|
@ -581,6 +581,13 @@ impl ServiceCtx {
|
||||||
self.actions.push(NetAction::DelLine { kind: kind.wire().to_string(), mask: mask.to_string() });
|
self.actions.push(NetAction::DelLine { kind: kind.wire().to_string(), mask: mask.to_string() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push a spam filter to the ircd's m_filter via a broadcast `METADATA * filter`.
|
||||||
|
// `value` is the encoded filter (see `encode_filter`). m_filter accepts adds
|
||||||
|
// this way; there is no matching metadata delete.
|
||||||
|
pub fn filter_push(&mut self, value: String) {
|
||||||
|
self.actions.push(NetAction::Metadata { target: "*".to_string(), key: "filter".to_string(), value });
|
||||||
|
}
|
||||||
|
|
||||||
// Disconnect a user, sourced from pseudoclient `from`.
|
// Disconnect a user, sourced from pseudoclient `from`.
|
||||||
pub fn kill(&mut self, from: &str, uid: &str, reason: &str) {
|
pub fn kill(&mut self, from: &str, uid: &str, reason: &str) {
|
||||||
self.actions.push(NetAction::KillUser { from: from.to_string(), uid: uid.to_string(), reason: reason.to_string() });
|
self.actions.push(NetAction::KillUser { from: from.to_string(), uid: uid.to_string(), reason: reason.to_string() });
|
||||||
|
|
@ -1208,6 +1215,36 @@ pub struct AkillView {
|
||||||
pub expires: Option<u64>,
|
pub expires: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A spam filter OperServ manages and pushes to the ircd's m_filter (SPAMFILTER).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FilterView {
|
||||||
|
pub pattern: String,
|
||||||
|
pub action: String,
|
||||||
|
pub flags: String,
|
||||||
|
pub reason: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub ts: u64,
|
||||||
|
pub expires: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter actions InspIRCd's m_filter accepts (what happens on a match).
|
||||||
|
pub const FILTER_ACTIONS: &[&str] = &["gline", "zline", "block", "silent", "kill", "shun", "warn", "none"];
|
||||||
|
|
||||||
|
// Whether `action` is a filter action m_filter understands (case-insensitive).
|
||||||
|
pub fn is_filter_action(action: &str) -> bool {
|
||||||
|
FILTER_ACTIONS.iter().any(|a| a.eq_ignore_ascii_case(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a filter for InspIRCd m_filter's `METADATA * filter` value:
|
||||||
|
/// `<freeform> <action> <flags> <duration> :<reason>`, spaces in the pattern
|
||||||
|
/// escaped to `\x07` exactly as m_filter's own EncodeFilter does. `flags` empty
|
||||||
|
/// becomes `-`; `duration` is seconds remaining (0 = permanent).
|
||||||
|
pub fn encode_filter(pattern: &str, action: &str, flags: &str, duration: u64, reason: &str) -> String {
|
||||||
|
let freeform: String = pattern.chars().map(|c| if c == ' ' { '\x07' } else { c }).collect();
|
||||||
|
let flags = if flags.is_empty() { "-" } else { flags };
|
||||||
|
format!("{freeform} {} {flags} {duration} :{reason}", action.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
// A network-ban type OperServ manages, identified by the ircd's X-line token.
|
// A network-ban type OperServ manages, identified by the ircd's X-line token.
|
||||||
// The wire (`NetAction::AddLine`), storage, and gossip stay the string token
|
// The wire (`NetAction::AddLine`), storage, and gossip stay the string token
|
||||||
// (a transparent passthrough — the ircd knows more line types than we model);
|
// (a transparent passthrough — the ircd knows more line types than we model);
|
||||||
|
|
@ -1669,6 +1706,11 @@ pub trait Store {
|
||||||
fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError>;
|
fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError>;
|
||||||
fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result<bool, RegError>;
|
fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result<bool, RegError>;
|
||||||
fn akills(&self) -> Vec<AkillView>;
|
fn akills(&self) -> Vec<AkillView>;
|
||||||
|
// Spam filters (OperServ SPAMFILTER, oper-only), persisted and re-asserted to
|
||||||
|
// the ircd at each burst. `filter_add` returns whether it was newly added.
|
||||||
|
fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError>;
|
||||||
|
fn filter_del(&mut self, pattern: &str) -> Result<bool, RegError>;
|
||||||
|
fn filters(&self) -> Vec<FilterView>;
|
||||||
// Registration bans (OperServ FORBID).
|
// Registration bans (OperServ FORBID).
|
||||||
fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError>;
|
fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError>;
|
||||||
fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result<bool, RegError>;
|
fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result<bool, RegError>;
|
||||||
|
|
@ -2066,6 +2108,15 @@ mod tests {
|
||||||
assert!(ircd_enforced("mute:*!*@x"), "extban echo's table lacks: ircd enforces");
|
assert!(ircd_enforced("mute:*!*@x"), "extban echo's table lacks: ircd enforces");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_filter_matches_m_filter_wire_form() {
|
||||||
|
// "<freeform> <action> <flags> <duration> :<reason>", spaces in the pattern
|
||||||
|
// escaped to \x07, action lowercased, empty flags -> "-".
|
||||||
|
assert_eq!(encode_filter("*viagra*", "GLINE", "*", 0, "spam"), "*viagra* gline * 0 :spam");
|
||||||
|
assert_eq!(encode_filter("buy now", "block", "", 3600, "ads"), "buy\x07now block - 3600 :ads");
|
||||||
|
assert!(is_filter_action("gline") && is_filter_action("Kill") && !is_filter_action("nonsense"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flags_bitset_parses_applies_and_matches_presets() {
|
fn flags_bitset_parses_applies_and_matches_presets() {
|
||||||
// ACCESS_FLAGS is exactly the flag letters in order — one source of truth.
|
// ACCESS_FLAGS is exactly the flag letters in order — one source of truth.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store};
|
||||||
|
|
||||||
#[path = "xline.rs"]
|
#[path = "xline.rs"]
|
||||||
mod xline;
|
mod xline;
|
||||||
|
mod spamfilter;
|
||||||
#[path = "forbid.rs"]
|
#[path = "forbid.rs"]
|
||||||
mod forbid;
|
mod forbid;
|
||||||
#[path = "global.rs"]
|
#[path = "global.rs"]
|
||||||
|
|
@ -76,6 +77,7 @@ impl Service for OperServ {
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db),
|
||||||
|
Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db),
|
Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
|
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
|
||||||
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
|
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
|
||||||
|
|
@ -109,6 +111,7 @@ const BLURB: &str = "OperServ holds the network operator tools. Every command is
|
||||||
|
|
||||||
const TOPICS: &[HelpEntry] = &[
|
const TOPICS: &[HelpEntry] = &[
|
||||||
HelpEntry { cmd: "AKILL", summary: "network-ban a user@host", detail: "Syntax: \x02AKILL ADD [+expiry] <user@host> <reason> | AKILL DEL <mask|number> | AKILL LIST [pattern]\x02\nA network-wide user@host ban." },
|
HelpEntry { cmd: "AKILL", summary: "network-ban a user@host", detail: "Syntax: \x02AKILL ADD [+expiry] <user@host> <reason> | AKILL DEL <mask|number> | AKILL LIST [pattern]\x02\nA network-wide user@host ban." },
|
||||||
|
HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." },
|
||||||
HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." },
|
HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." },
|
||||||
HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
|
HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
|
||||||
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },
|
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },
|
||||||
|
|
|
||||||
125
modules/operserv/src/spamfilter.rs
Normal file
125
modules/operserv/src/spamfilter.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use echo_api::{parse_duration, Priv, Sender, ServiceCtx, Store};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
// Default filter flags: apply to privmsg/notice/part/quit, exempt opers, match on
|
||||||
|
// colour-stripped text. (m_filter's `*`.)
|
||||||
|
const DEFAULT_FLAGS: &str = "*";
|
||||||
|
|
||||||
|
// SPAMFILTER ADD <action> [+expiry] <regex> <reason> | DEL <regex|number> | LIST [pattern]
|
||||||
|
// Content spam filters echo persists and pushes to the ircd's m_filter (re-asserted
|
||||||
|
// at each burst, so they outlive an ircd restart). The pattern is a regular
|
||||||
|
// expression (the ircd's filter engine), e.g. `.*free.*bitcoin.*`. `action` is what
|
||||||
|
// happens on a match: gline / zline / block / silent / kill / shun / warn / none.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
if !from.privs.has(Priv::Admin) {
|
||||||
|
ctx.notice(me, from.uid, "Access denied — SPAMFILTER needs the \x02admin\x02 privilege.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
|
Some("ADD") => add(me, from, &args[2..], ctx, db),
|
||||||
|
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
|
||||||
|
Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db),
|
||||||
|
_ => ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD <action> [+expiry] <pattern> <reason> | DEL <pattern|number> | LIST [pattern]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
let Some((&action, rest)) = rest.split_first() else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD <action> [+expiry] <pattern> <reason>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !echo_api::is_filter_action(action) {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{action}\x02 isn't a filter action. Use one of: {}.", echo_api::FILTER_ACTIONS.join(", ")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// An optional leading +duration, then the pattern (one glob token), then reason.
|
||||||
|
let mut rest = rest;
|
||||||
|
let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration);
|
||||||
|
if duration.is_some() {
|
||||||
|
rest = &rest[1..];
|
||||||
|
}
|
||||||
|
let Some((&pattern, reason_words)) = rest.split_first() else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD <action> [+expiry] <pattern> <reason>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if reason_words.is_empty() {
|
||||||
|
ctx.notice(me, from.uid, "Please give a reason.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A regex of only wildcards/quantifiers (e.g. `.*`) would match everything.
|
||||||
|
if pattern.chars().all(|c| ".*+?".contains(c)) {
|
||||||
|
ctx.notice(me, from.uid, "That pattern is too wide — it would match almost everything.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let reason = reason_words.join(" ");
|
||||||
|
let setter = from.account.unwrap_or(from.nick);
|
||||||
|
let expires = duration.map(|secs| now() + secs);
|
||||||
|
match db.filter_add(pattern, action, DEFAULT_FLAGS, setter, &reason, expires) {
|
||||||
|
Ok(fresh) => {
|
||||||
|
ctx.filter_push(echo_api::encode_filter(pattern, action, DEFAULT_FLAGS, duration.unwrap_or(0), &reason));
|
||||||
|
let word = if fresh { "added" } else { "updated" };
|
||||||
|
let expiry = if expires.is_some() { " (temporary)" } else { "" };
|
||||||
|
ctx.notice(me, from.uid, format!("Spam filter {word}: \x02{pattern}\x02 ({}){expiry}.", action.to_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
let Some(arg) = arg else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: SPAMFILTER DEL <pattern|number>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// A number targets the nth filter in the list; otherwise it's the pattern.
|
||||||
|
let pattern = match arg.parse::<usize>() {
|
||||||
|
Ok(n) if n >= 1 => match db.filters().get(n - 1) {
|
||||||
|
Some(f) => f.pattern.clone(),
|
||||||
|
None => {
|
||||||
|
ctx.notice(me, from.uid, format!("There's no spam filter number \x02{n}\x02."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => arg.to_string(),
|
||||||
|
};
|
||||||
|
match db.filter_del(&pattern) {
|
||||||
|
Ok(true) => ctx.notice(me, from.uid, format!("Spam filter \x02{pattern}\x02 removed. It stops being re-applied, but stays live on the ircd until the next services relink or a manual \x02/FILTER\x02 removal.")),
|
||||||
|
Ok(false) => ctx.notice(me, from.uid, format!("No spam filter matches \x02{pattern}\x02.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(me: &str, from: &Sender, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
let pat = pattern.map(|p| p.to_ascii_lowercase());
|
||||||
|
let mut shown = 0;
|
||||||
|
for (i, f) in db.filters().iter().enumerate() {
|
||||||
|
if let Some(p) = &pat {
|
||||||
|
if !f.pattern.to_ascii_lowercase().contains(p.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let expiry = match f.expires {
|
||||||
|
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] by {} — {}{}", i + 1, f.pattern, f.action, f.setter, f.reason, expiry));
|
||||||
|
shown += 1;
|
||||||
|
}
|
||||||
|
if shown == 0 {
|
||||||
|
ctx.notice(me, from.uid, "No matching spam filters.");
|
||||||
|
} else {
|
||||||
|
ctx.notice(me, from.uid, format!("End of spam-filter list ({shown} shown)."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human_secs(secs: u64) -> String {
|
||||||
|
match secs {
|
||||||
|
0 => "moments".to_string(),
|
||||||
|
s if s < 3600 => format!("{}m", s / 60),
|
||||||
|
s if s < 86_400 => format!("{}h", s / 3600),
|
||||||
|
s => format!("{}d", s / 86_400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,11 @@ pub enum Event {
|
||||||
kind: String,
|
kind: String,
|
||||||
mask: String,
|
mask: String,
|
||||||
},
|
},
|
||||||
|
// Spam filters (OperServ SPAMFILTER) pushed to the ircd's m_filter via
|
||||||
|
// broadcast METADATA. Global network policy; re-asserted at burst so they
|
||||||
|
// survive an ircd restart. Keyed by `pattern` (the filter's freeform match).
|
||||||
|
FilterAdded { pattern: String, action: String, flags: String, reason: String, setter: String, ts: u64, expires: Option<u64> },
|
||||||
|
FilterRemoved { pattern: String },
|
||||||
// Registration bans (OperServ FORBID). Global network policy. `kind` is
|
// Registration bans (OperServ FORBID). Global network policy. `kind` is
|
||||||
// "NICK", "CHAN", or "EMAIL".
|
// "NICK", "CHAN", or "EMAIL".
|
||||||
ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 },
|
ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 },
|
||||||
|
|
@ -193,6 +198,8 @@ impl Event {
|
||||||
| Event::AccountOperNoteSet { .. }
|
| Event::AccountOperNoteSet { .. }
|
||||||
| Event::AkillAdded { .. }
|
| Event::AkillAdded { .. }
|
||||||
| Event::AkillRemoved { .. }
|
| Event::AkillRemoved { .. }
|
||||||
|
| Event::FilterAdded { .. }
|
||||||
|
| Event::FilterRemoved { .. }
|
||||||
| Event::ForbidAdded { .. }
|
| Event::ForbidAdded { .. }
|
||||||
| Event::ForbidRemoved { .. }
|
| Event::ForbidRemoved { .. }
|
||||||
| Event::NewsAdded { .. }
|
| Event::NewsAdded { .. }
|
||||||
|
|
@ -615,6 +622,14 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
Event::AkillRemoved { kind, mask } => {
|
Event::AkillRemoved { kind, mask } => {
|
||||||
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
||||||
}
|
}
|
||||||
|
Event::FilterAdded { pattern, action, flags, reason, setter, ts, expires } => {
|
||||||
|
// Keyed by pattern (case-insensitive): a re-add refreshes in place.
|
||||||
|
net.filters.retain(|f| !f.pattern.eq_ignore_ascii_case(&pattern));
|
||||||
|
net.filters.push(Filter { pattern, action, flags, reason, setter, ts, expires });
|
||||||
|
}
|
||||||
|
Event::FilterRemoved { pattern } => {
|
||||||
|
net.filters.retain(|f| !f.pattern.eq_ignore_ascii_case(&pattern));
|
||||||
|
}
|
||||||
Event::ForbidAdded { kind, mask, setter, reason, ts } => {
|
Event::ForbidAdded { kind, mask, setter, reason, ts } => {
|
||||||
net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask)));
|
net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask)));
|
||||||
net.forbids.push(Forbid { kind, mask, setter, reason, ts });
|
net.forbids.push(Forbid { kind, mask, setter, reason, ts });
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,20 @@ pub struct Akill {
|
||||||
pub expires: Option<u64>,
|
pub expires: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A spam filter (OperServ SPAMFILTER), pushed to the ircd's m_filter. `expires`
|
||||||
|
// is absolute unix seconds (None = permanent), like an Akill.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Filter {
|
||||||
|
pub pattern: String,
|
||||||
|
pub action: String,
|
||||||
|
pub flags: String,
|
||||||
|
pub reason: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub ts: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
// A registration ban: an account name, channel, or email pattern that may not be
|
// A registration ban: an account name, channel, or email pattern that may not be
|
||||||
// registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an
|
// registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an
|
||||||
// AKILL, so it gossips and every node enforces it.
|
// AKILL, so it gossips and every node enforces it.
|
||||||
|
|
@ -288,6 +302,7 @@ pub type ChanStats = Vec<(String, u64, Vec<(String, u64)>)>;
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct NetData {
|
pub struct NetData {
|
||||||
pub akills: Vec<Akill>,
|
pub akills: Vec<Akill>,
|
||||||
|
pub filters: Vec<Filter>,
|
||||||
pub forbids: Vec<Forbid>,
|
pub forbids: Vec<Forbid>,
|
||||||
pub news: Vec<News>,
|
pub news: Vec<News>,
|
||||||
pub news_seq: u64,
|
pub news_seq: u64,
|
||||||
|
|
@ -1213,6 +1228,9 @@ impl Db {
|
||||||
for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
|
for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
|
||||||
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
|
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
|
||||||
}
|
}
|
||||||
|
for f in self.net.filters.iter().filter(|f| f.expires.is_none_or(|e| e > now)) {
|
||||||
|
snapshot.push(Event::FilterAdded { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires });
|
||||||
|
}
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,42 @@ impl Db {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add (or refresh) a spam filter. Returns whether it was newly added.
|
||||||
|
pub fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
|
||||||
|
// One timestamp for the event and the in-memory record (see akill_add).
|
||||||
|
let ts = now();
|
||||||
|
let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern);
|
||||||
|
let fresh = !self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > ts));
|
||||||
|
self.log
|
||||||
|
.append(Event::FilterAdded { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires })
|
||||||
|
.map_err(|_| RegError::Internal)?;
|
||||||
|
self.net.filters.retain(|f| !same(f));
|
||||||
|
self.net.filters.push(Filter { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires });
|
||||||
|
Ok(fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a spam filter by pattern. Returns whether a live one existed.
|
||||||
|
pub fn filter_del(&mut self, pattern: &str) -> Result<bool, RegError> {
|
||||||
|
let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern);
|
||||||
|
let existed = self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > now()));
|
||||||
|
if !existed {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.log.append(Event::FilterRemoved { pattern: pattern.to_string() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.net.filters.retain(|f| !same(f));
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live spam filters (expired ones hidden lazily), oldest first.
|
||||||
|
pub fn filters(&self) -> Vec<echo_api::FilterView> {
|
||||||
|
let now = now();
|
||||||
|
self.net.filters
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.expires.is_none_or(|e| e > now))
|
||||||
|
.map(|f| echo_api::FilterView { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a registration ban of `kind` ("NICK"/"CHAN"/"EMAIL") for `mask`.
|
/// Add a registration ban of `kind` ("NICK"/"CHAN"/"EMAIL") for `mask`.
|
||||||
/// Returns whether it was new.
|
/// Returns whether it was new.
|
||||||
pub fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError> {
|
pub fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError> {
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,15 @@ impl Store for Db {
|
||||||
fn akills(&self) -> Vec<AkillView> {
|
fn akills(&self) -> Vec<AkillView> {
|
||||||
Db::akills(self)
|
Db::akills(self)
|
||||||
}
|
}
|
||||||
|
fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
|
||||||
|
Db::filter_add(self, pattern, action, flags, setter, reason, expires)
|
||||||
|
}
|
||||||
|
fn filter_del(&mut self, pattern: &str) -> Result<bool, RegError> {
|
||||||
|
Db::filter_del(self, pattern)
|
||||||
|
}
|
||||||
|
fn filters(&self) -> Vec<echo_api::FilterView> {
|
||||||
|
Db::filters(self)
|
||||||
|
}
|
||||||
fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError> {
|
fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError> {
|
||||||
Db::forbid_add(self, kind, mask, setter, reason)
|
Db::forbid_add(self, kind, mask, setter, reason)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1057,6 +1057,12 @@ impl Engine {
|
||||||
let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0);
|
let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0);
|
||||||
out.push(NetAction::AddLine { kind: a.kind.wire().to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason });
|
out.push(NetAction::AddLine { kind: a.kind.wire().to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason });
|
||||||
}
|
}
|
||||||
|
// Re-assert the spam filters so they survive an ircd restart (m_filter has
|
||||||
|
// no s2s delete, so echo is the durable source of truth for them).
|
||||||
|
for f in self.db.filters() {
|
||||||
|
let duration = f.expires.map(|e| e.saturating_sub(now)).unwrap_or(0);
|
||||||
|
out.push(NetAction::Metadata { target: "*".to_string(), key: "filter".to_string(), value: echo_api::encode_filter(&f.pattern, &f.action, &f.flags, duration, &f.reason) });
|
||||||
|
}
|
||||||
// Re-introduce our juped servers over the fresh link.
|
// Re-introduce our juped servers over the fresh link.
|
||||||
for (name, sid, reason) in self.db.jupes() {
|
for (name, sid, reason) in self.db.jupes() {
|
||||||
out.push(NetAction::JupeServer { name, sid, reason });
|
out.push(NetAction::JupeServer { name, sid, reason });
|
||||||
|
|
@ -1657,6 +1663,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
||||||
format!("set a {} on \x02{mask}\x02{temp} ({reason})", ban_kind_label(kind))
|
format!("set a {} on \x02{mask}\x02{temp} ({reason})", ban_kind_label(kind))
|
||||||
}
|
}
|
||||||
AkillRemoved { kind, mask } => format!("lifted the {} on \x02{mask}\x02", ban_kind_label(kind)),
|
AkillRemoved { kind, mask } => format!("lifted the {} on \x02{mask}\x02", ban_kind_label(kind)),
|
||||||
|
FilterAdded { pattern, action, reason, .. } => format!("added a \x02{action}\x02 spam filter on \x02{pattern}\x02 ({reason})"),
|
||||||
|
FilterRemoved { pattern } => format!("removed the spam filter on \x02{pattern}\x02"),
|
||||||
ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()),
|
ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()),
|
||||||
ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()),
|
ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()),
|
||||||
JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"),
|
JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"),
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::ChannelNoExpire { .. }
|
| Event::ChannelNoExpire { .. }
|
||||||
| Event::AkillAdded { .. }
|
| Event::AkillAdded { .. }
|
||||||
| Event::AkillRemoved { .. }
|
| Event::AkillRemoved { .. }
|
||||||
|
| Event::FilterAdded { .. }
|
||||||
|
| Event::FilterRemoved { .. }
|
||||||
| Event::ForbidAdded { .. }
|
| Event::ForbidAdded { .. }
|
||||||
| Event::ForbidRemoved { .. }
|
| Event::ForbidRemoved { .. }
|
||||||
| Event::JupeAdded { .. }
|
| Event::JupeAdded { .. }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue