diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 670943d..b3a8904 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -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) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3ed8bc5..4ef77df 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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."), - }); - if !orphaned.is_empty() { + 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 { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 447bb74..057c553 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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]