chanserv: drop a lost account's channels on takeover

When a registration conflict takes an account name away from this node,
the channels it had registered locally as founder can't transfer to the
name's new owner, so they are dropped (releasing the registered mode) and
the former session is told which were released.
This commit is contained in:
Jean Chevronnet 2026-07-12 13:45:07 +00:00
parent aab64baf86
commit 0681c7a3b2
No known key found for this signature in database
2 changed files with 57 additions and 14 deletions

View file

@ -648,6 +648,15 @@ impl Db {
self.channels.values() self.channels.values()
} }
/// Names of channels founded by `account` (case-insensitive).
pub fn channels_owned_by(&self, account: &str) -> Vec<String> {
self.channels
.values()
.filter(|c| c.founder.eq_ignore_ascii_case(account))
.map(|c| c.name.clone())
.collect()
}
/// Set the mode-lock (chars to keep set / unset) for a registered channel. /// Set the mode-lock (chars to keep set / unset) for a registered channel.
pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
let k = key(name); let k = key(name);

View file

@ -124,29 +124,50 @@ impl Engine {
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> { pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
// If ingesting a peer's registration took an account name away from a // If ingesting a peer's registration took an account name away from a
// local session (it lost the conflict), log that session out. // local session (it lost the conflict), clean up after the loser.
if let Some(account) = self.db.ingest(entry)? { if let Some(account) = self.db.ingest(entry)? {
self.logout_lost_sessions(&account); self.handle_account_takeover(&account);
} }
Ok(()) Ok(())
} }
// A registration conflict resolved against this node: the name `account` is now // A registration conflict resolved against this node: the name `account` is now
// owned by another node. Any local session logged into it authenticated against // owned by another node. Log out any local session on it (its credential is
// the old owner's credential, which no longer exists — force them out and say why. // gone) and drop the channels it had registered locally — their founder
fn logout_lost_sessions(&mut self, account: &str) { // identity now belongs to another node, so they can't silently transfer.
for uid in self.network.uids_logged_into(account) { fn handle_account_takeover(&mut self, account: &str) {
let victims = self.network.uids_logged_into(account);
let orphaned = self.db.channels_owned_by(account);
for chan in &orphaned {
let _ = self.db.drop_channel(chan);
}
let (cs, ns) = (self.chan_service.clone(), self.nick_service.clone());
// Release the registered mode on each dropped channel.
for chan in &orphaned {
if let Some(cs) = &cs {
self.emit_irc(NetAction::ChannelMode { from: cs.clone(), channel: chan.clone(), modes: "-r".to_string() });
}
}
// Log out and inform each local session that held the name.
for uid in victims {
self.network.clear_account(&uid); self.network.clear_account(&uid);
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() }); self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
if let Some(ns) = &self.nick_service { if let Some(ns) = &ns {
self.emit_irc(NetAction::Notice {
from: ns.clone(),
to: uid.clone(),
text: format!("Your registration of \x02{account}\x02 collided with another network and it now belongs elsewhere. You have been logged out; please register a different name."),
});
if !orphaned.is_empty() {
self.emit_irc(NetAction::Notice { self.emit_irc(NetAction::Notice {
from: ns.clone(), from: ns.clone(),
to: uid, to: uid,
text: format!("Your registration of \x02{account}\x02 collided with another network and it now belongs elsewhere. You have been logged out; please register a different name."), text: format!("Channels you had registered as \x02{account}\x02 were released: {}.", orphaned.join(", ")),
}); });
} }
} }
} }
}
// 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.
@ -1033,13 +1054,22 @@ mod tests {
// notifies them over the services-initiated outbound path. // notifies them over the services-initiated outbound path.
#[test] #[test]
fn lost_conflict_logs_out_local_session() { fn lost_conflict_logs_out_local_session() {
let mut e = engine_with("lostconf", "alice", "sesame"); use crate::chanserv::ChanServ;
let path = std::env::temp_dir().join("fedserv-lostconf.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 ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 12345 };
let cs = ChanServ { uid: "42SAAAAAB".into() };
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
e.set_irc_out(tx); e.set_irc_out(tx);
// A local user logs into alice. // A local user logs into alice and registers a channel as founder.
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() }); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".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 before the conflict"); assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "logged in before the conflict");
e.db.register_channel("#hers", "alice").unwrap();
// An earlier claim from another node wins and takes the name over. // An earlier claim from another node wins and takes the name over.
let winner = db::Account { let winner = db::Account {
@ -1049,20 +1079,24 @@ mod tests {
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner));
e.gossip_ingest(entry).unwrap(); e.gossip_ingest(entry).unwrap();
// The local session is logged out... // The local session is logged out and its orphaned channel is dropped.
assert!(e.network.account_of("000AAAAAB").is_none(), "session cleared after losing the name"); assert!(e.network.account_of("000AAAAAB").is_none(), "session cleared after losing the name");
// ...and the outbound path got a logout (empty accountname) plus a notice. assert!(e.db.channel("#hers").is_none(), "orphaned channel dropped");
let mut logout = false; // The outbound path got a logout, a notice, a -r on the channel, and a release notice.
let mut notice = false; let (mut logout, mut notice, mut unreg, mut released) = (false, false, false, false);
while let Ok(a) = rx.try_recv() { while let Ok(a) = rx.try_recv() {
match a { match a {
NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => logout = true, NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => logout = true,
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("collided") => notice = true, NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("collided") => notice = true,
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("released") => released = true,
NetAction::ChannelMode { channel, modes, .. } if channel == "#hers" && modes == "-r" => unreg = true,
_ => {} _ => {}
} }
} }
assert!(logout, "a logout must be pushed to the uplink"); assert!(logout, "a logout must be pushed to the uplink");
assert!(notice, "the user must be told why"); assert!(notice, "the user must be told why");
assert!(unreg, "the orphaned channel must be de-registered (-r)");
assert!(released, "the user must be told which channels were released");
} }
// IDENTIFY accepts the two-arg account+password form: log into a named // IDENTIFY accepts the two-arg account+password form: log into a named