//! SSH 连接池(P2 连接复用)。 //! //! # 语义 //! //! 同一「身份」(用户名 + 主机 + 端口 + 认证材料指纹)的多个会话 //! **共享同一条 SSH 连接**:第二个会话跳过 TCP / 握手 / 认证,直接在 //! 既有连接上开新的会话通道。与 OpenSSH ControlMaster 的行为一致: //! - 打开:首个会话建立连接; //! - 共享:后续会话引用计数 +1; //! - 关闭:会话关闭只减引用并关闭**自己的 shell 通道**; //! **最后一个引用释放时**才断开底层连接(含跳板机链)。 //! //! # 为什么 Handle 必须经由池共享 //! //! `russh::client::Handle` 不实现 `Clone`(内含 session actor 的接收端), //! 此前每个 `SshSession` 独占一个 Handle,无从复用。池条目持有 //! `Arc>>` 槽位,会话间共享同一 Arc; //! tokio Mutex(而非 std)是为了允许**连接建立期间跨 `.await` 持锁**—— //! 它天然串行化了「双击两个标签同时连同一主机」的竞态:后到者等待, //! 先到者成功后直接复用。 //! //! # 跳板机链的归属 //! //! 经跳板链建立的连接,其跳板 Handle 挂在**池条目**上而不是首建会话上: //! 否则首建会话关闭时会连带剪断仍在被其他会话使用的隧道。 //! 最后一个引用释放时,跳板与目标连接一起断开。 //! //! # 已知取舍 //! //! - 共享连接的 keepalive / 加密参数取自**首个**建立它的会话; //! - 共享连接断开(网络故障)时,挂在上面的所有会话一起进入 Closed—— //! 这与「它们本来就在同一条 TCP 上」的物理事实一致; //! - 远程转发(-R)的入站路由按端口做**全局**匹配(见 forward 模块), //! 因为入站通道总是从属连接级 Handler,而 Handler 的 session_id 属于首建会话。 use std::collections::HashMap; use std::sync::{Arc, Mutex}; use russh::client::Handle; use russh::Disconnect; use super::SshHandler; /// 可克隆的池句柄(`TerminalManager` 持有一份,每个会话 clone 一份)。 #[derive(Clone, Default)] pub struct ConnectionPool { conns: Arc>>, } struct PooledEntry { /// 共享槽位:`None` = 连接建立中(或失败);`Some` = 已就绪。 slot: Arc>>>, /// 仍在使用此连接的会话数 refcount: usize, /// 连接是否已就绪(同步可查;`has_handle` 用) ready: bool, /// 跳板机链的连接(经跳板建立时非空;随条目共享,最后释放时断开) hops: Vec>, /// 日志用描述 label: String, } impl ConnectionPool { /// 取(或创建)某身份的连接槽位,引用计数 +1。 /// /// 返回的 Arc 就是池条目里的槽位本身:会话把它存进 `SshInner.handle`, /// 连接建立后写 `Some(handle)`,同键的其他会话即刻可见。 pub fn slot(&self, key: &str) -> Arc>>> { let mut conns = self.conns.lock().unwrap_or_else(|e| e.into_inner()); let entry = conns.entry(key.to_string()).or_insert_with(|| PooledEntry { slot: Arc::new(tokio::sync::Mutex::new(None)), refcount: 0, ready: false, hops: Vec::new(), label: String::new(), }); entry.refcount += 1; entry.slot.clone() } /// 设置日志用描述(连接建立成功后调用)。 pub fn set_label(&self, key: &str, label: &str) { if let Some(e) = self .conns .lock() .unwrap_or_else(|x| x.into_inner()) .get_mut(key) { e.label = label.to_string(); } } /// 标记连接已就绪(do_connect 写入 Handle 之后)。 pub fn mark_ready(&self, key: &str) { if let Some(e) = self .conns .lock() .unwrap_or_else(|x| x.into_inner()) .get_mut(key) { e.ready = true; } } /// 把跳板机连接挂到池条目上(fresh 连接路径、有跳板时调用一次)。 pub fn attach_hops(&self, key: &str, hops: Vec>) { if let Some(e) = self .conns .lock() .unwrap_or_else(|x| x.into_inner()) .get_mut(key) { e.hops = hops; } } /// 连接是否已就绪(同步可查;替代原 `SshSession::has_handle` 的语义)。 pub fn is_ready(&self, key: &str) -> bool { self.conns .lock() .unwrap_or_else(|e| e.into_inner()) .get(key) .is_some_and(|e| e.ready) } /// 释放一个会话的引用。 /// /// 返回 `Some((槽位, 跳板连接))` 表示这是**最后一个**引用——调用方负责 /// 断开底层连接与跳板(异步任务里做,见 `kill`)。非最后引用返回 `None`, /// 调用方只需关闭自己的 shell 通道。 pub fn release( &self, key: &str, ) -> Option<( Arc>>>, Vec>, )> { let mut conns = self.conns.lock().unwrap_or_else(|e| e.into_inner()); let Some(entry) = conns.get_mut(key) else { return None; }; entry.refcount = entry.refcount.saturating_sub(1); if entry.refcount > 0 { return None; } // 最后一个引用:移除条目并交出断开责任 let entry = conns.remove(key)?; Some((entry.slot, entry.hops)) } } /// 连接池身份键:用户名 + 主机 + 端口 + 认证方式 + 认证材料指纹。 /// /// 认证材料(密码或私钥文本)取短哈希入键——同一主机配置两份不同密钥/密码时 /// 不应共享连接(那等于用 A 的身份看了 B 的会话)。 pub fn pool_key_of( username: &str, host: &str, port: u16, auth_method: &str, auth_material: Option<&str>, ) -> String { let marker = match auth_material { Some(m) => short_hash(m), None => "none".to_string(), }; format!("{username}|{host}:{port}|{auth_method}|{marker}") } /// 材料指纹:SHA-256 前 8 字节的十六进制(16 字符)。 fn short_hash(material: &str) -> String { use sha2::{Digest, Sha256}; let digest = Sha256::digest(material.as_bytes()); digest[..8].iter().map(|b| format!("{b:02x}")).collect() } /// 断开一个 Handle(kill 与最后引用释放共用的收尾动作)。 pub async fn disconnect(handle: Handle) { let _ = handle .disconnect(Disconnect::ByApplication, "closed by user", "") .await; } #[cfg(test)] mod tests { use super::*; /// 条目级语义测试:不涉及真实 Handle(槽位保持 None 即可)。 #[test] fn acquire_increments_and_release_removes_on_last() { let pool = ConnectionPool::default(); let s1 = pool.slot("k"); let s2 = pool.slot("k"); // 同键两次 acquire 返回同一个 Arc(这才是「共享」) assert!(Arc::ptr_eq(&s1, &s2)); assert!(pool.release("k").is_none(), "还有 1 个引用,不应触发拆除"); let (slot, hops) = pool.release("k").expect("最后一个引用应触发拆除"); assert!(Arc::ptr_eq(&slot, &s1)); assert!(hops.is_empty()); // 移除后再次 acquire 得到全新条目 let s3 = pool.slot("k"); assert!(!Arc::ptr_eq(&s3, &s1)); pool.release("k"); } #[test] fn independent_keys_are_independent() { let pool = ConnectionPool::default(); let a = pool.slot("a"); let b = pool.slot("b"); assert!(!Arc::ptr_eq(&a, &b)); assert!(pool.release("a").is_some()); assert!(pool.release("b").is_some()); } #[test] fn ready_flag_and_hops_follow_entry_lifecycle() { let pool = ConnectionPool::default(); assert!(!pool.is_ready("k")); pool.slot("k"); pool.slot("k"); // 两个会话共享 pool.mark_ready("k"); assert!(pool.is_ready("k")); pool.attach_hops("k", Vec::new()); // 释放一个引用后条目仍在(另一个会话还在用),ready 保持 assert!(pool.release("k").is_none()); assert!(pool.is_ready("k")); // 最后一个引用释放后条目消失 assert!(pool.release("k").is_some()); assert!(!pool.is_ready("k")); } #[test] fn over_release_is_safe() { let pool = ConnectionPool::default(); assert!(pool.release("ghost").is_none()); pool.slot("k"); pool.release("k"); // 多余的 release 不应 panic(saturating 语义) let _ = pool.release("k"); } #[test] fn pool_key_distinguishes_identity() { let k1 = pool_key_of("ops", "srv", 22, "key", Some("keytext")); let k2 = pool_key_of("ops", "srv", 22, "key", Some("other-key")); let k3 = pool_key_of("ops", "srv", 22, "key", Some("keytext")); let k4 = pool_key_of("root", "srv", 22, "key", Some("keytext")); assert_ne!(k1, k2, "不同认证材料不应共享连接"); assert_eq!(k1, k3, "相同身份应命中同一池条目"); assert_ne!(k1, k4, "不同用户不应共享连接"); // 无认证材料(理论上不出现)也不与他人混淆 let k5 = pool_key_of("ops", "srv", 22, "password", None); assert_ne!(k1, k5); } }