engine: log out local sessions on a gossiped account suspension
All checks were successful
CI / check (push) Successful in 3m43s
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:
parent
58af1187fc
commit
886fe65e4d
3 changed files with 84 additions and 10 deletions
|
|
@ -888,6 +888,9 @@ impl EventLog {
|
||||||
pub enum AccountChange {
|
pub enum AccountChange {
|
||||||
TakenOver(String),
|
TakenOver(String),
|
||||||
Dropped(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
|
// 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()))),
|
Event::AccountDropped { account } => Some((account.clone(), self.account(account).map(|c| c.home.clone()))),
|
||||||
_ => None,
|
_ => 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);
|
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 {
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,12 @@ impl Engine {
|
||||||
self.handle_account_gone(&a, "was dropped");
|
self.handle_account_gone(&a, "was dropped");
|
||||||
true
|
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,
|
None => false,
|
||||||
};
|
};
|
||||||
// handle_account_gone may have released a channel that had a bot assigned.
|
// 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.
|
// Log out and inform each local session that held the name.
|
||||||
for uid in victims {
|
for uid in victims {
|
||||||
self.network.clear_account(&uid);
|
self.logout_uid(&uid, account, reason);
|
||||||
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
|
if !orphaned.is_empty() {
|
||||||
if let Some(ns) = &ns {
|
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.emit_irc(NetAction::Notice {
|
self.emit_irc(NetAction::Notice {
|
||||||
from: ns.clone(),
|
from: ns.clone(),
|
||||||
to: uid,
|
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
|
// Compact the log if it has grown past the live account count. Returns
|
||||||
// whether it rewrote anything.
|
// whether it rewrote anything.
|
||||||
pub fn maybe_compact(&mut self) -> std::io::Result<bool> {
|
pub fn maybe_compact(&mut self) -> std::io::Result<bool> {
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,43 @@
|
||||||
assert!(parted, "the assigned bot parts the released channel");
|
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
|
// Losing a registration conflict logs out the local session on that name and
|
||||||
// notifies them over the services-initiated outbound path.
|
// notifies them over the services-initiated outbound path.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue