From 63ecd79b12b5f4cfc7f3378ea841ace999350746 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 10:06:36 +0000 Subject: [PATCH] =?UTF-8?q?engine:=20handle=20inbound=20KILL=20=E2=80=94?= =?UTF-8?q?=20forget=20users,=20reintroduce=20bots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KILL was never parsed, so a killed user lingered in services' state (stale session, session-limit slot, channel membership) and a killed services bot vanished for good. The parser now surfaces UserKilled; a killed real user is forgotten like a QUIT (shared forget_user helper), while a killed bot is dropped and reconciled so it is reintroduced and rejoins its channels. --- api/src/lib.rs | 3 ++ modules/protocol/inspircd/src/lib.rs | 13 ++++++++ src/engine/mod.rs | 28 +++++++++++++++--- src/engine/tests.rs | 44 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 176c24d..7624cc7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -43,6 +43,9 @@ pub enum NetEvent { // the source uid; our own changes are filtered out by the protocol layer. TopicChange { channel: String, setter: String, topic: String }, Quit { uid: String }, + // A user was forcibly removed (KILL). Handled like a quit, except a killed + // services bot is reintroduced rather than forgotten. + UserKilled { uid: String }, // An ircd relaying an IRCv3 account-registration request to us as the authority. AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String }, // An ircd relaying a SASL exchange step to us (the SASL agent). mode = H/S/C/D. diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 79a6ca4..6735596 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -202,6 +202,12 @@ impl Protocol for InspIrcd { } } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], + // : KILL : — a user forcibly removed. We forget + // them (or reintroduce, if it was one of our bots). + "KILL" => match tokens.next() { + Some(uid) if !uid.is_empty() => vec![NetEvent::UserKilled { uid: uid.to_string() }], + _ => vec![], + }, // ENCAP … — we care about relayed SASL and the // account-registration relay (the ircd's account module forwards a // leaf's REGISTER/VERIFY/RESEND/STATUS to us, the authority server). @@ -465,6 +471,13 @@ mod tests { assert!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :deadbeef").is_empty(), "non-account keys ignored"); } + // A KILL surfaces as a UserKilled for the target uid. + #[test] + fn parses_kill() { + let ev = proto().parse(":0IR KILL 0IRAAAAAB :bye now"); + assert!(matches!(ev.as_slice(), [NetEvent::UserKilled { uid }] if uid == "0IRAAAAAB"), "{ev:?}"); + } + // A UID burst introduces the user under their current nick. #[test] fn parses_uid_burst() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0e9ff7d..679f501 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -533,6 +533,15 @@ impl Engine { } } + // Forget a user who left the network (QUIT or KILL): drop their membership, + // any half-finished SASL exchange, and their pending nick-protection timer. + fn forget_user(&mut self, uid: &str) { + self.network.user_quit(uid); + self.sasl_sessions.remove(uid); + self.pending_enforce.retain(|p| p.uid != uid); + self.forget_chatter_everywhere(uid); + } + // Log a single session out of `account`, telling them why (services-sourced). fn logout_uid(&mut self, uid: &str, account: &str, reason: &str) { self.network.clear_account(uid); @@ -1031,10 +1040,21 @@ impl Engine { } } NetEvent::Quit { uid } => { - self.network.user_quit(&uid); - self.sasl_sessions.remove(&uid); // drop any half-finished exchange - self.pending_enforce.retain(|p| p.uid != uid); - self.forget_chatter_everywhere(&uid); + self.forget_user(&uid); + Vec::new() + } + NetEvent::UserKilled { uid } => { + // If the ircd killed one of our bots, forget it so reconcile + // reintroduces it (and rejoins its channels); a killed real user is + // simply gone. + if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == &uid).then(|| lc.clone())) { + self.bot_uids.remove(&bot_lc); + self.bot_idents.remove(&bot_lc); + self.network.bot_forget(&bot_lc); + self.bot_channels.retain(|(b, _)| *b != bot_lc); + return self.reconcile_bots(); + } + self.forget_user(&uid); Vec::new() } NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text), diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 9cc9c1d..e12609c 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -1860,6 +1860,50 @@ assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}"); } + // A KILL forgets a real user, but a killed services bot is reintroduced and + // rejoins its channels — an oper can't take a bot down for good. + #[test] + fn kill_forgets_a_user_and_reintroduces_a_bot() { + use echo_botserv::BotServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-killbot.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(echo_api::Priv::Admin)); + e.set_opers(opers); + let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() }); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() }); + ns(&mut e, "IDENTIFY password1"); + bs(&mut e, "BOT ADD Bendy bot serv.host Helper"); + let bot_uid = bs(&mut e, "ASSIGN #c Bendy").iter().find_map(|a| match a { + NetAction::ServiceJoin { uid, channel } if channel == "#c" => Some(uid.clone()), + _ => None, + }).expect("bot joined on assign"); + + // Killing a real user forgets their session. + assert_eq!(e.network.account_of("000AAAAAB"), Some("boss"), "identified before the kill"); + e.handle(NetEvent::UserKilled { uid: "000AAAAAB".into() }); + assert!(e.network.account_of("000AAAAAB").is_none(), "killed user forgotten"); + + // Killing the bot brings it right back and rejoins its channel. + let out = e.handle(NetEvent::UserKilled { uid: bot_uid }); + assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, .. } if nick == "Bendy")), "killed bot reintroduced: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "reintroduced bot rejoins #c: {out:?}"); + } + // A channel that expires with an assigned bot parts the bot in the same // sweep — it must not linger in a channel it no longer serves. #[test]