modules: port securelist and denychans (badchan/goodchan with redirect)
This commit is contained in:
parent
245d8d8529
commit
dc22dcd072
4 changed files with 272 additions and 0 deletions
131
src/modules/denychans.rs
Normal file
131
src/modules/denychans.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! denychans — forbid joining channels whose name matches a `badchan` glob, with
|
||||
//! an optional redirect to a safe channel and an `allowopers` bypass. A `goodchan`
|
||||
//! glob whitelists names back out of a broad `badchan` pattern. Config:
|
||||
//!
|
||||
//! ```text
|
||||
//! badchan = #evil* reason="That channel is off-limits." redirect=#lobby allowopers=yes
|
||||
//! goodchan = #evilgenius
|
||||
//! ```
|
||||
//!
|
||||
//! Dispatched straight from `Server::join` (like the CBAN check), so it works
|
||||
//! per-channel even when several are joined at once. All config-driven; nothing
|
||||
//! lives on `Server`.
|
||||
//!
|
||||
//! Behaviour reference: InspIRCd's `m_denychans`. Original native Rust.
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::numeric::{ERR_BADCHANNEL, ERR_LINKCHANNEL};
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// One parsed `badchan` line.
|
||||
struct BadChan {
|
||||
glob: String,
|
||||
reason: String,
|
||||
redirect: Option<String>,
|
||||
allowopers: bool,
|
||||
}
|
||||
|
||||
/// Reuse the quoted-attribute tokenizer shape: split on whitespace but keep
|
||||
/// `key="quoted value"` together.
|
||||
fn tokenize(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let (mut in_q, mut has) = (false, 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
|
||||
}
|
||||
|
||||
fn parse(s: &Server) -> Vec<BadChan> {
|
||||
let mut out = Vec::new();
|
||||
for line in s.conf_all("badchan") {
|
||||
let toks = tokenize(line);
|
||||
let Some((glob, attrs)) = toks.split_first() else {
|
||||
continue;
|
||||
};
|
||||
if glob.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut bc = BadChan {
|
||||
glob: glob.clone(),
|
||||
reason: "Channel is forbidden.".to_string(),
|
||||
redirect: None,
|
||||
allowopers: false,
|
||||
};
|
||||
for tok in attrs {
|
||||
let Some((k, v)) = tok.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
match k {
|
||||
"reason" => bc.reason = v.to_string(),
|
||||
"redirect" => {
|
||||
if !v.is_empty() {
|
||||
bc.redirect = Some(v.to_string())
|
||||
}
|
||||
}
|
||||
"allowopers" => bc.allowopers = crate::config::yesish(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(bc);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `name` is whitelisted by any `goodchan` glob.
|
||||
fn is_good(s: &Server, name: &str) -> bool {
|
||||
s.conf_all("goodchan").iter().any(|g| glob_match(g, name))
|
||||
}
|
||||
|
||||
/// Called from `Server::join`. Returns `true` when the join to `name` should be
|
||||
/// blocked (the caller returns without joining); emits the numeric and performs a
|
||||
/// redirect join if configured. `is_oper` lets an `allowopers` badchan through.
|
||||
pub fn intercept(s: &mut Server, uid: Uid, name: &str, is_oper: bool) -> bool {
|
||||
if s.conf_all("badchan").is_empty() {
|
||||
return false;
|
||||
}
|
||||
if is_good(s, name) {
|
||||
return false;
|
||||
}
|
||||
let bad = parse(s);
|
||||
let Some(bc) = bad.iter().find(|b| glob_match(&b.glob, name)) else {
|
||||
return false;
|
||||
};
|
||||
if is_oper && bc.allowopers {
|
||||
return false;
|
||||
}
|
||||
|
||||
s.numeric(uid, ERR_BADCHANNEL, &format!("{name} :{}", bc.reason));
|
||||
if let Some(redir) = &bc.redirect {
|
||||
if !redir.eq_ignore_ascii_case(name) {
|
||||
let redir = redir.clone();
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_LINKCHANNEL,
|
||||
&format!("{name} {redir} :You have been redirected."),
|
||||
);
|
||||
s.join(uid, &redir, None);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod chathistory;
|
|||
pub mod cloak;
|
||||
pub mod connectban;
|
||||
pub mod connflood;
|
||||
pub mod denychans;
|
||||
pub mod dnsbl;
|
||||
pub mod filter;
|
||||
pub mod flood;
|
||||
|
|
@ -24,6 +25,7 @@ pub mod realnameban;
|
|||
pub mod reputation;
|
||||
pub mod restrictcommands;
|
||||
pub mod restrictmsg;
|
||||
pub mod securelist;
|
||||
pub mod securitygroups;
|
||||
pub mod serverban;
|
||||
pub mod snoop;
|
||||
|
|
@ -50,6 +52,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
|
|||
Box::new(restrictmsg::RestrictMsg),
|
||||
Box::new(blockamsg::BlockAmsg),
|
||||
Box::new(connectban::ConnectBan),
|
||||
Box::new(securelist::SecureList),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
134
src/modules/securelist.rs
Normal file
134
src/modules/securelist.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! securelist — hold back the `/LIST` command until a user has been connected for
|
||||
//! a while, which defeats spambots that connect, `LIST`, spam every channel and
|
||||
//! leave. Non-exempt users who `LIST` too early get an optional notice and a
|
||||
//! throwaway *fake* channel list (so a bot waiting on the reply is satisfied and
|
||||
//! wastes its time), then the real `LIST` is denied. Exempt: opers, logged-in
|
||||
//! accounts (when `securelist_exemptregistered`), and hosts matching a
|
||||
//! `securelist_exception` glob. Off unless `securelist = yes`; all config-driven.
|
||||
//!
|
||||
//! Behaviour reference: InspIRCd's `m_securelist`. Original native Rust; the fake
|
||||
//! names use OpenSSL's CSPRNG (already a dependency) rather than any new crate.
|
||||
|
||||
use openssl::rand::rand_bytes;
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::module::{ModResult, Module};
|
||||
use crate::numeric::{RPL_LIST, RPL_LISTEND, RPL_LISTSTART};
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
/// A small unsigned int from the CSPRNG in `0..bound` (bound>0), else 0.
|
||||
fn rand_below(bound: u32) -> u32 {
|
||||
if bound == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut b = [0u8; 4];
|
||||
if rand_bytes(&mut b).is_err() {
|
||||
return 0;
|
||||
}
|
||||
u32::from_le_bytes(b) % bound
|
||||
}
|
||||
|
||||
/// A random lowercase-alnum string of `len` chars (for a fake channel suffix).
|
||||
fn rand_name(len: usize) -> String {
|
||||
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut buf = vec![0u8; len];
|
||||
if rand_bytes(&mut buf).is_err() {
|
||||
return "channel".to_string();
|
||||
}
|
||||
buf.iter()
|
||||
.map(|&b| ALPHABET[b as usize % ALPHABET.len()] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `uid` exempt from the LIST hold?
|
||||
fn is_exempt(s: &Server, uid: Uid) -> bool {
|
||||
if s.is_oper(uid) {
|
||||
return true;
|
||||
}
|
||||
if s.conf_bool("securelist_exemptregistered", true) && s.is_logged_in(uid) {
|
||||
return true;
|
||||
}
|
||||
let exceptions = s.conf_all("securelist_exception");
|
||||
if exceptions.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(u) = s.users.get(&uid) else {
|
||||
return false;
|
||||
};
|
||||
let forms = [
|
||||
format!("{}@{}", u.ident, u.host),
|
||||
format!("{}@{}", u.ident, u.addr.ip()),
|
||||
];
|
||||
exceptions
|
||||
.iter()
|
||||
.any(|mask| forms.iter().any(|f| glob_match(mask, f)))
|
||||
}
|
||||
|
||||
pub struct SecureList;
|
||||
|
||||
impl Module for SecureList {
|
||||
fn name(&self) -> &'static str {
|
||||
"securelist"
|
||||
}
|
||||
|
||||
fn on_pre_command(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
cmd: &str,
|
||||
_params: &[String],
|
||||
) -> ModResult {
|
||||
if !srv.conf_bool("securelist", false) || !cmd.eq_ignore_ascii_case("LIST") {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
if is_exempt(srv, uid) {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let waittime = srv.conf_num("securelist_waittime", 60u64);
|
||||
let signon = srv.users.get(&uid).map(|u| u.signon).unwrap_or(0);
|
||||
let elapsed = now().saturating_sub(signon);
|
||||
if waittime > 0 && elapsed >= waittime {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
|
||||
// tell them to wait
|
||||
if srv.conf_bool("securelist_showmsg", true) {
|
||||
let remain = waittime.saturating_sub(elapsed);
|
||||
let nick = srv
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
srv.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** You cannot view the channel list yet. \
|
||||
Please try again in {remain} seconds (or log in to an account).",
|
||||
srv.name
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// throwaway fake list so a bot waiting on the reply is satisfied
|
||||
let fakechans = srv.conf_num("securelist_fakechans", 5u32);
|
||||
let prefix = srv
|
||||
.conf("securelist_fakechanprefix")
|
||||
.unwrap_or("#")
|
||||
.to_string();
|
||||
let topic = srv
|
||||
.conf("securelist_fakechantopic")
|
||||
.unwrap_or("Fake channel for confusing spambots")
|
||||
.to_string();
|
||||
let usercount = srv.users.len().max(1) as u32;
|
||||
|
||||
srv.numeric(uid, RPL_LISTSTART, "Channel :Users Name");
|
||||
for _ in 0..fakechans {
|
||||
let suffix = rand_name((rand_below(8) + 3) as usize);
|
||||
let count = rand_below(usercount) + 1;
|
||||
srv.numeric(uid, RPL_LIST, &format!("{prefix}{suffix} {count} :{topic}"));
|
||||
}
|
||||
srv.numeric(uid, RPL_LISTEND, ":End of channel list.");
|
||||
ModResult::Deny
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue