From ee98fade40d962e307414facfcbb724e44ce04f5 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 22:34:56 +0000 Subject: [PATCH] filter (m_filter) as a self-contained module: Server.ext typemap for module state + module_commands() wiring; FILTER command + on_pre_message hook all in modules/filter.rs --- src/coremods/mod.rs | 1 + src/modules/filter.rs | 182 ++++++++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 9 +++ src/server.rs | 5 ++ 4 files changed, 197 insertions(+) create mode 100644 src/modules/filter.rs diff --git a/src/coremods/mod.rs b/src/coremods/mod.rs index 798b92f..c0d9ed0 100644 --- a/src/coremods/mod.rs +++ b/src/coremods/mod.rs @@ -29,6 +29,7 @@ pub fn command_table() -> HashMap<&'static str, Box> { .chain(core_info::commands()) .chain(core_extra::commands()) .chain(core_watch::commands()) + .chain(crate::modules::module_commands()) { m.insert(c.name(), c); } diff --git a/src/modules/filter.rs b/src/modules/filter.rs new file mode 100644 index 0000000..90a322b --- /dev/null +++ b/src/modules/filter.rs @@ -0,0 +1,182 @@ +//! filter — InspIRCd's `m_filter`: oper-configured spam/word filters. A glob is +//! matched against PRIVMSG/NOTICE text and, on a hit, an action is taken. Fully +//! self-contained: the rule set lives in `Server.ext` (the module-owned typemap), +//! the `FILTER` command manages it, and the `on_pre_message` hook enforces it — +//! nothing leaks into server.rs or config.rs. + +use crate::channels::glob_match; +use crate::command::{CmdResult, Command}; +use crate::module::{ModResult, Module}; +use crate::numeric::ERR_NOPRIVILEGES; +use crate::server::Server; +use crate::xline::{parse_duration, XKind}; +use crate::Uid; + +/// One filter rule. +#[derive(Clone)] +pub struct SpamFilter { + pub pattern: String, // glob matched against message text + pub action: String, // block | silent | kill | kline | gline | zline + pub duration: u64, // ban length for the *line actions (seconds; 0 = permanent) + pub reason: String, +} + +/// The rule set — stored in `Server.ext`, so it never touches the core struct. +#[derive(Default)] +pub struct Filters(pub Vec); + +impl Filters { + /// The (action, reason, duration) of the first rule whose glob matches `text`. + fn hit(&self, text: &str) -> Option<(String, String, u64)> { + self.0 + .iter() + .find(|f| glob_match(&f.pattern, text)) + .map(|f| (f.action.clone(), f.reason.clone(), f.duration)) + } +} + +/// The enforcement hook. +pub struct Filter; +impl Module for Filter { + fn name(&self) -> &'static str { + "filter" + } + fn on_pre_message(&mut self, s: &mut Server, uid: Uid, _target: &str, text: &str) -> ModResult { + let Some((action, reason, duration)) = s.ext.get::().and_then(|f| f.hit(text)) + else { + return ModResult::Passthru; + }; + let (mask, ip) = match s.users.get(&uid) { + Some(u) => (u.prefix(), u.addr.ip().to_string()), + None => return ModResult::Deny, + }; + s.snotice(&format!( + "FILTER: {mask} matched a filter (action={action}): {reason}" + )); + match action.as_str() { + "block" => s.notice_star(uid, &format!("Your message was blocked: {reason}")), + "silent" => {} + "kill" => { + s.send(uid, format!("ERROR :Closing link: ({reason})")); + s.remove_user(uid, &reason); + } + "kline" => { + s.add_xline( + XKind::Kline, + &format!("*@{ip}"), + duration, + "filter", + &reason, + ); + s.remove_user(uid, &reason); + } + "gline" => { + s.add_xline( + XKind::Gline, + &format!("*@{ip}"), + duration, + "filter", + &reason, + ); + s.remove_user(uid, &reason); + } + "zline" => { + s.add_xline(XKind::Zline, &ip, duration, "filter", &reason); + s.remove_user(uid, &reason); + } + _ => {} + } + ModResult::Deny // the message never reaches the channel/user + } +} + +pub fn commands() -> Vec> { + vec![Box::new(FilterCmd)] +} + +/// FILTER — manage spam filters (oper). No args = list; `` = remove; +/// ` [duration] :` = add/replace. +struct FilterCmd; +impl Command for FilterCmd { + fn name(&self) -> &'static str { + "FILTER" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + match params.first() { + None => { + let list: Vec = s + .ext + .get::() + .map(|f| { + f.0.iter() + .map(|r| { + format!("{} {} {} :{}", r.pattern, r.action, r.duration, r.reason) + }) + .collect() + }) + .unwrap_or_default(); + for r in list { + s.send(uid, format!(":{} NOTICE {nick} :FILTER {r}", s.name)); + } + s.send( + uid, + format!(":{} NOTICE {nick} :End of FILTER list", s.name), + ); + } + Some(pattern) => { + let pattern = pattern.clone(); + if params.len() < 2 { + let removed = s + .ext + .get_mut::() + .map(|f| { + let before = f.0.len(); + f.0.retain(|r| r.pattern != pattern); + before != f.0.len() + }) + .unwrap_or(false); + let word = if removed { "removed" } else { "not found" }; + s.send( + uid, + format!(":{} NOTICE {nick} :FILTER {word}: {pattern}", s.name), + ); + } else { + let action = params[1].clone(); + let (duration, reason) = match params.get(2).and_then(|p| parse_duration(p)) { + Some(d) if params.len() > 3 => (d, params[3].clone()), + _ => ( + 0, + params + .get(2) + .cloned() + .unwrap_or_else(|| "Filtered".to_string()), + ), + }; + let f = s.ext.get_or_insert_with::(Filters::default); + f.0.retain(|r| r.pattern != pattern); + f.0.push(SpamFilter { + pattern: pattern.clone(), + action: action.clone(), + duration, + reason, + }); + s.snotice(&format!("{nick} added FILTER {pattern} (action={action})")); + } + } + } + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 89a01b5..1f87bec 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -6,9 +6,11 @@ pub mod antimixedutf8; pub mod cloak; pub mod dnsbl; +pub mod filter; pub mod flood; pub mod snoop; +use crate::command::Command; use crate::module::Module; /// The modules loaded at boot. (Later: load by name from the config.) @@ -18,5 +20,12 @@ pub fn default_modules() -> Vec> { Box::new(flood::Flood), Box::new(cloak::Cloak), Box::new(antimixedutf8::AntiMixedUtf8), + Box::new(filter::Filter), ] } + +/// Commands contributed by modules (chained into the core command table), so a +/// module that adds a command keeps it in its own file, InspIRCd-style. +pub fn module_commands() -> Vec> { + filter::commands().into_iter().collect() +} diff --git a/src/server.rs b/src/server.rs index 2b6b5af..19fd635 100644 --- a/src/server.rs +++ b/src/server.rs @@ -169,6 +169,10 @@ pub struct Server { pub mline: HashMap, // in-progress inbound multiline batches pub event_tx: Sender, // self-inject events (DNS results) pub conn_counter: Arc, // mints connection uids (for CONNECT dials) + /// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem` + /// equivalent. Each `modules/*.rs` stores its own struct here so features live + /// in their own file instead of bloating this one. + pub ext: Extensible, } impl Server { @@ -216,6 +220,7 @@ impl Server { mline: HashMap::new(), event_tx, conn_counter, + ext: Extensible::default(), } }