diff --git a/api/src/lib.rs b/api/src/lib.rs index 8bea31f..a07bf76 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -46,8 +46,11 @@ pub enum NetEvent { // A user was forcibly removed (KILL). Handled like a quit, except a killed // services bot is reintroduced rather than forgotten. UserKilled { uid: String }, - // A server split away (SQUIT). `server` is its SID; every user behind it - // (whose uid carries that SID prefix) is gone, since a split is signalled once + // A downstream server was introduced (a sourced SERVER). `parent` is the SID + // that introduced it, so we can track the tree and cascade a hub's SQUIT. + ServerLink { sid: String, parent: String }, + // A server split away (SQUIT). `server` is its SID; every user behind it — and + // behind any server in its subtree — is gone, since a split is signalled once // rather than as a QUIT per user. ServerSplit { server: String }, // An ircd relaying an IRCv3 account-registration request to us as the authority. diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 5f18119..ede55f6 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -53,7 +53,19 @@ impl Protocol for InspIrcd { None => return vec![], }; match cmd.to_ascii_uppercase().as_str() { - "SERVER" => vec![NetEvent::Registered], + // A sourced SERVER ( : SERVER [hops] : ) is a + // downstream link — track it for the server tree. The unsourced auth + // handshake ( SERVER … ) is our uplink registering. + "SERVER" => match source { + Some(parent) => { + let _name = tokens.next(); + match tokens.next() { + Some(sid) if !sid.is_empty() => vec![NetEvent::ServerLink { sid: sid.to_string(), parent }], + _ => vec![], + } + } + None => vec![NetEvent::Registered], + }, "ENDBURST" => vec![NetEvent::EndBurst], "PING" => { let token = tokens @@ -483,6 +495,16 @@ mod tests { assert!(matches!(ev.as_slice(), [NetEvent::UserKilled { uid }] if uid == "0IRAAAAAB"), "{ev:?}"); } + // The unsourced auth handshake registers our uplink; a sourced SERVER is a + // downstream link tracked (with its parent) for the server tree. + #[test] + fn parses_server_link() { + let mut p = proto(); + assert!(matches!(p.parse("SERVER hub.example password 0IR :a hub").as_slice(), [NetEvent::Registered]), "uplink handshake"); + assert!(matches!(p.parse(":0IR SERVER leaf.example 0XY :a leaf").as_slice(), + [NetEvent::ServerLink { sid, parent }] if sid == "0XY" && parent == "0IR"), "downstream link"); + } + // A SQUIT surfaces as a ServerSplit carrying the departed server's SID. #[test] fn parses_squit() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 089eed3..4a38251 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -783,6 +783,7 @@ impl Engine { self.bot_idents.clear(); self.bot_channels.clear(); self.network.clear_bots(); + self.network.clear_servers(); // the burst re-introduces the server tree self.next_bot_index = 0; let mut out = vec![NetAction::Burst]; for svc in &self.services { @@ -1068,11 +1069,17 @@ impl Engine { self.forget_user(&uid); Vec::new() } + NetEvent::ServerLink { sid, parent } => { + self.network.server_link(&sid, &parent); + Vec::new() + } NetEvent::ServerSplit { server } => { - // Every user behind the departed server is gone in one message; - // forget them so their sessions, slots and memberships don't linger. - for uid in self.network.uids_on_server(&server) { - self.forget_user(&uid); + // A hub's SQUIT arrives once but takes its whole subtree with it; + // forget every user behind the departed server and its descendants. + for sid in self.network.server_split(&server) { + for uid in self.network.uids_on_server(&sid) { + self.forget_user(&uid); + } } Vec::new() } diff --git a/src/engine/state.rs b/src/engine/state.rs index 12824e6..50ca44d 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -20,6 +20,9 @@ pub struct Network { // `users` so a reconnect can clear them wholesale without disturbing the // uplink-sourced user map. bots: HashMap, + // Downstream server tree: SID -> the SID that introduced it. Lets a hub's + // SQUIT cascade to every server (and user) behind it. Rebuilt each link. + servers: HashMap, // Shared, namespaced stat counters any service contributes to (ephemeral, // ordered for a stable snapshot). Read by StatServ and the gRPC Stats API. stats: BTreeMap, @@ -186,6 +189,37 @@ impl Network { self.users.keys().filter(|u| u.starts_with(sid)).cloned().collect() } + // Record a downstream server and the SID that introduced it (its parent). + pub fn server_link(&mut self, sid: &str, parent: &str) { + self.servers.insert(sid.to_string(), parent.to_string()); + } + + // Forget the server subtree behind (and including) `sid` — its own SID plus + // every server introduced beneath it — and return the whole set so their users + // can be forgotten too. A hub's SQUIT arrives once but takes its children with it. + pub fn server_split(&mut self, sid: &str) -> Vec { + let mut subtree = vec![sid.to_string()]; + let mut i = 0; + while i < subtree.len() { + let parent = subtree[i].clone(); + for (child, up) in &self.servers { + if up == &parent && !subtree.contains(child) { + subtree.push(child.clone()); + } + } + i += 1; + } + for s in &subtree { + self.servers.remove(s); + } + subtree + } + + // Drop the whole server tree (on a fresh link, before the burst rebuilds it). + pub fn clear_servers(&mut self) { + self.servers.clear(); + } + pub fn user_nick_change(&mut self, uid: &str, nick: String) { if let Some(user) = self.users.get_mut(uid) { self.seen.insert(lc(&nick), Seen { nick: nick.clone(), ts: now(), what: format!("changing nick from {}", user.nick) }); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index dcec81e..6a1e182 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -451,6 +451,30 @@ assert_eq!(e.network.account_of("0XYAAAAAB"), Some("bob"), "users on other servers are kept"); } + // A hub's SQUIT cascades: users behind the hub AND behind every server in its + // subtree are forgotten, while unrelated servers' users are kept. + #[test] + fn squit_cascades_to_the_whole_subtree() { + let mut e = engine_with("squitcascade", "alice", "sesame"); + e.db.register("bob", "hunter2", None).unwrap(); + e.db.register("carol", "pw", None).unwrap(); + // 0HB is a hub under our uplink; 0LF is a leaf behind the hub. + e.handle(NetEvent::ServerLink { sid: "0HB".into(), parent: "42S".into() }); + e.handle(NetEvent::ServerLink { sid: "0LF".into(), parent: "0HB".into() }); + e.handle(NetEvent::UserConnect { uid: "0HBAAAAAA".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::UserConnect { uid: "0LFAAAAAA".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::UserConnect { uid: "0OKAAAAAA".into(), nick: "carol".into(), host: "h".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "0HBAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::Privmsg { from: "0LFAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY hunter2".into() }); + e.handle(NetEvent::Privmsg { from: "0OKAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() }); + + // The hub splits — it and everything behind it are gone. + e.handle(NetEvent::ServerSplit { server: "0HB".into() }); + assert!(e.network.account_of("0HBAAAAAA").is_none(), "hub user forgotten"); + assert!(e.network.account_of("0LFAAAAAA").is_none(), "user on a leaf behind the hub forgotten"); + assert_eq!(e.network.account_of("0OKAAAAAA"), Some("carol"), "unrelated server's user kept"); + } + // 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]