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

This commit is contained in:
Jean Chevronnet 2026-08-08 22:34:56 +00:00
parent 7bf9e4c0b8
commit ee98fade40
4 changed files with 197 additions and 0 deletions

View file

@ -29,6 +29,7 @@ pub fn command_table() -> HashMap<&'static str, Box<dyn Command>> {
.chain(core_info::commands())
.chain(core_extra::commands())
.chain(core_watch::commands())
.chain(crate::modules::module_commands())
{
m.insert(c.name(), c);
}

182
src/modules/filter.rs Normal file
View file

@ -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<SpamFilter>);
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::<Filters>().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<Box<dyn Command>> {
vec![Box::new(FilterCmd)]
}
/// FILTER — manage spam filters (oper). No args = list; `<pattern>` = remove;
/// `<pattern> <action> [duration] :<reason>` = 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<String> = s
.ext
.get::<Filters>()
.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::<Filters>()
.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>(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
}
}

View file

@ -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<dyn Module>> {
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<Box<dyn Command>> {
filter::commands().into_iter().collect()
}

View file

@ -169,6 +169,10 @@ pub struct Server {
pub mline: HashMap<Uid, MlineBatch>, // in-progress inbound multiline batches
pub event_tx: Sender<Event>, // self-inject events (DNS results)
pub conn_counter: Arc<AtomicU64>, // 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(),
}
}