diff --git a/src/modules/antirandom.rs b/src/modules/antirandom.rs new file mode 100644 index 0000000..5a9b53a --- /dev/null +++ b/src/modules/antirandom.rs @@ -0,0 +1,211 @@ +//! antirandom — detect spam drones whose nick/ident/realname is random-looking, +//! by scoring character patterns and acting when a threshold is crossed. This is +//! reverse's own detection model (the run rules + the unlikely-trigram penalty +//! table are the spec — they define what counts as random); everything around +//! them is original native Rust. +//! +//! Score, summed over nick (+ ident + realname when `checkfull`): +//! - a run reaching 5 digits / 4 vowels / 4 consonants adds that length; each +//! further char in the run adds 1 +//! - each adjacent letter pair found in the "unlikely trigram" table adds 1 +//! +//! At/above `antirandom_threshold` the action fires: kill | gline | kline | +//! zline | block. Opers and logged-in accounts are always exempt. Off unless +//! `antirandom = yes` — read entirely from the config, nothing on `Server`. + +use crate::module::{ModResult, Module}; +use crate::server::Server; +use crate::xline::XKind; +use crate::Uid; + +/// Adjacent letter pairs that rarely occur in real words but pepper random +/// strings — each occurrence adds 1 to the score. reverse's high-signal subset. +const TRIPLES: &[&[u8; 2]] = &[ + b"aj", b"aq", b"av", b"aw", b"ax", b"az", b"bd", b"bg", b"bk", b"bq", b"bx", b"bz", b"cb", + b"cf", b"cg", b"cj", b"cp", b"cv", b"cw", b"cx", b"dx", b"fb", b"fc", b"fg", b"fh", b"fj", + b"fk", b"fp", b"fq", b"fv", b"fw", b"fx", b"fz", b"gb", b"gf", b"gj", b"gp", b"gv", b"gx", + b"hb", b"hf", b"hj", b"hk", b"hv", b"hx", b"hz", b"jc", b"jd", b"jf", b"jg", b"jh", b"jk", + b"jl", b"jm", b"jn", b"jp", b"jq", b"jr", b"js", b"jt", b"jv", b"jw", b"jx", b"jy", b"jz", + b"kb", b"kd", b"kf", b"kg", b"kh", b"kj", b"kp", b"kq", b"kv", b"kx", b"kz", b"lj", b"lq", + b"lx", b"mj", b"mq", b"mx", b"mz", b"pb", b"pf", b"pg", b"pj", b"pk", b"pq", b"pv", b"px", + b"pz", b"qb", b"qc", b"qd", b"qe", b"qf", b"qg", b"qh", b"qi", b"qj", b"qk", b"ql", b"qm", + b"qn", b"qo", b"qp", b"qr", b"qs", b"qt", b"qu", b"qv", b"qw", b"qx", b"qy", b"qz", b"sx", + b"sz", b"tj", b"tq", b"tx", b"vb", b"vc", b"vd", b"vf", b"vg", b"vh", b"vj", b"vk", b"vl", + b"vm", b"vn", b"vp", b"vq", b"vr", b"vs", b"vt", b"vw", b"vx", b"vz", b"wb", b"wc", b"wd", + b"wf", b"wg", b"wj", b"wk", b"wp", b"wq", b"wv", b"wx", b"wz", b"xb", b"xc", b"xd", b"xf", + b"xg", b"xh", b"xj", b"xk", b"xl", b"xm", b"xn", b"xp", b"xq", b"xr", b"xs", b"xt", b"xv", + b"xw", b"xz", b"yb", b"yc", b"yd", b"yf", b"yg", b"yh", b"yj", b"yk", b"yp", b"yq", b"yv", + b"yw", b"yx", b"yz", b"zb", b"zc", b"zd", b"zf", b"zg", b"zh", b"zj", b"zk", b"zl", b"zm", + b"zn", b"zp", b"zq", b"zr", b"zs", b"zt", b"zv", b"zw", b"zx", +]; + +fn is_vowel(c: u8) -> bool { + matches!(c, b'a' | b'e' | b'i' | b'o' | b'u') +} +fn is_consonant(c: u8) -> bool { + c.is_ascii_lowercase() && !is_vowel(c) +} + +/// Score one string for "randomness". Higher = more likely a bot. +fn score_string(input: &str) -> u32 { + if input.is_empty() { + return 0; + } + let s: Vec = input.bytes().map(|b| b.to_ascii_lowercase()).collect(); + let mut score = 0u32; + let (mut digits, mut vowels, mut consonants) = (0u32, 0u32, 0u32); + for i in 0..s.len() { + let c = s[i]; + if c.is_ascii_digit() { + digits += 1; + vowels = 0; + consonants = 0; + } else if is_vowel(c) { + vowels += 1; + digits = 0; + consonants = 0; + } else if is_consonant(c) { + consonants += 1; + digits = 0; + vowels = 0; + } else { + digits = 0; + vowels = 0; + consonants = 0; + } + match digits { + 5 => score += 5, + d if d > 5 => score += 1, + _ => {} + } + match vowels { + 4 => score += 4, + v if v > 4 => score += 1, + _ => {} + } + match consonants { + 4 => score += 4, + c if c > 4 => score += 1, + _ => {} + } + // trigram penalty: the adjacent pair ending at i + if i >= 1 { + let pair = [s[i - 1], c]; + if TRIPLES.iter().any(|t| t[0] == pair[0] && t[1] == pair[1]) { + score += 1; + } + } + } + score +} + +fn dur(s: &Server) -> u64 { + s.conf("antirandom_duration") + .and_then(crate::xline::parse_duration) + .filter(|&d| d > 0) + .unwrap_or(3600) +} + +pub struct AntiRandom; + +impl Module for AntiRandom { + fn name(&self) -> &'static str { + "antirandom" + } + + fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { + if !srv.conf_bool("antirandom", false) { + return ModResult::Passthru; + } + // opers and logged-in accounts are exempt + if srv.is_oper(uid) || srv.is_logged_in(uid) { + return ModResult::Passthru; + } + let threshold = srv.conf_num("antirandom_threshold", 10u32).max(1); + let checkfull = srv.conf_bool("antirandom_checkfull", true); + + let (nick, ident, realname, host, ip, mask) = { + let Some(u) = srv.users.get(&uid) else { + return ModResult::Passthru; + }; + ( + u.nick.clone(), + u.ident.clone(), + u.realname.clone(), + u.host.clone(), + u.addr.ip().to_string(), + u.prefix(), + ) + }; + + let mut score = score_string(&nick); + if checkfull { + score += score_string(&ident); + score += score_string(&realname); + } + if score < threshold { + return ModResult::Passthru; + } + + let action = srv + .conf("antirandom_action") + .unwrap_or("kill") + .to_ascii_lowercase(); + let reason = srv + .conf("antirandom_reason") + .unwrap_or("Random nick/ident/realname (likely spam bot)") + .to_string(); + + if srv.conf_bool("antirandom_showfailed", false) { + srv.snotice(&format!( + "ANTIRANDOM: {mask} (score {score} >= {threshold}) — action: {action}" + )); + } + + let setter = format!("antirandom@{}", srv.name); + let d = dur(srv); + match action.as_str() { + "block" => { + srv.send( + uid, + format!( + ":{} NOTICE {nick} :*** Your nick/ident/realname looks random \ + (often a sign of a bot). Reconnect with a more natural nick, \ + or register your account.", + srv.name + ), + ); + } + "gline" => srv.add_xline(XKind::Gline, &format!("*@{host}"), d, &setter, &reason), + "kline" => srv.add_xline(XKind::Kline, &format!("*@{host}"), d, &setter, &reason), + "zline" => srv.add_xline(XKind::Zline, &ip, d, &setter, &reason), + _ => {} // "kill" (default): the core disconnects on Deny + } + ModResult::Deny + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn real_names_score_low() { + assert!(score_string("reverse") < 10); + assert!(score_string("michael") < 10); + assert!(score_string("nick") < 10); + } + + #[test] + fn random_strings_score_high() { + assert!(score_string("xkjqzvwx") >= 5); + assert!(score_string("qzxjkvbg") >= 5); + assert!(score_string("aeiouaeiou") >= 4); // long vowel run + assert!(score_string("bcdfghjklm") >= 4); // long consonant run + } + + #[test] + fn digit_runs_score() { + assert!(score_string("a123456789") >= 5); + } +} diff --git a/src/modules/blockamsg.rs b/src/modules/blockamsg.rs new file mode 100644 index 0000000..dd13300 --- /dev/null +++ b/src/modules/blockamsg.rs @@ -0,0 +1,136 @@ +//! blockamsg — block the mass "/amsg" and "/ame" commands that mIRC/HexChat send +//! (one message fanned out to every channel you're on), a classic advertise/flood +//! vector. A PRIVMSG/NOTICE whose target list is two-or-more channels is blocked +//! when either: the same text was just sent to a *different* target list within +//! `blockamsg_delay` seconds, or the number of channel targets equals the number +//! of channels the sender is on (>1). Off unless `blockamsg = yes`. Per-user +//! last-message bookkeeping lives in `Server.ext`; nothing on `Server`. +//! +//! Behaviour reference: InspIRCd's `m_blockamsg`. Original native Rust. + +use std::collections::HashMap; + +use crate::module::{ModResult, Module}; +use crate::server::{now, Server}; +use crate::xline::XKind; +use crate::Uid; + +/// Per-user record of the last PRIVMSG/NOTICE: (text, target-list, unix secs). +#[derive(Default)] +struct LastMsg(HashMap); + +/// Count how many comma-separated targets in `list` are channels (`#…`). +fn channel_targets(list: &str) -> usize { + list.split(',').filter(|t| t.starts_with('#')).count() +} + +pub struct BlockAmsg; + +impl Module for BlockAmsg { + fn name(&self) -> &'static str { + "blockamsg" + } + + fn on_pre_command( + &mut self, + srv: &mut Server, + uid: Uid, + cmd: &str, + params: &[String], + ) -> ModResult { + if !srv.conf_bool("blockamsg", false) { + return ModResult::Passthru; + } + if !(cmd.eq_ignore_ascii_case("PRIVMSG") || cmd.eq_ignore_ascii_case("NOTICE")) { + return ModResult::Passthru; + } + if params.len() < 2 { + return ModResult::Passthru; + } + // opers bypass the check entirely + if srv.is_oper(uid) { + return ModResult::Passthru; + } + + let (list, text) = (¶ms[0], ¶ms[1]); + let targets = channel_targets(list); + if targets == 0 { + return ModResult::Passthru; // a PM — never blocked + } + + let delay = srv.conf_num("blockamsg_delay", 3u64); + let chan_count = srv.users.get(&uid).map(|u| u.channels.len()).unwrap_or(0); + let n = now(); + + let store = srv.ext.get_or_insert_with::(LastMsg::default); + let prev = store.0.get(&uid).cloned(); + // record this message for next time (identical to InspIRCd: always update) + store.0.insert(uid, (text.clone(), list.clone(), n)); + + let repeat_hit = prev + .as_ref() + .map(|(pmsg, ptgt, psent)| { + pmsg == text && ptgt != list && delay > 0 && *psent >= n.saturating_sub(delay) + }) + .unwrap_or(false); + let allchans_hit = targets > 1 && targets == chan_count; + + if !(repeat_hit || allchans_hit) { + return ModResult::Passthru; + } + + // ── block it ── + let action = srv + .conf("blockamsg_action") + .unwrap_or("killopers") + .to_ascii_lowercase(); + let notify_opers = matches!(action.as_str(), "killopers" | "noticeopers" | "zlineopers"); + if notify_opers { + let mask = srv.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + srv.snotice(&format!("User {mask} had an /amsg or /ame blocked")); + } + let reason = "Attempted to global message (/amsg or /ame)"; + match action.as_str() { + "kill" | "killopers" => srv.remove_user(uid, reason), + "notice" | "noticeopers" => { + let nick = srv + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + srv.send( + uid, + format!( + ":{} NOTICE {nick} :Global message (/amsg or /ame) blocked", + srv.name + ), + ); + } + "zline" | "zlineopers" => { + let (ip, dur) = ( + srv.users + .get(&uid) + .map(|u| u.addr.ip().to_string()) + .unwrap_or_default(), + srv.conf_num("blockamsg_duration", 900u64), + ); + let setter = format!("blockamsg@{}", srv.name); + srv.add_xline(XKind::Zline, &ip, dur, &setter, reason); + } + _ => {} // "silent": drop with no output + } + ModResult::Deny + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_only_channel_targets() { + assert_eq!(channel_targets("#a,#b,#c"), 3); + assert_eq!(channel_targets("nick"), 0); + assert_eq!(channel_targets("#a,nick"), 1); + } +} diff --git a/src/modules/connectban.rs b/src/modules/connectban.rs new file mode 100644 index 0000000..6f31f5e --- /dev/null +++ b/src/modules/connectban.rs @@ -0,0 +1,162 @@ +//! connectban — z-line an IP range that opens an excessive number of connections +//! to the server. Each connection bumps a per-range counter; when it reaches +//! `connectban_threshold` the range is z-lined for `connectban_duration` and the +//! counter cleared. The whole tally is periodically wiped (`connectban_gcinterval`) +//! so long-lived counts don't accumulate — mirroring InspIRCd's garbage-collect. +//! A `connectban_bootwait` grace after start avoids banning the reconnect storm +//! when the server (re)starts. Off unless `connectban = yes`; all state in +//! `Server.ext`, nothing on `Server`. +//! +//! Because echoIRCd z-lines match by glob (not CIDR), the banned range is emitted +//! as a wildcard mask (`1.2.3.*` for an IPv4 /24, the exact IP for a /32). +//! +//! Behaviour reference: InspIRCd's `m_connectban`. Original native Rust. + +use std::collections::HashMap; +use std::net::IpAddr; + +use crate::module::Module; +use crate::server::{now, Server}; +use crate::xline::XKind; + +/// Per-range connection tally plus the boot-grace / GC bookkeeping. +#[derive(Default)] +struct State { + counts: HashMap, + ignore_until: u64, // ignore connections until this unix time (boot grace) + last_gc: u64, // unix time of the last full clear + booted: bool, // whether ignore_until has been initialised +} + +/// From an IP and the configured prefix length, return `(group_key, ban_glob)`. +/// The group key buckets connections; the glob is what gets z-lined. Non-8-bit +/// (v4) / non-16-bit (v6) prefixes are rounded down for the glob. +fn range_of(ip: IpAddr, v4cidr: u8, v6cidr: u8) -> (String, String) { + match ip { + IpAddr::V4(a) => { + let o = a.octets(); + let keep = (v4cidr / 8).min(4) as usize; + match keep { + 0 => ("v4:*".to_string(), "*".to_string()), + 4 => (format!("v4:{}", a), a.to_string()), + k => { + let head = o[..k] + .iter() + .map(|b| b.to_string()) + .collect::>() + .join("."); + (format!("v4:{head}"), format!("{head}.*")) + } + } + } + IpAddr::V6(a) => { + let segs = a.segments(); + let keep = (v6cidr / 16).min(8) as usize; + if keep >= 8 { + (format!("v6:{}", a), a.to_string()) + } else { + let head = segs[..keep] + .iter() + .map(|s| format!("{s:x}")) + .collect::>() + .join(":"); + let glob = if head.is_empty() { + "*".to_string() + } else { + format!("{head}:*") + }; + (format!("v6:{head}"), glob) + } + } + } +} + +/// Record a new connection from `ip`, z-lining its range if it crosses the limit. +/// No-op when connectban is disabled or still inside the boot-grace window. +pub fn on_connect(s: &mut Server, ip: IpAddr) { + if !s.conf_bool("connectban", false) { + return; + } + let threshold = s.conf_num("connectban_threshold", 10u32).max(2); + let v4 = s.conf_num("connectban_ipv4cidr", 32u8).clamp(1, 32); + let v6 = s.conf_num("connectban_ipv6cidr", 128u8).clamp(1, 128); + let bootwait = s.conf_num("connectban_bootwait", 120u64); + let n = now(); + + let st = s.ext.get_or_insert_with::(State::default); + if !st.booted { + st.ignore_until = n + bootwait; + st.last_gc = n; + st.booted = true; + } + if n < st.ignore_until { + return; + } + + let (key, glob) = range_of(ip, v4, v6); + let c = st.counts.entry(key.clone()).or_insert(0); + *c += 1; + if *c < threshold { + return; + } + st.counts.remove(&key); + + let dur = s.conf_num("connectban_duration", 6 * 60 * 60u64).max(1); + let setter = format!("connectban@{}", s.name); + let reason = s + .conf("connectban_banmessage") + .unwrap_or( + "Your IP range has been attempting to connect too many times in too short a \ + duration. Wait a while, and you will be able to connect.", + ) + .to_string(); + s.add_xline(XKind::Zline, &glob, dur, &setter, &reason); + s.snotice(&format!( + "Connect flooding from IP range {glob} (threshold {threshold})" + )); +} + +/// Periodically clears the whole tally, like InspIRCd's garbage collector. +pub struct ConnectBan; +impl Module for ConnectBan { + fn name(&self) -> &'static str { + "connectban" + } + fn on_tick(&mut self, s: &mut Server) { + if !s.conf_bool("connectban", false) { + return; + } + let gc = s.conf_num("connectban_gcinterval", 3600u64).max(1); + let n = now(); + if let Some(st) = s.ext.get_mut::() { + if n.saturating_sub(st.last_gc) >= gc { + st.counts.clear(); + st.last_gc = n; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_ranges() { + let ip: IpAddr = "1.2.3.4".parse().unwrap(); + assert_eq!(range_of(ip, 32, 128).1, "1.2.3.4"); + assert_eq!(range_of(ip, 24, 128).1, "1.2.3.*"); + assert_eq!(range_of(ip, 16, 128).1, "1.2.*"); + assert_eq!(range_of(ip, 8, 128).1, "1.*"); + // same /24 buckets to one key + let ip2: IpAddr = "1.2.3.9".parse().unwrap(); + assert_eq!(range_of(ip, 24, 128).0, range_of(ip2, 24, 128).0); + } + + #[test] + fn v6_ranges() { + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + assert_eq!(range_of(ip, 32, 128).1, "2001:db8::1"); + assert_eq!(range_of(ip, 32, 32).1, "2001:db8:*"); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index e3df31a..47c5c13 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -4,8 +4,11 @@ //! than the hook bus, but lives here as its own self-contained unit. pub mod antimixedutf8; +pub mod antirandom; +pub mod blockamsg; pub mod chathistory; pub mod cloak; +pub mod connectban; pub mod connflood; pub mod dnsbl; pub mod filter; @@ -17,6 +20,8 @@ pub mod multiline; pub mod network_icon; pub mod profilelink; pub mod reputation; +pub mod restrictcommands; +pub mod restrictmsg; pub mod securitygroups; pub mod snoop; pub mod whoisport; @@ -37,6 +42,11 @@ pub fn default_modules() -> Vec> { Box::new(multiline::Multiline), Box::new(reputation::ReputationMod::default()), Box::new(connflood::ConnFlood), + Box::new(antirandom::AntiRandom), + Box::new(restrictcommands::RestrictCommands), + Box::new(restrictmsg::RestrictMsg), + Box::new(blockamsg::BlockAmsg), + Box::new(connectban::ConnectBan), ] } diff --git a/src/modules/restrictcommands.rs b/src/modules/restrictcommands.rs new file mode 100644 index 0000000..b97b2c2 --- /dev/null +++ b/src/modules/restrictcommands.rs @@ -0,0 +1,185 @@ +//! restrictcommands — hold back chosen commands from brand-new / unregistered +//! users (UnrealIRCd's `set::restrict-commands`), with exemptions. reverse's own +//! module. Each restriction is one config line: +//! +//! ```text +//! restrictcommand = LIST connectdelay=60 exemptidentified=yes exemptwebirc=yes \ +//! exempttls=no exemptscore=24 reason="Please wait a bit." +//! ``` +//! +//! A user may run the command if they are an oper, if ANY exemption matches, or +//! once they have been connected at least `connectdelay` seconds. Everything is +//! read from the config via `Server::conf*` — nothing lives on `Server`. + +use crate::module::{ModResult, Module}; +use crate::server::{now, Server}; +use crate::Uid; + +/// One parsed `restrictcommand` line. +struct Restriction { + command: String, // uppercased + connectdelay: u64, + exempt_identified: bool, + exempt_webirc: bool, + exempt_tls: bool, + exempt_score: Option, + reason: String, +} + +/// Split a config line into whitespace tokens, but keep `"quoted values"` +/// (spaces and all) as a single token — so `reason="a b c"` survives intact. +fn tokenize(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + let mut has = false; + for ch in line.chars() { + match ch { + '"' => { + in_q = !in_q; + has = true; + } + c if c.is_whitespace() && !in_q => { + if has { + out.push(std::mem::take(&mut cur)); + has = false; + } + } + c => { + cur.push(c); + has = true; + } + } + } + if has { + out.push(cur); + } + out +} + +/// Parse all `restrictcommand` config lines into restrictions. +fn parse(s: &Server) -> Vec { + let mut out = Vec::new(); + for line in s.conf_all("restrictcommand") { + let toks = tokenize(line); + let Some((name, attrs)) = toks.split_first() else { + continue; + }; + if name.is_empty() { + continue; + } + let mut r = Restriction { + command: name.to_ascii_uppercase(), + connectdelay: 60, + exempt_identified: true, + exempt_webirc: false, + exempt_tls: false, + exempt_score: None, + reason: "You cannot use this command yet. Please wait or log in.".to_string(), + }; + for tok in attrs { + let Some((k, v)) = tok.split_once('=') else { + continue; + }; + match k { + "connectdelay" => r.connectdelay = crate::xline::parse_duration(v).unwrap_or(60), + "exemptidentified" => r.exempt_identified = crate::config::yesish(v), + "exemptwebirc" => r.exempt_webirc = crate::config::yesish(v), + "exempttls" => r.exempt_tls = crate::config::yesish(v), + "exemptscore" => r.exempt_score = v.parse().ok(), + "reason" => r.reason = v.to_string(), + _ => {} + } + } + out.push(r); + } + out +} + +pub struct RestrictCommands; + +impl Module for RestrictCommands { + fn name(&self) -> &'static str { + "restrictcommands" + } + + fn on_pre_command( + &mut self, + srv: &mut Server, + uid: Uid, + cmd: &str, + _params: &[String], + ) -> ModResult { + // fast path: nothing configured + if srv.conf_all("restrictcommand").is_empty() { + return ModResult::Passthru; + } + let restrictions = parse(srv); + let Some(r) = restrictions + .iter() + .find(|r| r.command.eq_ignore_ascii_case(cmd)) + else { + return ModResult::Passthru; + }; + + // opers are never restricted + if srv.is_oper(uid) { + return ModResult::Passthru; + } + let (secure, webirc, signon) = { + let Some(u) = srv.users.get(&uid) else { + return ModResult::Passthru; + }; + (u.secure, u.flags.via_webirc, u.signon) + }; + + // exemptions: any match lets the command through + if r.exempt_identified && srv.is_logged_in(uid) { + return ModResult::Passthru; + } + if r.exempt_webirc && webirc { + return ModResult::Passthru; + } + if r.exempt_tls && secure { + return ModResult::Passthru; + } + if let Some(min) = r.exempt_score { + if crate::modules::reputation::score_of(srv, uid) >= min { + return ModResult::Passthru; + } + } + // connect-delay: allowed once connected long enough + if r.connectdelay > 0 && now().saturating_sub(signon) >= r.connectdelay { + return ModResult::Passthru; + } + + let (nick, reason) = ( + srv.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + r.reason.clone(), + ); + srv.send(uid, format!(":{} NOTICE {nick} :*** {reason}", srv.name)); + ModResult::Deny + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokenize_keeps_quoted_reason() { + let t = tokenize(r#"LIST connectdelay=60 reason="please wait a bit""#); + assert_eq!( + t, + vec!["LIST", "connectdelay=60", "reason=please wait a bit"] + ); + } + + #[test] + fn tokenize_plain() { + assert_eq!(tokenize("A b c"), vec!["A", "b", "c"]); + } +} diff --git a/src/modules/restrictmsg.rs b/src/modules/restrictmsg.rs new file mode 100644 index 0000000..aca2210 --- /dev/null +++ b/src/modules/restrictmsg.rs @@ -0,0 +1,59 @@ +//! restrictmsg — stop ordinary users from private-messaging each other. A PM +//! between two users is allowed only when the sender is an oper, the target is an +//! oper, or the target is a service/bot (so users can still reach NickServ etc.). +//! Channel messages are never affected. Off unless `restrictmsg = yes` — read +//! straight from the config, nothing on `Server`. +//! +//! Behaviour reference: InspIRCd's `m_restrictmsg`. Original native Rust. + +use crate::module::{ModResult, Module}; +use crate::numeric::ERR_CANTSENDTOUSER; +use crate::server::Server; +use crate::Uid; + +pub struct RestrictMsg; + +impl Module for RestrictMsg { + fn name(&self) -> &'static str { + "restrictmsg" + } + + fn on_pre_message( + &mut self, + srv: &mut Server, + uid: Uid, + target: &str, + _text: &str, + ) -> ModResult { + if !srv.conf_bool("restrictmsg", false) { + return ModResult::Passthru; + } + // channels are unaffected + if target.starts_with('#') { + return ModResult::Passthru; + } + // sender opers may message anyone + if srv.is_oper(uid) { + return ModResult::Passthru; + } + let Some(tuid) = srv.find_nick(target) else { + return ModResult::Passthru; // let the core answer "no such nick" + }; + // allow messaging opers and services/bots + let target_privileged = srv + .users + .get(&tuid) + .map(|u| u.flags.oper || u.flags.bot) + .unwrap_or(false); + if target_privileged { + return ModResult::Passthru; + } + + srv.numeric( + uid, + ERR_CANTSENDTOUSER, + &format!("{target} :You cannot send messages to this user."), + ); + ModResult::Deny + } +} diff --git a/src/server.rs b/src/server.rs index a7252eb..3c01a74 100644 --- a/src/server.rs +++ b/src/server.rs @@ -311,6 +311,9 @@ impl Server { return; } + // connectban — z-line an IP range that opens too many connections (see modules::connectban) + crate::modules::connectban::on_connect(self, ip); + // Pre-registration connection notices, InspIRCd / solanum style. Ident-113 // is archaic and firewalled, so those two are cosmetic; the hostname lookup // is real (see `resolver`) — its result arrives later as an Event.