engine: log out local sessions on a gossiped account suspension
All checks were successful
CI / check (push) Successful in 3m43s

A local SUSPEND logs out the account's active sessions, but a suspension
arriving by gossip only set the flag — a session already identified on a
remote node stayed logged in despite the network-wide suspension. ingest
now signals AccountChange::Suspended, and gossip_ingest ends those sessions
(the account and its channels are kept). Factors the per-session logout into
a shared logout_uid helper.
This commit is contained in:
Jean Chevronnet 2026-07-16 09:13:53 +00:00
parent 58af1187fc
commit 886fe65e4d
No known key found for this signature in database
3 changed files with 84 additions and 10 deletions

View file

@ -888,6 +888,9 @@ impl EventLog {
pub enum AccountChange {
TakenOver(String),
Dropped(String),
// A gossiped suspension that is now in force: local sessions must be logged
// out, but the account (and its channels) stay put.
Suspended(String),
}
// A fingerprint is hex (hash digest), optionally colon-separated. Bound the
@ -1027,7 +1030,13 @@ impl Db {
Event::AccountDropped { account } => Some((account.clone(), self.account(account).map(|c| c.home.clone()))),
_ => None,
};
if let Some(event) = self.log.ingest(entry)? {
let applied = self.log.ingest(entry)?;
// A freshly applied suspension may need to terminate local sessions.
let suspended = applied.as_ref().and_then(|e| match e {
Event::AccountSuspended { account, .. } => Some(account.clone()),
_ => None,
});
if let Some(event) = applied {
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event);
}
if let Some((name, prev_home)) = watched {
@ -1037,6 +1046,12 @@ impl Db {
_ => {}
}
}
if let Some(account) = suspended {
// Only if the suspension is actually in force (not an already-expired one).
if self.is_suspended(&account) {
return Ok(Some(AccountChange::Suspended(account)));
}
}
Ok(None)
}

View file

@ -469,6 +469,12 @@ impl Engine {
self.handle_account_gone(&a, "was dropped");
true
}
Some(db::AccountChange::Suspended(a)) => {
// A gossiped suspension keeps the account and its channels, but its
// live sessions here must end (a local SUSPEND logs them out too).
self.logout_account_sessions(&a, "was suspended");
false
}
None => false,
};
// handle_account_gone may have released a channel that had a bot assigned.
@ -504,15 +510,9 @@ impl Engine {
}
// Log out and inform each local session that held the name.
for uid in victims {
self.network.clear_account(&uid);
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
if let Some(ns) = &ns {
self.emit_irc(NetAction::Notice {
from: ns.clone(),
to: uid.clone(),
text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."),
});
self.logout_uid(&uid, account, reason);
if !orphaned.is_empty() {
if let Some(ns) = &ns {
self.emit_irc(NetAction::Notice {
from: ns.clone(),
to: uid,
@ -523,6 +523,28 @@ impl Engine {
}
}
// 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);
self.emit_irc(NetAction::Metadata { target: uid.to_string(), key: "accountname".to_string(), value: String::new() });
if let Some(ns) = self.nick_service.clone() {
self.emit_irc(NetAction::Notice {
from: ns,
to: uid.to_string(),
text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."),
});
}
}
// Log out every local session identified to `account` — e.g. after a gossiped
// suspension. Unlike `handle_account_gone` the account and its channels stay
// put; only the live sessions end.
fn logout_account_sessions(&mut self, account: &str, reason: &str) {
for uid in self.network.uids_logged_into(account) {
self.logout_uid(&uid, account, reason);
}
}
// Compact the log if it has grown past the live account count. Returns
// whether it rewrote anything.
pub fn maybe_compact(&mut self) -> std::io::Result<bool> {

View file

@ -432,6 +432,43 @@
assert!(parted, "the assigned bot parts the released channel");
}
// A suspension that arrives by gossip must end local sessions on that account,
// just as a local SUSPEND does — the account and its channels stay put.
#[test]
fn gossiped_suspension_logs_out_local_sessions() {
use echo_nickserv::NickServ;
let path = std::env::temp_dir().join("echo-gossipsusp.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "test");
db.scram_iterations = 4096;
db.register("alice", "sesame", None).unwrap();
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
e.set_sid("42S".into());
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "logged in first");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
e.set_irc_out(tx);
// A peer suspends alice; the suspension gossips in.
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountSuspended { account: "alice".into(), by: "peer-oper".into(), reason: "spam".into(), ts: 0, expires: None });
e.gossip_ingest(entry).unwrap();
assert!(e.db.is_suspended("alice"), "suspension applied locally");
assert!(e.network.account_of("000AAAAAB").is_none(), "the live session is logged out");
assert!(e.db.exists("alice"), "the account itself is kept");
let (mut cleared, mut told) = (false, false);
while let Ok(a) = rx.try_recv() {
match a {
NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => cleared = true,
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("suspended") => told = true,
_ => {}
}
}
assert!(cleared, "accountname metadata cleared on the ircd");
assert!(told, "the user is told they were suspended");
}
// Losing a registration conflict logs out the local session on that name and
// notifies them over the services-initiated outbound path.
#[test]