Track login state and nick changes in NickServ

This commit is contained in:
Jean Chevronnet 2026-07-12 05:45:33 +00:00
parent 60fc687162
commit 65729a62b8
No known key found for this signature in database
6 changed files with 150 additions and 5 deletions

View file

@ -127,12 +127,16 @@ impl Engine {
}
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
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<NetAction> {
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<NetAction> {
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:?}");
}
}

View file

@ -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

View file

@ -6,6 +6,7 @@ use std::collections::HashMap;
pub struct Network {
pub users: HashMap<String, User>, // keyed by UID
pub channels: HashMap<String, Channel>,
accounts: HashMap<String, String>, // 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);
}
}

View file

@ -81,6 +81,15 @@ impl Protocol for InspIrcd {
_ => vec![],
}
}
// :<uid> NICK <newnick> <newts> — 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 <reqid> <origin> <kind> <account> <p2> :<p3>

View file

@ -11,6 +11,7 @@ pub enum NetEvent {
Ping { token: String, from: Option<String> },
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 },

View file

@ -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);