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
|
|
@ -128,6 +128,11 @@ pub enum Event {
|
|||
kind: 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
|
||||
// "NICK", "CHAN", or "EMAIL".
|
||||
ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 },
|
||||
|
|
@ -193,6 +198,8 @@ impl Event {
|
|||
| Event::AccountOperNoteSet { .. }
|
||||
| Event::AkillAdded { .. }
|
||||
| Event::AkillRemoved { .. }
|
||||
| Event::FilterAdded { .. }
|
||||
| Event::FilterRemoved { .. }
|
||||
| Event::ForbidAdded { .. }
|
||||
| Event::ForbidRemoved { .. }
|
||||
| Event::NewsAdded { .. }
|
||||
|
|
@ -615,6 +622,14 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
Event::AkillRemoved { kind, 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 } => {
|
||||
net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask)));
|
||||
net.forbids.push(Forbid { kind, mask, setter, reason, ts });
|
||||
|
|
|
|||
|
|
@ -190,6 +190,20 @@ pub struct Akill {
|
|||
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
|
||||
// registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an
|
||||
// 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)]
|
||||
pub struct NetData {
|
||||
pub akills: Vec<Akill>,
|
||||
pub filters: Vec<Filter>,
|
||||
pub forbids: Vec<Forbid>,
|
||||
pub news: Vec<News>,
|
||||
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)) {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Returns whether it was new.
|
||||
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> {
|
||||
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> {
|
||||
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);
|
||||
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.
|
||||
for (name, sid, reason) in self.db.jupes() {
|
||||
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))
|
||||
}
|
||||
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()),
|
||||
ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()),
|
||||
JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue