From 0d16240606f00839659a9686e3ce6fb1f56ca8fb Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 00:24:27 +0000 Subject: [PATCH] nickserv/chanserv: announce a blocked look-alike registration to the staff feed (ServiceCtx alert drained by dispatch) --- api/src/lib.rs | 10 ++++++++++ modules/chanserv/src/lib.rs | 1 + modules/nickserv/src/register.rs | 1 + src/engine/dispatch.rs | 8 ++++++++ src/engine/tests.rs | 26 ++++++++++++++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/api/src/lib.rs b/api/src/lib.rs index 61d1c30..aa2ec86 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -422,6 +422,10 @@ pub struct ServiceCtx { // folds these into the shared registry after the command runs — so every // service reports its own stats through one shared pipe. pub stats: Vec, + // Staff-feed alerts (category, text) about what the sender just did. The + // engine drains these to the log channel, prefixing the sender's nick/host — + // e.g. a blocked look-alike registration attempt. + pub alerts: Vec<(String, String)>, } impl ServiceCtx { @@ -467,6 +471,12 @@ impl ServiceCtx { self.stats.push(key.into()); } + // Send a line to the staff feed about what the sender just did. The engine + // prefixes the sender's nick/host and routes it to the log channel. + pub fn alert(&mut self, category: impl Into, text: impl Into) { + self.alerts.push((category.into(), text.into())); + } + // A channel/target message sourced from one of our pseudo-clients (e.g. a // bot answering a fantasy command, or BotServ SAY). pub fn privmsg(&mut self, from: &str, to: &str, text: impl Into) { diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 5401e7c..0157a2a 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -135,6 +135,7 @@ impl Service for ChanServ { // homoglyph of a real channel), unless the guard is turned off. if db.confusable_check_enabled() { if let Some(reason) = echo_api::confusable_reason(chan) { + ctx.alert("REGISTER", format!("tried to register the look-alike channel \x02{chan}\x02 (blocked)")); ctx.notice(me, from.uid, reason); return; } diff --git a/modules/nickserv/src/register.rs b/modules/nickserv/src/register.rs index cc13322..eef4fb7 100644 --- a/modules/nickserv/src/register.rs +++ b/modules/nickserv/src/register.rs @@ -16,6 +16,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: // unless the guard is turned off. if db.confusable_check_enabled() { if let Some(reason) = echo_api::confusable_reason(from.nick) { + ctx.alert("REGISTER", "tried to register a look-alike nick (blocked)"); ctx.notice(me, from.uid, reason); return; } diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 85effd5..5dd8140 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -96,11 +96,19 @@ impl Engine { for key in std::mem::take(&mut ctx.stats) { self.bump(&key); } + // Staff-feed alerts a command raised (e.g. a blocked look-alike name), + // prefixed with who the sender is, routed to the log channel. + let alerts = std::mem::take(&mut ctx.alerts); // A command may have changed the bot registry (BotServ BOT ADD/DEL). let mut out = ctx.actions; out.extend(self.reconcile_bots()); // Announce whatever this command changed to the staff audit channel. out.extend(self.audit_feed(audit_mark, &nick, account.as_deref())); + for (cat, text) in alerts { + if let Some(line) = self.feed(&cat, format!("{} {text}", self.who(from))) { + out.push(line); + } + } self.apply_dict_limit(&mut out); self.apply_msg_style(&mut out); out diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 4c64ba5..a81dea5 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5528,6 +5528,32 @@ fn fantasy_dict_emits_a_lookup_via_the_bot() { ); } +// A blocked look-alike channel registration refuses the user AND tells the staff +// feed who tried it. +#[test] +fn confusable_register_alerts_the_staff_feed() { + use echo_chanserv::ChanServ; + let mut db = svc_db("confalert"); + db.register("boss", "sesame", None).unwrap(); + let mut e = Engine::new(vec![ns(), Box::new(ChanServ { uid: "42SAAAAAB".into() })], db); + e.set_sid("42S".into()); + e.set_log_channel(Some("#services".into())); + e.set_debugserv_uid("42SAAAAAO"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "host.fr".into(), ip: "1.2.3.4".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let chan = "#teѕt"; // Latin te + Cyrillic ѕ + Latin t — a look-alike + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: chan.into(), op: true }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: format!("REGISTER {chan}") }); + // The user is refused, + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("alphabets"))), "user refused: {out:?}"); + // and #services is told who tried it. + assert!( + out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } + if from == "42SAAAAAO" && to == "#services" && text.contains("[REGISTER]") && text.contains("boss") && text.contains("look-alike"))), + "staff feed alert: {out:?}" + ); +} + // Perf harness — not part of the normal suite. Run: // cargo test --release -p echo -- --ignored --nocapture bench_engine // Measures the two costs that actually bound the single-threaded engine: the