From 61584a545bd22da80e396f059ac4e6f8d0a4429d Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 22:52:28 +0000 Subject: [PATCH] config: make flood/dnsbl/multiline/chathistory/extjwt limits + nick/chan/watch/monitor/silence/whowas/timeouts configurable (no hardcoded options) --- echoircd.conf.example | 38 ++++++++++++++++++++++++++++++++++++++ src/channels.rs | 6 +++--- src/coremods/core_oper.rs | 6 +++--- src/coremods/core_user.rs | 10 ++++++++-- src/coremods/core_watch.rs | 14 +++++++++----- src/link.rs | 2 +- src/modules/chathistory.rs | 15 +++++++++++---- src/modules/dnsbl.rs | 21 +++++---------------- src/modules/extjwt.rs | 5 +++-- src/modules/flood.rs | 12 ++++++++---- src/modules/multiline.rs | 15 +++++++++++++-- src/server.rs | 35 +++++++++++++++++++++++------------ src/users.rs | 28 ++++++++++++++++------------ 13 files changed, 141 insertions(+), 66 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index 14bd97b..9faaa68 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -121,3 +121,41 @@ amu_target = both # G: ban extban (e.g. +b G:CN,RU), the oper GEOIP command and # a country line in WHOIS (opers). Point at a GeoLite2-Country.mmdb file: # geoip_database = /etc/echoircd/GeoLite2-Country.mmdb + +# --- tunable limits & timeouts (every one shown with its built-in default; set a +# line only to override it). None of these are hardcoded — all read at runtime. + +# flood: allow this many messages per this many seconds before dropping (opers exempt) +# flood_messages = 8 +# flood_seconds = 4 + +# dnsbl: ban length in seconds when dnsbl_action is kline/gline/zline (default 1 day) +# dnsbl_duration = 86400 + +# draft/multiline: the max-bytes / max-lines advertised in the cap AND enforced +# multiline_maxbytes = 4096 +# multiline_maxlines = 24 + +# CHATHISTORY: messages kept per conversation (also the ceiling a client may request) +# chathistory_limit = 256 + +# EXTJWT: token line-chunk size (bytes) when splitting a long token across lines +# extjwt_chunk = 200 + +# per-user list sizes (advertised in ISUPPORT WATCH/MONITOR/SILENCE where applicable) +# maxwatch = 128 +# maxmonitor = 128 +# maxsilence = 32 +# maxaccept = 64 + +# WHOWAS: number of historical nick records retained +# whowas_maxentries = 256 + +# nick / channel name length limits (advertised as NICKLEN / CHANNELLEN) +# maxnick = 30 +# maxchannel = 50 + +# connection timeouts, in seconds +# registration_timeout = 60 # drop clients that never register (NICK+USER) in time +# ping_frequency = 90 # send a PING after this much idle time +# ping_timeout = 60 # then drop if no PONG within this much longer diff --git a/src/channels.rs b/src/channels.rs index 6f7de3e..89fca91 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -501,7 +501,7 @@ impl Server { /// Join a user to a channel (creating it if new, giving the creator +o), /// then broadcast JOIN and send TOPIC + NAMES. Queues the join hook. pub fn join(&mut self, uid: Uid, name: &str, key_arg: Option<&str>) { - if !valid_chan(name) { + if !valid_chan(name, self.conf_num("maxchannel", 50usize)) { self.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{name} :No such channel")); return; } @@ -1076,10 +1076,10 @@ impl Server { } /// A channel name starts with `#`, is ≤ 50 chars, and has no space/comma/control. -pub fn valid_chan(name: &str) -> bool { +pub fn valid_chan(name: &str, maxlen: usize) -> bool { name.starts_with('#') && name.len() > 1 - && name.len() <= 50 + && name.len() <= maxlen && !name .chars() .any(|c| c == ' ' || c == ',' || (c as u32) < 0x20) diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 10b159a..e507e0f 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -391,7 +391,7 @@ impl Command for SaNick { return CmdResult::Fail; }; let newnick = ¶ms[1]; - if !valid_nick(newnick) { + if !valid_nick(newnick, s.conf_num("maxnick", 30usize)) { s.numeric( uid, ERR_ERRONEUSNICKNAME, @@ -441,7 +441,7 @@ impl Command for SvsNick { return CmdResult::Fail; }; let newnick = ¶ms[1]; - if !valid_nick(newnick) { + if !valid_nick(newnick, s.conf_num("maxnick", 30usize)) { s.numeric( uid, ERR_ERRONEUSNICKNAME, @@ -761,7 +761,7 @@ impl Command for NickLock { return CmdResult::Fail; }; let newnick = ¶ms[1]; - if !valid_nick(newnick) { + if !valid_nick(newnick, s.conf_num("maxnick", 30usize)) { s.numeric( uid, ERR_ERRONEUSNICKNAME, diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 36ad51a..da7501e 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -180,7 +180,13 @@ impl Command for Cap { format!( ":{} CAP {who} LS :{}", s.name, - Caps::ls_line(cap302, secure, &acctreg) + Caps::ls_line( + cap302, + secure, + &acctreg, + crate::modules::multiline::max_bytes(s), + crate::modules::multiline::max_lines(s), + ) ), ); } @@ -393,7 +399,7 @@ impl Command for Nick { s.numeric(uid, ERR_NONICKNAMEGIVEN, ":No nickname given"); return CmdResult::Fail; }; - if !valid_nick(newnick) { + if !valid_nick(newnick, s.conf_num("maxnick", 30usize)) { s.numeric( uid, ERR_ERRONEUSNICKNAME, diff --git a/src/coremods/core_watch.rs b/src/coremods/core_watch.rs index 9fe965d..35ec418 100644 --- a/src/coremods/core_watch.rs +++ b/src/coremods/core_watch.rs @@ -45,10 +45,11 @@ fn watch_add(s: &mut Server, uid: Uid, nick: &str) { return; } let low = nick.to_ascii_lowercase(); + let maxwatch = s.conf_num("maxwatch", WATCH_MAX); let full = s .users .get(&uid) - .map(|u| u.watch.len() >= WATCH_MAX && !u.watch.contains(&low)) + .map(|u| u.watch.len() >= maxwatch && !u.watch.contains(&low)) .unwrap_or(true); if full { s.numeric( @@ -185,16 +186,17 @@ impl Command for Monitor { let mut added = Vec::new(); for t in targets { let low = t.to_ascii_lowercase(); + let maxmon = s.conf_num("maxmonitor", MONITOR_MAX); let full = s .users .get(&uid) - .map(|u| u.monitor.len() >= MONITOR_MAX && !u.monitor.contains(&low)) + .map(|u| u.monitor.len() >= maxmon && !u.monitor.contains(&low)) .unwrap_or(true); if full { s.numeric( uid, ERR_MONLISTFULL, - &format!("{MONITOR_MAX} {t} :Monitor list is full"), + &format!("{maxmon} {t} :Monitor list is full"), ); continue; } @@ -278,10 +280,11 @@ impl Command for Silence { let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); if let Some(m) = arg.strip_prefix('+') { let mask = normalize_mask(m); + let maxsil = s.conf_num("maxsilence", SILENCE_MAX); let full = s .users .get(&uid) - .map(|u| u.silence.len() >= SILENCE_MAX && !u.silence.contains(&mask)) + .map(|u| u.silence.len() >= maxsil && !u.silence.contains(&mask)) .unwrap_or(true); if full { s.numeric( @@ -353,10 +356,11 @@ impl Command for Accept { } let low = name.to_ascii_lowercase(); if adding { + let maxacc = s.conf_num("maxaccept", ACCEPT_MAX); let (full, exists) = s .users .get(&uid) - .map(|u| (u.accept.len() >= ACCEPT_MAX, u.accept.contains(&low))) + .map(|u| (u.accept.len() >= maxacc, u.accept.contains(&low))) .unwrap_or((true, false)); if exists { s.numeric( diff --git a/src/link.rs b/src/link.rs index 7c7f670..710ad93 100644 --- a/src/link.rs +++ b/src/link.rs @@ -433,7 +433,7 @@ impl Server { self.forward_to_target(target, msg, from); return; }; - if !valid_nick(newnick) + if !valid_nick(newnick, self.conf_num("maxnick", 30usize)) || self.find_nick(newnick).is_some() || self.remote_nick.contains_key(&newnick.to_ascii_lowercase()) { diff --git a/src/modules/chathistory.rs b/src/modules/chathistory.rs index 034cfd4..fa01ce8 100644 --- a/src/modules/chathistory.rs +++ b/src/modules/chathistory.rs @@ -12,9 +12,15 @@ use crate::command::{CmdResult, Command}; use crate::server::{iso_time, now, parse_iso, Server}; use crate::Uid; -/// Recent messages CHATHISTORY keeps per conversation. +/// Default number of messages CHATHISTORY keeps per conversation, if +/// `chathistory_limit` is unset. Also the ceiling a client can request. pub const HISTORY_CAP: usize = 256; +/// The configured per-conversation history size (overridable via `chathistory_limit`). +pub fn limit(s: &Server) -> usize { + s.conf_num("chathistory_limit", HISTORY_CAP).clamp(1, 100_000) +} + /// One stored message, replayed by CHATHISTORY / the `+H` backlog. pub struct HistMsg { pub ts: u64, @@ -40,6 +46,7 @@ pub fn record( text: &str, msgid: &str, ) { + let cap = limit(s); let buf = s .ext .get_or_insert_with::(History::default) @@ -54,7 +61,7 @@ pub fn record( target: target.to_string(), text: text.to_string(), }); - while buf.len() > HISTORY_CAP { + while buf.len() > cap { buf.pop_front(); } } @@ -105,7 +112,7 @@ impl Command for ChatHistory { .get(3) .and_then(|l| l.parse::().ok()) .unwrap_or(50) - .clamp(1, HISTORY_CAP); + .clamp(1, limit(s)); let me = s .users .get(&uid) @@ -186,7 +193,7 @@ impl Command for ChatHistory { let limit = limit_s .and_then(|l| l.parse::().ok()) .unwrap_or(50) - .clamp(1, HISTORY_CAP); + .clamp(1, limit(s)); let bref = s.next_msgid().replace('-', ""); let mut lines: Vec = Vec::new(); diff --git a/src/modules/dnsbl.rs b/src/modules/dnsbl.rs index 75d1dc6..d97b378 100644 --- a/src/modules/dnsbl.rs +++ b/src/modules/dnsbl.rs @@ -20,7 +20,7 @@ use crate::xline::XKind; use crate::Uid; /// Ban length applied by the `*line` actions on a hit. -const DNSBL_BAN: u64 = 86_400; // 1 day +const DNSBL_BAN: u64 = 86_400; // default ban length (1 day) if `dnsbl_duration` unset /// Outcome of a DNSBL check for one connecting client. pub enum Outcome { @@ -84,22 +84,11 @@ fn act(s: &mut Server, uid: Uid, zone: &str, reply: Ipv4Addr) { )); let reason = format!("{} (listed on {zone})", s.dnsbl_reason); let ipstr = ip.to_string(); + let dur = s.conf_num("dnsbl_duration", DNSBL_BAN); match action.as_str() { - "kline" => s.add_xline( - XKind::Kline, - &format!("*@{ipstr}"), - DNSBL_BAN, - "dnsbl", - &reason, - ), - "gline" => s.add_xline( - XKind::Gline, - &format!("*@{ipstr}"), - DNSBL_BAN, - "dnsbl", - &reason, - ), - "zline" => s.add_xline(XKind::Zline, &ipstr, DNSBL_BAN, "dnsbl", &reason), + "kline" => s.add_xline(XKind::Kline, &format!("*@{ipstr}"), dur, "dnsbl", &reason), + "gline" => s.add_xline(XKind::Gline, &format!("*@{ipstr}"), dur, "dnsbl", &reason), + "zline" => s.add_xline(XKind::Zline, &ipstr, dur, "dnsbl", &reason), "kill" | "reject" => {} _ => return, // "mark" or unknown: notify only, don't disconnect } diff --git a/src/modules/extjwt.rs b/src/modules/extjwt.rs index bfe0a59..0ad18db 100644 --- a/src/modules/extjwt.rs +++ b/src/modules/extjwt.rs @@ -21,7 +21,7 @@ use crate::server::{now, Server}; use crate::Uid; /// Longest token chunk per EXTJWT line (keeps the whole line well under 512). -const CHUNK: usize = 200; +const CHUNK: usize = 200; // default token chunk size if `extjwt_chunk` unset /// Resolve `(secret, duration)` for a service name (`*` = the default service). fn service(s: &Server, name: &str) -> Option<(String, u64)> { @@ -133,10 +133,11 @@ impl Command for ExtJwt { }; // send the token, chunked, with a `*` continuation marker on all but the last + let chunk_sz = s.conf_num("extjwt_chunk", CHUNK).max(1); let bytes = token.as_bytes(); let mut i = 0; while i < bytes.len() { - let end = (i + CHUNK).min(bytes.len()); + let end = (i + chunk_sz).min(bytes.len()); let chunk = &token[i..end]; let more = end < bytes.len(); let line = if more { diff --git a/src/modules/flood.rs b/src/modules/flood.rs index 90a5a5d..9d81b7c 100644 --- a/src/modules/flood.rs +++ b/src/modules/flood.rs @@ -10,8 +10,10 @@ use crate::module::{ModResult, Module}; use crate::server::{now, Server}; use crate::Uid; -const FLOOD_MAX: usize = 8; // messages allowed… -const FLOOD_WINDOW: u64 = 4; // …within this many seconds +// Defaults if unset in the config (`flood_messages` / `flood_seconds`): this many +// messages allowed within this many seconds. +const FLOOD_MAX: usize = 8; +const FLOOD_WINDOW: u64 = 4; #[derive(Default)] struct FloodState { @@ -34,6 +36,8 @@ impl Module for Flood { _text: &str, ) -> ModResult { let now = now(); + let max = srv.conf_num("flood_messages", FLOOD_MAX); + let window = srv.conf_num("flood_seconds", FLOOD_WINDOW); let (over, warn) = { let Some(u) = srv.users.get_mut(&uid) else { return ModResult::Passthru; @@ -42,9 +46,9 @@ impl Module for Flood { return ModResult::Passthru; // opers bypass flood limits } let st = u.ext.get_or_insert_with(FloodState::default); - st.times.retain(|&t| now.saturating_sub(t) < FLOOD_WINDOW); + st.times.retain(|&t| now.saturating_sub(t) < window); st.times.push(now); - let over = st.times.len() > FLOOD_MAX; + let over = st.times.len() > max; let warn = over && !st.warned; // notice once per burst st.warned = over; (over, warn) diff --git a/src/modules/multiline.rs b/src/modules/multiline.rs index 9596dc3..562d512 100644 --- a/src/modules/multiline.rs +++ b/src/modules/multiline.rs @@ -14,10 +14,20 @@ use crate::module::Module; use crate::server::Server; use crate::Uid; -/// Limits advertised in the `draft/multiline` cap and enforced while buffering. +/// Default limits (overridable via `multiline_maxbytes` / `multiline_maxlines`), +/// advertised in the `draft/multiline` cap and enforced while buffering. pub const MAX_BYTES: usize = 4096; pub const MAX_LINES: usize = 24; +/// The configured maximum total bytes of one multiline batch. +pub fn max_bytes(s: &Server) -> usize { + s.conf_num("multiline_maxbytes", MAX_BYTES) +} +/// The configured maximum number of lines in one multiline batch. +pub fn max_lines(s: &Server) -> usize { + s.conf_num("multiline_maxlines", MAX_LINES) +} + /// An in-progress inbound multiline batch — one long client message being /// assembled from several `@batch=`-tagged PRIVMSG/NOTICE lines. pub struct MlineBatch { @@ -58,9 +68,10 @@ pub fn accumulate( text: &str, concat: bool, ) -> bool { + let (max_lines, max_bytes) = (max_lines(s), max_bytes(s)); match s.ext.get_mut::().and_then(|m| m.0.get_mut(&uid)) { Some(mb) if mb.bref == bref => { - if mb.parts.len() < MAX_LINES && mb.bytes + text.len() <= MAX_BYTES { + if mb.parts.len() < max_lines && mb.bytes + text.len() <= max_bytes { mb.notice = notice; mb.bytes += text.len(); mb.parts.push((text.to_string(), concat)); diff --git a/src/server.rs b/src/server.rs index cc965e7..a968729 100644 --- a/src/server.rs +++ b/src/server.rs @@ -280,7 +280,8 @@ impl Server { account, ts: now(), }); - while self.whowas.len() > 256 { + let cap = self.conf_num("whowas_maxentries", 256usize); + while self.whowas.len() > cap { self.whowas.pop_back(); } } @@ -584,8 +585,15 @@ impl Server { /// without the trailing `:are supported by this server`. Shared by the welcome /// burst and the `ISUPPORT` command (draft/extended-isupport). pub fn isupport_lines(&self) -> Vec { + // advertised limits mirror the (config-driven) values actually enforced + let maxwatch = self.conf_num("maxwatch", crate::watch::WATCH_MAX); + let maxmon = self.conf_num("maxmonitor", crate::watch::MONITOR_MAX); + let maxsil = self.conf_num("maxsilence", crate::watch::SILENCE_MAX); + let chathist = crate::modules::chathistory::limit(self); + let maxnick = self.conf_num("maxnick", 30usize); + let maxchan = self.conf_num("maxchannel", 50usize); let mut lines = vec![format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,Gcgjmnrsy WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={}", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,Gcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}", self.network )]; if let Some(tok) = crate::modules::network_icon::isupport(self) { @@ -980,19 +988,22 @@ impl Server { /// Decide which connections to PING and which to drop, given `now`. /// Returns `(to_ping, to_quit)`. Pure over the state, so it's unit-testable. pub fn idle_check(&self, now: u64) -> (Vec, Vec) { + let reg_timeout = self.conf_num("registration_timeout", REG_TIMEOUT); + let ping_after = self.conf_num("ping_frequency", PING_AFTER); + let ping_timeout = self.conf_num("ping_timeout", PING_TIMEOUT); let mut ping = Vec::new(); let mut quit = Vec::new(); for (&uid, u) in &self.users { let idle = now.saturating_sub(u.last_active); if !u.registered { - if idle >= REG_TIMEOUT { + if idle >= reg_timeout { quit.push(uid); // never registered in time } } else if u.ping_sent { - if idle >= PING_AFTER + PING_TIMEOUT { + if idle >= ping_after + ping_timeout { quit.push(uid); // no reply to our PING } - } else if idle >= PING_AFTER { + } else if idle >= ping_after { ping.push(uid); // idle — poke it } } @@ -1180,13 +1191,13 @@ mod tests { #[test] fn nick_and_chan_validation() { - assert!(valid_nick("reverse")); - assert!(valid_nick("[abc]`")); - assert!(!valid_nick("1abc")); // can't start with a digit - assert!(!valid_nick("")); - assert!(valid_chan("#argentina")); - assert!(!valid_chan("argentina")); - assert!(!valid_chan("#a b")); + assert!(valid_nick("reverse", 30)); + assert!(valid_nick("[abc]`", 30)); + assert!(!valid_nick("1abc", 30)); // can't start with a digit + assert!(!valid_nick("", 30)); + assert!(valid_chan("#argentina", 50)); + assert!(!valid_chan("argentina", 50)); + assert!(!valid_chan("#a b", 50)); } #[test] diff --git a/src/users.rs b/src/users.rs index 9630975..63c36d1 100644 --- a/src/users.rs +++ b/src/users.rs @@ -7,7 +7,6 @@ use std::net::{SocketAddr, TcpStream}; use crate::extensible::Extensible; use crate::module::Hook; -use crate::modules::multiline::{MAX_BYTES as MLINE_MAX_BYTES, MAX_LINES as MLINE_MAX_LINES}; use crate::numeric::*; use crate::server::{Server, VERSION}; use crate::socketengine::OutSink; @@ -162,7 +161,14 @@ impl Caps { /// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients. /// EXTERNAL is only offered on TLS connections (it needs a client cert). - pub fn ls_line(cap302: bool, secure: bool, acctreg: &str) -> String { + /// `mline_bytes`/`mline_lines` are the (config-driven) draft/multiline limits. + pub fn ls_line( + cap302: bool, + secure: bool, + acctreg: &str, + mline_bytes: usize, + mline_lines: usize, + ) -> String { SUPPORTED_CAPS .iter() .map(|c| { @@ -173,9 +179,7 @@ impl Caps { "sasl=PLAIN".to_string() } } else if *c == "draft/multiline" && cap302 { - format!( - "draft/multiline=max-bytes={MLINE_MAX_BYTES},max-lines={MLINE_MAX_LINES}" - ) + format!("draft/multiline=max-bytes={mline_bytes},max-lines={mline_lines}") } else if *c == "draft/account-registration" && cap302 && !acctreg.is_empty() { format!("draft/account-registration={acctreg}") } else { @@ -537,14 +541,14 @@ pub fn valid_ident(i: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) } -pub fn valid_nick(n: &str) -> bool { +pub fn valid_nick(n: &str, maxlen: usize) -> bool { let special = |c: char| "[]\\`_^{}|".contains(c); let mut chars = n.chars(); match chars.next() { Some(c) if c.is_ascii_alphabetic() || special(c) => {} _ => return false, } - n.len() <= 30 + n.len() <= maxlen && n.chars() .all(|c| c.is_ascii_alphanumeric() || special(c) || c == '-') } @@ -575,12 +579,12 @@ mod tests { assert!(!c.set("bogus-cap", true)); // unknown cap rejected assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl")); assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order - assert!(Caps::ls_line(true, false, "").contains("sasl=PLAIN")); // 302 shows mechs - assert!(!Caps::ls_line(true, false, "").contains("EXTERNAL")); // plaintext: no EXTERNAL - assert!(Caps::ls_line(true, true, "").contains("sasl=PLAIN,EXTERNAL")); // TLS offers it + assert!(Caps::ls_line(true, false, "", 4096, 24).contains("sasl=PLAIN")); // 302 shows mechs + assert!(!Caps::ls_line(true, false, "", 4096, 24).contains("EXTERNAL")); // plaintext: no EXTERNAL + assert!(Caps::ls_line(true, true, "", 4096, 24).contains("sasl=PLAIN,EXTERNAL")); // TLS offers it assert!( - Caps::ls_line(false, false, "").contains("sasl") - && !Caps::ls_line(false, false, "").contains("sasl=") + Caps::ls_line(false, false, "", 4096, 24).contains("sasl") + && !Caps::ls_line(false, false, "", 4096, 24).contains("sasl=") ); c.set("server-time", false); assert!(!c.has("server-time"));