diff --git a/echoircd.conf.example b/echoircd.conf.example index 044c929..af0dc79 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -117,6 +117,10 @@ amu_target = both # hidewhois_hide_server = yes # hide 312 # hidewhois_hide_idle = yes # hide 317 # hidewhois_hide_secure = yes # hide 671 +# --- solvemsg (m_solvemsg): an un-vouched user must answer one arithmetic +# question before their private messages are delivered (opers & logged-in +# accounts are exempt). Cheap anti-spam-bot gate. +# solvemsg = yes # --- dccallow (m_dccallow): block unwanted DCC transfers unless the recipient # ran /DCCALLOW +. Blocked file globs are repeatable; blockchat also # gates DCC CHAT. Recipients manage their allow-list with DCCALLOW +/-/LIST. diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 4702e6d..d3842d1 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -51,6 +51,7 @@ pub mod securelist; pub mod securitygroups; pub mod serverban; pub mod showfile; +pub mod solvemsg; pub mod snoop; pub mod tline; pub mod whoisport; @@ -86,6 +87,7 @@ pub fn default_modules() -> Vec> { Box::new(disable::Disable), Box::new(maphide::MapHide), Box::new(dccallow::DccAllow), + Box::new(solvemsg::SolveMsg), ] } diff --git a/src/modules/solvemsg.rs b/src/modules/solvemsg.rs new file mode 100644 index 0000000..f507341 --- /dev/null +++ b/src/modules/solvemsg.rs @@ -0,0 +1,110 @@ +//! solvemsg — a lightweight anti-spam gate: before an un-vouched user's *private* +//! messages are delivered, they must answer one small arithmetic question. Opers +//! and users logged into an account are exempt. Off unless `solvemsg = yes`. +//! +//! Flow: the first PM is held and a question is posed; the user replies with the +//! number (that reply is consumed), and once correct every later message passes. +//! Reference: InspIRCd's `m_solvemsg`. Original native Rust. + +use crate::module::{ModResult, Module}; +use crate::server::Server; +use crate::Uid; + +/// Per-user challenge state, on `User.ext`. +#[derive(Default)] +struct SolveState { + solved: bool, + answer: Option, +} + +/// A uniform-ish random byte in `0..max` via openssl (no `rand` crate). +fn rnd(max: u8) -> u8 { + let mut b = [0u8; 1]; + let _ = openssl::rand::rand_bytes(&mut b); + b[0] % max +} + +pub struct SolveMsg; + +impl Module for SolveMsg { + fn name(&self) -> &'static str { + "solvemsg" + } + + fn on_pre_message( + &mut self, + s: &mut Server, + uid: Uid, + target: &str, + text: &str, + ) -> ModResult { + if !s.conf_bool("solvemsg", false) { + return ModResult::Passthru; + } + // trusted: opers and logged-in accounts never see a challenge + let exempt = s + .users + .get(&uid) + .map(|u| u.flags.oper || u.account.is_some()) + .unwrap_or(true); + if exempt { + return ModResult::Passthru; + } + // only gate PMs to another user (channels have their own controls) + let Some(tuid) = s.find_nick(target) else { + return ModResult::Passthru; + }; + if tuid == uid { + return ModResult::Passthru; + } + let st = s.users.get(&uid).and_then(|u| u.ext.get::()); + if st.map(|st| st.solved).unwrap_or(false) { + return ModResult::Passthru; // already solved + } + let pending = st.and_then(|st| st.answer); + let nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); + + // is this message the answer to an outstanding challenge? + if let Some(ans) = pending { + if text.trim().parse::().ok() == Some(ans) { + if let Some(u) = s.users.get_mut(&uid) { + u.ext.set(SolveState { + solved: true, + answer: None, + }); + } + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** Correct — you may now message freely; please resend your message.", + s.name + ), + ); + return ModResult::Deny; // consume the answer itself + } + } + + // pose a fresh question + let a = (rnd(9) + 1) as i64; + let b = (rnd(9) + 1) as i64; + let (sym, ans) = match rnd(3) { + 0 => ("+", a + b), + 1 => ("-", a - b), + _ => ("*", a * b), + }; + if let Some(u) = s.users.get_mut(&uid) { + u.ext.set(SolveState { + solved: false, + answer: Some(ans), + }); + } + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** To cut spam, answer this to send your message — what is {a} {sym} {b} ?", + s.name + ), + ); + ModResult::Deny + } +}