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:?}");
}
}