diff --git a/api/src/lib.rs b/api/src/lib.rs index 6dbde30..176c24d 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -18,6 +18,10 @@ pub enum NetEvent { Privmsg { from: String, to: String, text: String }, UserConnect { uid: String, nick: String, host: String, ip: String }, NickChange { uid: String, nick: String }, + // The ircd tells us a user's logged-in account (METADATA accountname), e.g. + // replayed on a services netburst so we can restore who was identified. An + // empty `account` means they logged out. + AccountLogin { uid: String, account: String }, // A channel was created or bursted (an FJOIN). Subsequent single joins arrive // as IJOIN and are not surfaced. ChannelCreate { channel: String }, diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 732d456..79a6ca4 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -185,6 +185,22 @@ impl Protocol for InspIrcd { _ => vec![], } } + // : METADATA : — the ircd sharing user + // state. We care about `accountname` on a uid (e.g. replayed on our + // netburst) to restore who is logged in; an empty value is a logout. + "METADATA" => { + let a: Vec<&str> = tokens.collect(); + match (source.as_deref(), a.first(), a.get(1)) { + (Some(src), Some(target), Some(key)) + if !src.starts_with(self.sid.as_str()) + && key.eq_ignore_ascii_case("accountname") + && *target != "*" => + { + vec![NetEvent::AccountLogin { uid: target.to_string(), account: trailing(rest) }] + } + _ => vec![], + } + } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], // ENCAP … — we care about relayed SASL and the // account-registration relay (the ircd's account module forwards a @@ -436,6 +452,19 @@ mod tests { ); } + // The ircd replaying `accountname` metadata (e.g. on our netburst) restores a + // login; an empty value is a logout, and our own echoes and other keys are ignored. + #[test] + fn parses_accountname_metadata() { + let mut p = proto(); + assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB accountname :alice").as_slice(), + [NetEvent::AccountLogin { uid, account }] if uid == "0IRAAAAAB" && account == "alice"), "login restored"); + assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB accountname :").as_slice(), + [NetEvent::AccountLogin { uid, account }] if uid == "0IRAAAAAB" && account.is_empty()), "empty value = logout"); + assert!(p.parse(":42S METADATA 0IRAAAAAB accountname :bob").is_empty(), "our own echo is ignored"); + assert!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :deadbeef").is_empty(), "non-account keys ignored"); + } + // A UID burst introduces the user under their current nick. #[test] fn parses_uid_burst() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 49a5eae..b88102f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -850,6 +850,18 @@ impl Engine { } out } + NetEvent::AccountLogin { uid, account } => { + // The ircd told us who a user is logged in as (e.g. replayed on our + // netburst). Restore the mapping so services recognise the login; + // no side-effects — the burst already put them in their channels. + if account.is_empty() { + self.network.clear_account(&uid); + } else { + self.network.set_account(&uid, &account); + self.pending_enforce.retain(|p| p.uid != uid); + } + Vec::new() + } NetEvent::NickChange { uid, nick } => { let new_nick = nick.clone(); self.network.user_nick_change(&uid, nick); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index b09310a..aee424a 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -432,6 +432,19 @@ assert!(parted, "the assigned bot parts the released channel"); } + // A netburst that replays a user's accountname restores their services login + // (so a services restart doesn't silently log everyone out); empty = logout. + #[test] + fn account_login_event_restores_session() { + let mut e = engine_with("acctlogin", "alice", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() }); + assert_eq!(e.network.account_of("000AAAAAB"), None, "not recognised before the metadata"); + e.handle(NetEvent::AccountLogin { uid: "000AAAAAB".into(), account: "alice".into() }); + assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "login restored from the ircd"); + e.handle(NetEvent::AccountLogin { uid: "000AAAAAB".into(), account: String::new() }); + assert_eq!(e.network.account_of("000AAAAAB"), None, "empty account is a logout"); + } + // 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]