diff --git a/echoircd.conf.example b/echoircd.conf.example index 207de2d..044c929 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -117,6 +117,13 @@ amu_target = both # hidewhois_hide_server = yes # hide 312 # hidewhois_hide_idle = yes # hide 317 # hidewhois_hide_secure = yes # hide 671 +# --- 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. +# dccallow_blockfile = *.exe +# dccallow_blockfile = *.scr +# dccallow_blockchat = yes +# dccallow_maxentries = 20 # --- conn_waitpong (m_conn_waitpong): hold registration until the client answers # a server PING with the exact cookie — filters bots that never PONG. Real # clients auto-reply, so it's transparent to them. diff --git a/src/modules/dccallow.rs b/src/modules/dccallow.rs new file mode 100644 index 0000000..bbee118 --- /dev/null +++ b/src/modules/dccallow.rs @@ -0,0 +1,216 @@ +//! dccallow — block unwanted DCC file transfers (and optionally DCC CHAT) unless +//! the recipient has explicitly allowed the sender with `/DCCALLOW +`. +//! Mirrors InspIRCd's `m_dccallow`. Config (all optional, all runtime-read): +//! +//! ```text +//! dccallow_blockfile = *.exe # repeatable: filename globs to block on DCC SEND +//! dccallow_blockfile = *.scr +//! dccallow_blockchat = yes # also block DCC CHAT (default: no) +//! dccallow_maxentries = 20 # per-user allow-list cap (default 20) +//! ``` +//! +//! A user's allow-list ("nicks I permit to DCC me") lives on their `User.ext`, so +//! it vanishes cleanly when they quit. Original native Rust. + +use crate::channels::glob_match; +use crate::command::{CmdResult, Command}; +use crate::module::{ModResult, Module}; +use crate::server::Server; +use crate::Uid; + +/// Nicks (lowercased) a user permits to DCC them. Stored on the recipient's ext. +#[derive(Default)] +struct Allowed(Vec); + +/// A parsed DCC CTCP request. +enum Dcc { + Send(String), // SEND ... + Chat, // CHAT chat ... + Other, // RESUME/ACCEPT/etc. — not gated +} + +/// Parse a `\x01DCC …\x01` CTCP body; `None` if the text isn't a DCC request. +/// Handles a quoted filename that contains spaces (`"my file.exe"`). +fn parse_dcc(text: &str) -> Option { + let inner = text.strip_prefix('\u{1}')?; + let inner = inner.strip_suffix('\u{1}').unwrap_or(inner); + let mut it = inner.split_whitespace(); + if !it.next()?.eq_ignore_ascii_case("DCC") { + return None; + } + let sub = it.next()?; + if sub.eq_ignore_ascii_case("SEND") { + let first = it.next()?; + let fname = if let Some(stripped) = first.strip_prefix('"') { + match stripped.strip_suffix('"') { + Some(one) => one.to_string(), // "name" — single token + None => { + let mut acc = stripped.to_string(); + for tok in it.by_ref() { + acc.push(' '); + if let Some(end) = tok.strip_suffix('"') { + acc.push_str(end); + break; + } + acc.push_str(tok); + } + acc + } + } + } else { + first.to_string() + }; + Some(Dcc::Send(fname)) + } else if sub.eq_ignore_ascii_case("CHAT") { + Some(Dcc::Chat) + } else { + Some(Dcc::Other) + } +} + +pub struct DccAllow; + +impl Module for DccAllow { + fn name(&self) -> &'static str { + "dccallow" + } + + fn on_pre_message( + &mut self, + s: &mut Server, + uid: Uid, + target: &str, + text: &str, + ) -> ModResult { + let Some(dcc) = parse_dcc(text) else { + return ModResult::Passthru; // not a DCC request + }; + let Some(tuid) = s.find_nick(target) else { + return ModResult::Passthru; // DCC only makes sense user-to-user + }; + if tuid == uid { + return ModResult::Passthru; + } + let sender = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); + let low = sender.to_ascii_lowercase(); + // recipient already whitelisted this sender? + let allowed = s + .users + .get(&tuid) + .and_then(|u| u.ext.get::()) + .map(|a| a.0.contains(&low)) + .unwrap_or(false); + if allowed { + return ModResult::Passthru; + } + let (block, what) = match &dcc { + Dcc::Send(fname) => { + let hit = s + .conf_all("dccallow_blockfile") + .iter() + .any(|p| glob_match(p, fname)); + (hit, format!("the file \"{fname}\"")) + } + Dcc::Chat => (s.conf_bool("dccallow_blockchat", false), "a DCC CHAT".to_string()), + Dcc::Other => (false, String::new()), + }; + if !block { + return ModResult::Passthru; + } + let tnick = s.users.get(&tuid).map(|u| u.nick.clone()).unwrap_or_default(); + s.send( + uid, + format!( + ":{} NOTICE {sender} :*** Your DCC to {tnick} was blocked; they must /DCCALLOW +{sender} first.", + s.name + ), + ); + s.send( + tuid, + format!( + ":{} NOTICE {tnick} :*** {sender} tried to send you {what} — blocked. /DCCALLOW +{sender} to allow it, then ask them to resend.", + s.name + ), + ); + ModResult::Deny + } +} + +pub fn commands() -> Vec> { + vec![Box::new(DccAllowCmd)] +} + +/// DCCALLOW `+nick` / `-nick` / `LIST` / `HELP` — manage the nicks you let DCC you. +struct DccAllowCmd; +impl Command for DccAllowCmd { + fn name(&self) -> &'static str { + "DCCALLOW" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); + let note = |s: &Server, msg: String| s.send(uid, format!(":{} NOTICE {nick} :{msg}", s.name)); + let arg = ¶ms[0]; + + if arg.eq_ignore_ascii_case("LIST") { + let list = s + .users + .get(&uid) + .and_then(|u| u.ext.get::()) + .map(|a| a.0.join(", ")) + .unwrap_or_default(); + note( + s, + if list.is_empty() { + "*** Your DCCALLOW list is empty.".to_string() + } else { + format!("*** DCCALLOW list: {list}") + }, + ); + return CmdResult::Ok; + } + + let (add, name) = match arg.strip_prefix('+') { + Some(n) => (true, n), + None => match arg.strip_prefix('-') { + Some(n) => (false, n), + None => { + note(s, "*** Usage: DCCALLOW + | - | LIST".to_string()); + return CmdResult::Ok; + } + }, + }; + if name.is_empty() { + note(s, "*** DCCALLOW: no nick given.".to_string()); + return CmdResult::Fail; + } + let low = name.to_ascii_lowercase(); + let max = s.conf_num("dccallow_maxentries", 20usize); + // mutate under the borrow, then drop it before sending the notice + let msg = { + let Some(u) = s.users.get_mut(&uid) else { + return CmdResult::Fail; + }; + let a = u.ext.get_or_insert_with(Allowed::default); + if add { + if a.0.contains(&low) { + format!("*** {name} is already on your DCCALLOW list.") + } else if a.0.len() >= max { + format!("*** Your DCCALLOW list is full ({max}).") + } else { + a.0.push(low); + format!("*** Added {name} to your DCCALLOW list.") + } + } else if let Some(pos) = a.0.iter().position(|n| n == &low) { + a.0.remove(pos); + format!("*** Removed {name} from your DCCALLOW list.") + } else { + format!("*** {name} was not on your DCCALLOW list.") + } + }; + note(s, msg); + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index e542bd4..4702e6d 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -16,6 +16,7 @@ pub mod conn_waitpong; pub mod connectban; pub mod connflood; pub mod customtitle; +pub mod dccallow; pub mod denychans; pub mod disable; pub mod dnsbl; @@ -84,6 +85,7 @@ pub fn default_modules() -> Vec> { Box::new(randquote::RandQuote), Box::new(disable::Disable), Box::new(maphide::MapHide), + Box::new(dccallow::DccAllow), ] } @@ -116,6 +118,7 @@ pub fn module_commands() -> Vec> { .chain(tline::commands()) .chain(rmode::commands()) .chain(customtitle::commands()) + .chain(dccallow::commands()) .chain(geoip::commands()) .collect() }