From 65729a62b87e2d27436b04382b1a6e75d7a1fd65 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 05:45:33 +0000 Subject: [PATCH] Track login state and nick changes in NickServ --- src/engine/mod.rs | 110 +++++++++++++++++++++++++++++++++++++-- src/engine/service.rs | 4 +- src/engine/state.rs | 22 ++++++++ src/proto/inspircd.rs | 9 ++++ src/proto/mod.rs | 1 + src/services/nickserv.rs | 9 ++++ 6 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index d614456..47c5c5f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -127,12 +127,16 @@ impl Engine { } pub fn handle(&mut self, event: NetEvent) -> Vec { - match event { + let out = match event { NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], NetEvent::UserConnect { uid, nick } => { self.network.user_connect(uid, nick); Vec::new() } + NetEvent::NickChange { uid, nick } => { + self.network.user_nick_change(&uid, nick); + Vec::new() + } NetEvent::Quit { uid } => { self.network.user_quit(&uid); self.sasl_sessions.remove(&uid); // drop any half-finished exchange @@ -144,6 +148,25 @@ impl Engine { } NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data), _ => Vec::new(), + }; + self.track_accounts(&out); + out + } + + // Keep per-user login state in step with the accountname metadata we send: + // a non-empty value (SASL/IDENTIFY/REGISTER) logs the uid in, an empty one + // (LOGOUT) logs it out. Server-global metadata ("*") is not a login. + fn track_accounts(&mut self, actions: &[NetAction]) { + for action in actions { + if let NetAction::Metadata { target, key, value } = action { + if key == "accountname" && target != "*" { + if value.is_empty() { + self.network.clear_account(target); + } else { + self.network.set_account(target, value); + } + } + } } } @@ -326,15 +349,18 @@ impl Engine { Err(RegError::Exists) => RegOutcome::Exists, Err(RegError::Internal) => RegOutcome::Internal, }; - reg_reply(&reply, outcome, account) + let out = reg_reply(&reply, outcome, account); + self.track_accounts(&out); + out } // Route a PRIVMSG addressed to a service (by uid or nick) into that service, - // handing it the sender's resolved nick and the account store. + // handing it the sender's resolved nick, login state, and the account store. fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec { let nick = self.network.nick_of(from).unwrap_or(from).to_string(); + let account = self.network.account_of(from).map(str::to_string); let mut ctx = ServiceCtx::default(); - let sender = Sender { uid: from, nick: &nick }; + let sender = Sender { uid: from, nick: &nick, account: account.as_deref() }; let Self { services, db, .. } = self; for svc in services.iter_mut() { if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) { @@ -744,4 +770,80 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12345")), "logout renames to guest nick: {out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("logged out"))), "{out:?}"); } + + fn logout(e: &mut Engine, uid: &str) -> Vec { + e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() }) + } + + // LOGOUT while not identified must not rename you or clear anything. + #[test] + fn logout_without_login_is_noop() { + let mut e = engine_with("nologin", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into() }); + let out = logout(&mut e, "000AAAAAB"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{out:?}"); + assert!(!out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "must not rename when not logged in: {out:?}"); + } + + // Regression: a second LOGOUT is a no-op, not another guest rename (the churn + // where Guest33294 -> LOGOUT -> Guest33295). + #[test] + fn logout_twice_renames_only_once() { + let mut e = engine_with("twice", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + let first = logout(&mut e, "000AAAAAB"); + assert!(first.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "first logout renames: {first:?}"); + + let second = logout(&mut e, "000AAAAAB"); + assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{second:?}"); + assert!(!second.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "second logout must not rename again: {second:?}"); + } + + // A SASL login (before the user is even bursted) is remembered, so a later + // LOGOUT from that uid is recognised as logged in. + #[test] + fn sasl_login_is_tracked_for_logout() { + let mut e = engine_with("sasllogout", "foo", "sesame"); + sasl(&mut e, "S", "PLAIN"); + let ok = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame")); + assert!(is_success(&ok), "{ok:?}"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + + let out = logout(&mut e, "000AAAAAB"); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "sasl-authed user can log out: {out:?}"); + } + + // Regression: repeated IDENTIFY while already identified must not re-fire the + // login (the 900 loop when the command is spammed). + #[test] + fn identify_twice_does_not_relogin() { + let mut e = engine_with("reident", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + let ident = |e: &mut Engine| e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(), + }); + + let first = ident(&mut e); + assert!(first.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "first identify logs in: {first:?}"); + + let second = ident(&mut e); + assert!(!second.iter().any(|a| matches!(a, NetAction::Metadata { key, .. } if key == "accountname")), "second identify must not re-login: {second:?}"); + assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("already identified"))), "{second:?}"); + } + + // Regression: after a nick change (e.g. a guest rename, then back to your + // real nick), IDENTIFY must authenticate the CURRENT nick, not the one held + // at burst time — otherwise you log into the wrong account. + #[test] + fn identify_uses_current_nick_after_rename() { + let mut e = engine_with("renameident", "realnick", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest99999".into() }); + e.handle(NetEvent::NickChange { uid: "000AAAAAB".into(), nick: "realnick".into() }); + let out = e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(), + }); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}"); + } } diff --git a/src/engine/service.rs b/src/engine/service.rs index 5c8e7e1..09a57b5 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -1,10 +1,12 @@ use crate::engine::db::Db; use crate::proto::{NetAction, RegReply}; -// Who sent the command, resolved by the engine (UID + current nick). +// Who sent the command, resolved by the engine (UID + current nick + the +// account they are identified to, if any). pub struct Sender<'a> { pub uid: &'a str, pub nick: &'a str, + pub account: Option<&'a str>, } // A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the diff --git a/src/engine/state.rs b/src/engine/state.rs index 7dfe8c0..dfdfcac 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; pub struct Network { pub users: HashMap, // keyed by UID pub channels: HashMap, + accounts: HashMap, // UID -> logged-in account, until logout/quit } pub struct User { @@ -24,11 +25,32 @@ impl Network { self.users.insert(uid.clone(), User { uid, nick }); } + pub fn user_nick_change(&mut self, uid: &str, nick: String) { + if let Some(user) = self.users.get_mut(uid) { + user.nick = nick; + } + } + pub fn user_quit(&mut self, uid: &str) { self.users.remove(uid); + self.accounts.remove(uid); } pub fn nick_of(&self, uid: &str) -> Option<&str> { self.users.get(uid).map(|u| u.nick.as_str()) } + + // A user's currently identified account, if any. Kept in step with the + // accountname metadata the engine emits (login sets it, logout clears it). + pub fn account_of(&self, uid: &str) -> Option<&str> { + self.accounts.get(uid).map(String::as_str) + } + + pub fn set_account(&mut self, uid: &str, account: &str) { + self.accounts.insert(uid.to_string(), account.to_string()); + } + + pub fn clear_account(&mut self, uid: &str) { + self.accounts.remove(uid); + } } diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 2775dd8..aa13dbc 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -81,6 +81,15 @@ impl Protocol for InspIrcd { _ => vec![], } } + // : NICK — keep the sender's current nick + // fresh, so nick-based commands (IDENTIFY/REGISTER) act on who they + // are now, not their nick at burst time (e.g. after a guest rename). + "NICK" => match (source, tokens.next()) { + (Some(uid), Some(nick)) if !nick.is_empty() => { + vec![NetEvent::NickChange { uid, nick: nick.to_string() }] + } + _ => vec![], + }, "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], // account-registration relay from an ircd: // ACCTREGISTER : diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 4fb297d..43271f5 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -11,6 +11,7 @@ pub enum NetEvent { Ping { token: String, from: Option }, Privmsg { from: String, to: String, text: String }, UserConnect { uid: String, nick: String }, + NickChange { uid: String, nick: String }, Quit { 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 }, diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index 9c4336d..69fe19f 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -45,6 +45,11 @@ impl Service for NickServ { }; match db.authenticate(from.nick, password) { Some(account) => { + // Already identified to this account: don't re-fire the login. + if from.account == Some(account) { + ctx.notice(me, from.uid, format!("You are already identified for \x02{}\x02.", account)); + return; + } let account = account.to_string(); ctx.login(from.uid, &account); ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", account)); @@ -53,6 +58,10 @@ impl Service for NickServ { } } Some("LOGOUT") | Some("LOGOFF") => { + if from.account.is_none() { + ctx.notice(me, from.uid, "You are not logged in."); + return; + } // Guest nick = prefix + sequence (seeded from the link TS, bumped // per logout). Inlined field access so it stays disjoint from `me`. let guest = format!("{}{}", self.guest_nick, self.guest_seq);