From 43def8ee23b0698170758e2830fe45361ab516c6 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 15:18:39 +0000 Subject: [PATCH] BotServ kickers: FLOOD and REPEAT KICK <#channel> FLOOD ON [lines [secs]] kicks a user who sends too many lines too fast (default 6 in 10s); KICK <#channel> REPEAT ON [times] kicks one who repeats the same line (default 3, case-insensitive). These are stateful, so the engine keeps per-channel/per-user counters in an ephemeral map that is never event-logged. Hot-path conscious: a channel with no kickers costs one lookup and returns; a returning speaker allocates nothing (get_mut, not entry); repeat comparison is an allocation-free case-folding 64-bit hash. Counters are dropped when a user parts or quits, so the map stays bounded. Flood time is injectable for deterministic tests. --- api/src/lib.rs | 4 + botserv/src/kick.rs | 33 +++++++- src/engine/db.rs | 50 +++++++++++- src/engine/mod.rs | 189 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 264 insertions(+), 12 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 630aa95..c7843e7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -412,6 +412,8 @@ pub enum Kicker { Underlines, Reverses, Italics, + Flood, + Repeat, // Exemption, not a rule: never kick channel operators. DontKickOps, } @@ -549,6 +551,8 @@ pub trait Store { fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError>; fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError>; fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>; + fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>; + fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>; fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>; fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option) -> Result<(), ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result; diff --git a/botserv/src/kick.rs b/botserv/src/kick.rs index 2f2edc7..25e4515 100644 --- a/botserv/src/kick.rs +++ b/botserv/src/kick.rs @@ -5,7 +5,7 @@ use fedserv_api::{Kicker, Sender, ServiceCtx, Store}; // and the exemption DONTKICKOPS. Founder-or-admin. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { let (Some(&chan), Some(kind)) = (args.get(1), args.get(2)) else { - ctx.notice(me, from.uid, "Syntax: KICK <#channel> [params]"); + ctx.notice(me, from.uid, "Syntax: KICK <#channel> [params]"); return; }; if !super::require_channel_admin(me, from, chan, ctx, db) { @@ -20,7 +20,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } }; - // CAPS carries optional length/percentage thresholds when turned on. + // CAPS/FLOOD/REPEAT carry optional thresholds when turned on. if kind.eq_ignore_ascii_case("CAPS") { if on { let min = args.get(4).and_then(|s| s.parse::().ok()).unwrap_or(0); @@ -34,6 +34,31 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } return; } + if kind.eq_ignore_ascii_case("FLOOD") { + if on { + let lines = args.get(4).and_then(|s| s.parse::().ok()).unwrap_or(0); + let secs = args.get(5).and_then(|s| s.parse::().ok()).unwrap_or(0); + match db.set_flood_kicker(chan, lines, secs) { + Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02flooding\x02 in \x02{chan}\x02.")), + Err(_) => reg_error(me, from, chan, ctx), + } + } else { + toggle(me, from, chan, Kicker::Flood, false, ctx, db); + } + return; + } + if kind.eq_ignore_ascii_case("REPEAT") { + if on { + let times = args.get(4).and_then(|s| s.parse::().ok()).unwrap_or(0); + match db.set_repeat_kicker(chan, times) { + Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02repeating\x02 in \x02{chan}\x02.")), + Err(_) => reg_error(me, from, chan, ctx), + } + } else { + toggle(me, from, chan, Kicker::Repeat, false, ctx, db); + } + return; + } let kicker = match kind.to_ascii_uppercase().as_str() { "BOLDS" => Kicker::Bolds, @@ -43,7 +68,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: "ITALICS" => Kicker::Italics, "DONTKICKOPS" => Kicker::DontKickOps, other => { - ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS.")); + ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS.")); return; } }; @@ -58,6 +83,8 @@ fn label(kicker: Kicker) -> &'static str { Kicker::Underlines => "underlines", Kicker::Reverses => "reverses", Kicker::Italics => "italics", + Kicker::Flood => "flood", + Kicker::Repeat => "repeat", Kicker::DontKickOps => "dontkickops", } } diff --git a/src/engine/db.rs b/src/engine/db.rs index 00b7458..8087ad5 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -301,6 +301,20 @@ pub struct KickerSettings { pub reverses: bool, #[serde(default)] pub italics: bool, + // Kick users who send too many lines too fast. + #[serde(default)] + pub flood: bool, + // Lines within `flood_secs` that trip the flood kicker (0 = default, 6). + #[serde(default)] + pub flood_lines: u16, + #[serde(default)] + pub flood_secs: u16, + // Kick users who repeat the same line. + #[serde(default)] + pub repeat: bool, + // Consecutive repeats that trip the repeat kicker (0 = default, 3). + #[serde(default)] + pub repeat_times: u16, // Don't kick channel operators, whatever they send. #[serde(default)] pub dontkickops: bool, @@ -309,7 +323,16 @@ pub struct KickerSettings { impl KickerSettings { // Any kicker enabled? (dontkickops alone doesn't count.) pub fn any(&self) -> bool { - self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics + self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat + } + + // Resolved thresholds, applying Anope's defaults for a 0 (unset) value. + pub fn flood_thresholds(&self) -> (u16, u16) { + (if self.flood_lines < 2 { 6 } else { self.flood_lines }, if self.flood_secs == 0 { 10 } else { self.flood_secs }) + } + + pub fn repeat_threshold(&self) -> u16 { + if self.repeat_times == 0 { 3 } else { self.repeat_times } } // The reason to kick `text` for, or None if it trips no enabled kicker. @@ -1372,6 +1395,8 @@ impl Db { Kicker::Underlines => k.underlines = on, Kicker::Reverses => k.reverses = on, Kicker::Italics => k.italics = on, + Kicker::Flood => k.flood = on, + Kicker::Repeat => k.repeat = on, Kicker::DontKickOps => k.dontkickops = on, }) } @@ -1385,6 +1410,23 @@ impl Db { }) } + /// Enable the flood kicker with its lines/seconds thresholds (0 = default). + pub fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> { + self.update_kickers(channel, |k| { + k.flood = true; + k.flood_lines = lines; + k.flood_secs = secs; + }) + } + + /// Enable the repeat kicker with its repeat-count threshold (0 = default). + pub fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> { + self.update_kickers(channel, |k| { + k.repeat = true; + k.repeat_times = times; + }) + } + // Read-modify-write the whole kicker struct through one event. fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> { let k = key(channel); @@ -1963,6 +2005,12 @@ impl Store for Db { fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError> { Db::set_caps_kicker(self, channel, caps_min, caps_percent) } + fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> { + Db::set_flood_kicker(self, channel, lines, secs) + } + fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> { + Db::set_repeat_kicker(self, channel, times) + } fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { Db::set_channel_topic(self, channel, topic) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 22297b8..ca4b70b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -98,6 +98,21 @@ pub struct Engine { bot_uids: HashMap, // casefolded bot nick -> live uid (per connection) next_bot_index: u32, bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined + // Ephemeral per-channel, per-user chatter tracking for the FLOOD and REPEAT + // kickers. Only channels with one of those kickers ever get an entry; a + // user's entry is dropped when they part or quit, so this stays bounded. + // Keyed by the uplink-canonical channel string (never event-logged). + chatter: HashMap>, + now_override: Option, // test clock (unix secs); None = real wall time +} + +// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers). +#[derive(Default)] +struct ChatterState { + flood_start: u64, // window start (unix secs) + flood_lines: u16, // lines since flood_start + last_hash: u64, // case-insensitive hash of the previous line + repeats: u16, // consecutive identical lines seen so far (0 on the first) } impl Engine { @@ -118,9 +133,17 @@ impl Engine { bot_uids: HashMap::new(), next_bot_index: 0, bot_channels: std::collections::HashSet::new(), + chatter: HashMap::new(), + now_override: None, } } + // Wall-clock seconds, overridable in tests so the time-based FLOOD kicker is + // deterministic. + fn now_secs(&self) -> u64 { + self.now_override.unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)) + } + // Wire the channel the link layer drains, so the engine can push actions to the // uplink that no ircd event asked for (here: a forced logout after losing a // registration conflict). @@ -577,6 +600,7 @@ impl Engine { } NetEvent::Part { uid, channel } => { self.network.channel_part(&channel, &uid); + self.forget_chatter(&channel, &uid); Vec::new() } NetEvent::ChannelOp { channel, uid, op } => { @@ -617,6 +641,7 @@ impl Engine { NetEvent::Quit { uid } => { self.network.user_quit(&uid); self.sasl_sessions.remove(&uid); // drop any half-finished exchange + self.forget_chatter_everywhere(&uid); Vec::new() } NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text), @@ -902,19 +927,87 @@ impl Engine { // A BotServ kicker verdict for a channel line: if the channel has an active // kicker the message trips (and the sender isn't an exempt operator), the - // assigned bot kicks them. - fn kicker_check(&self, from: &str, channel: &str, text: &str) -> Option { + // assigned bot kicks them. `&mut self` because the FLOOD/REPEAT kickers keep + // per-user counters. Hot path: one HashMap lookup for a channel with no + // kickers, and no heap allocation for a returning speaker. + fn kicker_check(&mut self, from: &str, channel: &str, text: &str) -> Option { + // `c` borrows self.db; self.network and self.chatter are disjoint fields, + // so they can be touched while it is alive. let c = self.db.channel(channel)?; - if !c.kickers.any() { + let k = &c.kickers; + if !k.any() { return None; } - if c.kickers.dontkickops && self.network.is_op(channel, from) { + if k.dontkickops && self.network.is_op(channel, from) { return None; } - let reason = c.kickers.violation(text)?; - let bot = c.assigned_bot.clone()?; - let botuid = self.network.uid_by_nick(&bot)?.to_string(); - Some(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() }) + let botuid = self.network.uid_by_nick(c.assigned_bot.as_deref()?)?.to_string(); + let kick = |reason: &str| Some(NetAction::Kick { from: botuid.clone(), channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() }); + + // Stateless kickers first (cheapest, and they don't touch counters). + if let Some(reason) = k.violation(text) { + return kick(reason); + } + if !k.flood && !k.repeat { + return None; + } + let (flood_lines, flood_secs) = k.flood_thresholds(); + let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold()); + let now = self.now_secs(); + + // Get (or lazily create) this speaker's counters. get_mut avoids any + // allocation for a channel/user already being tracked. + if !self.chatter.contains_key(channel) { + self.chatter.insert(channel.to_string(), HashMap::new()); + } + let bucket = self.chatter.get_mut(channel).unwrap(); + if !bucket.contains_key(from) { + bucket.insert(from.to_string(), ChatterState::default()); + } + let state = bucket.get_mut(from).unwrap(); + + if flood { + if now.saturating_sub(state.flood_start) > flood_secs as u64 { + state.flood_start = now; + state.flood_lines = 0; + } + state.flood_lines = state.flood_lines.saturating_add(1); + if state.flood_lines >= flood_lines { + return kick("Stop flooding!"); + } + } + if repeat { + let h = ci_hash(text); + if h == state.last_hash { + state.repeats = state.repeats.saturating_add(1); + } else { + state.repeats = 0; + state.last_hash = h; + } + if state.repeats >= repeat_times { + return kick("Stop repeating yourself!"); + } + } + None + } + + // Drop a user's chatter counters for a channel (on part), and the channel's + // bucket once empty, so kicker state never outlives the people it tracks. + fn forget_chatter(&mut self, channel: &str, uid: &str) { + if let Some(bucket) = self.chatter.get_mut(channel) { + bucket.remove(uid); + if bucket.is_empty() { + self.chatter.remove(channel); + } + } + } + + // Drop a user's chatter counters across every channel (on quit). + fn forget_chatter_everywhere(&mut self, uid: &str) { + self.chatter.retain(|_, bucket| { + bucket.remove(uid); + !bucket.is_empty() + }); } // Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel @@ -974,6 +1067,18 @@ fn resource_action(a: &mut NetAction, old: &str, new: &str) { // Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate // it, returning the canonical account name on success. +// A case-insensitive 64-bit hash of a line, for the REPEAT kicker. Allocation- +// free (folds each byte to lowercase in place) so it stays cheap on the hot +// path; a 64-bit collision causing a spurious kick is astronomically unlikely. +fn ci_hash(text: &str) -> u64 { + use std::hash::Hasher; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for b in text.bytes() { + h.write_u8(b.to_ascii_lowercase()); + } + h.finish() +} + fn login_plain(b64: &str, db: &Db) -> Option { let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); @@ -1948,6 +2053,74 @@ mod tests { assert!(!say(&mut e, "000AAAAAR", "SHOUTING BUT I AM OP").iter().any(|a| matches!(a, NetAction::Kick { .. })), "op exempt"); } + // The repeat kicker counts consecutive identical lines (case-insensitively) + // and kicks once the threshold is reached; a different line resets it. + #[test] + fn botserv_repeat_kicker_kicks() { + let (mut e, _p) = kicker_fixture("bsrepeat"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + bs(&mut e, "KICK #c REPEAT ON 2"); // kick on the third identical line + + let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS")); + assert!(!kicked(&say(&mut e, "spam")), "1st ok"); + assert!(!kicked(&say(&mut e, "SPAM")), "2nd (case-fold) ok"); + assert!(kicked(&say(&mut e, "Spam")), "3rd identical kicks"); + // A different line clears the streak. + assert!(!kicked(&say(&mut e, "something else entirely")), "reset on new line"); + } + + // The flood kicker counts lines within a window; the window resets after it + // elapses. Uses the injectable clock so it is deterministic. + #[test] + fn botserv_flood_kicker_kicks() { + let (mut e, _p) = kicker_fixture("bsflood"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + bs(&mut e, "KICK #c FLOOD ON 3 10"); // 3 lines in 10 seconds + let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS")); + + e.now_override = Some(1000); + assert!(!kicked(&say(&mut e, "a")), "line 1"); + assert!(!kicked(&say(&mut e, "b")), "line 2"); + // The window elapses before the 3rd line, so it resets instead of kicking. + e.now_override = Some(1011); + assert!(!kicked(&say(&mut e, "c")), "window reset"); + // Three lines inside the window now trip it. + assert!(!kicked(&say(&mut e, "d")), "line 2 of new window"); + assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks"); + } + + // Registers #c with founder boss (admin), a live assigned bot Bendy, and + // returns the engine ready for KICK configuration + a spammer uid 000AAAAAS. + fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) { + use fedserv_botserv::BotServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join(format!("fedserv-{tag}.jsonl")); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "password1", None).unwrap(); + db.register_channel("#c", "boss").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #c Bendy".into() }); + (e, path) + } + // A member's personal greet is shown by the assigned bot on join, once the // channel enables greets and the member has access. #[test]