modules: port antirandom, restrictcommands, restrictmsg, blockamsg, connectban
This commit is contained in:
parent
131471e245
commit
a48b48e46f
7 changed files with 766 additions and 0 deletions
211
src/modules/antirandom.rs
Normal file
211
src/modules/antirandom.rs
Normal file
|
|
@ -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<u8> = 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);
|
||||
}
|
||||
}
|
||||
136
src/modules/blockamsg.rs
Normal file
136
src/modules/blockamsg.rs
Normal file
|
|
@ -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<Uid, (String, String, u64)>);
|
||||
|
||||
/// 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>(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);
|
||||
}
|
||||
}
|
||||
162
src/modules/connectban.rs
Normal file
162
src/modules/connectban.rs
Normal file
|
|
@ -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<String, u32>,
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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>(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::<State>() {
|
||||
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:*");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<dyn Module>> {
|
|||
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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
185
src/modules/restrictcommands.rs
Normal file
185
src/modules/restrictcommands.rs
Normal file
|
|
@ -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<u32>,
|
||||
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<String> {
|
||||
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<Restriction> {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
59
src/modules/restrictmsg.rs
Normal file
59
src/modules/restrictmsg.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue