diff --git a/docs/prd-ssh-connection-manager.md b/docs/prd-ssh-connection-manager.md new file mode 100644 index 00000000..ccf251e6 --- /dev/null +++ b/docs/prd-ssh-connection-manager.md @@ -0,0 +1,398 @@ +# PRD · tty7 SSH 连接管理器 + +| 项 | 内容 | +|---|---| +| **文档状态** | Draft v2(评审后修订) | +| **对标** | Tabby (`tabby-ssh`, 1.0.216 起基于 russh) | +| **产品定位** | 从"能 SSH 的终端"升级为"内置一流连接管理器的终端" | +| **架构结论** | 默认路径全面切换到原生 SSH 库(**russh** + **russh-sftp**);现有 shell-out `ssh` 保留为 per-profile **"系统 ssh 兼容模式"** 逃生门(冻结,不再演进) | +| **一句话** | 功能对标 Tabby,交互沿用 tty7 自己的语汇(palette 优先、上下文面板、零 modal、渐进展开) | + +--- + +## 1. 背景与目标 + +### 1.1 现状 +tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/pane.rs` 的 `build_managed_ssh_command`,每 pane 一个 ControlMaster socket),只在其上加了一层 **loopback 端口自动转发**(`daemon/forward.rs` 走 `ssh -O forward`)。设计哲学是 "OpenSSH is the source of truth"。 + +**已有可复用的地基:** +- daemon 拥有全部 PTY,GUI 经 socket 镜像字节流(`terminal/remote.rs` 的 `RemoteTerminal`)——字节源已抽象,是 russh shell channel 的天然接缝 +- palette 已能实时发现 `~/.ssh/config` 的 Host alias(`core/ssh_config.rs`,仅发现不解析) +- pane 级滑入面板(`ui/forwards.rs`)、全窗口设置页、竖向 tab 侧栏均已存在 +- **完全没有的**:profile 存储、keychain/凭据、GUI 认证、SFTP、Remote/Dynamic 转发 + +**这套架构做不了连接管理器的核心功能:** +- 无带凭据的连接 profile、无凭据保险库 +- 无 SFTP / 文件浏览器 +- 认证 / host-key 全靠终端里 `ssh` 自己打印,无法做 GUI 托管 +- 端口转发仅 loopback,无 Remote / Dynamic / 预配置 + +### 1.2 目标(In Scope) +| 目标 | 说明 | +|---|---| +| **G1 连接 Profile + 凭据保险库** | 完整可编辑的连接配置,密码/passphrase 进 OS keychain | +| **G2 原生认证与 host-key 托管** | GUI 提示密码/passphrase/2FA,指纹确认与 known_hosts 管理 | +| **G3 内置 SFTP 文件面板** | 浏览 / 上传 / 下载 / 增删改 / chmod | +| **G4 端口转发补全** | Local / Remote / Dynamic + profile 预配置 + 运行时增删 | +| **G5 高级连接能力** | Jump host / 代理 / 算法 / keepalive / X11 / agent forwarding | +| **G6 保持 tty7 的克制 UX** | palette 优先、上下文面板、零常驻工具栏、渐进展开 | + +### 1.3 非目标(Out of Scope,v1) +- 跨设备云同步 / 团队共享(未来可加) +- 会话录制 / 审计日志 +- Telnet / Serial / 其它协议(Tabby 有,本期不做) +- Windows 为一等公民(v1 以 macOS 为主,架构保留跨平台可能) + +### 1.4 成功指标 +| 指标 | 目标 | +|---|---| +| Tabby SSH 功能覆盖率 | ≥ 90%(见 §4 对照表) | +| 新建并连接一个 profile 的操作步数 | ≤ 4 步(打开 palette → 输 host → 认证 → 连上) | +| 常用连接"零表单"直连 | palette 打字即连,无需进编辑页 | + +--- + +## 2. 用户与场景 + +### 2.1 目标用户 +- **开发/运维**:每天连十几到上百台机器,重度依赖 `~/.ssh/config`、agent、跳板。 +- **从 Termius / Tabby 迁移者**:要 profile 管理 + SFTP,不想回退到裸终端。 + +### 2.2 关键场景 +| # | 场景 | 期望体验 | +|---|---|---| +| S1 | 临时连一台机器 | palette 打 `user@host` 回车即连 | +| S2 | 连常用机器 | palette 模糊搜 profile 名,回车秒连(复用会话) | +| S3 | 首连需确认指纹 | pane 内滑出 sheet 显示指纹,回车信任 | +| S4 | 输密码并记住 | pane 内 sheet 输入,勾"记住"存 keychain | +| S5 | 传文件 | 热键呼出 SFTP 分栏,拖到 Finder | +| S6 | 建隧道访问远端服务 | 转发面板加一条,或点终端里的 `localhost:3000` | +| S7 | 走跳板连内网 | profile 选跳板,自动多跳 | +| S8 | 导入现有 ssh_config | 一键把 alias 导成 profile | + +--- + +## 3. 架构决策 + +### 3.1 默认路径采用 russh(原生 Rust SSH 库)+ 系统 ssh 兼容模式逃生门 +| 决策 | 理由 | +|---|---| +| **库 = russh + russh-sftp** | 纯 Rust + async,契合 tty7/gpui;Tabby 1.0.216 起用它跑通全部目标功能,是现成存在证明。SFTP client 不在 russh 本体,由 `russh-sftp` crate 提供 | +| **russh 为默认且唯一的管理器路径** | 连接管理器的 SFTP / GUI 认证 / 凭据保险库要求拥有协议栈;profile 连接默认全部走 russh | +| **保留"系统 ssh 兼容模式"逃生门** | per-profile 开关:勾选后该 profile 退回今天的 shell-out 行为(含 ControlMaster loopback 转发),**不提供** SFTP / GUI 认证 / 凭据保险库。前车之鉴:Tabby 切 russh 后爆出私钥加载失败(#10207)、ProxyCommand 缺 `%h`(#11058)、复杂 ssh_config 不兼容(#10188),社区强烈要求保留 OpenSSH 选项(#10162)而 Tabby 未提供——tty7 不重蹈覆辙 | +| **兼容模式 = 冻结** | 旧路径代码保留但不再演进,不接任何新功能;它只服务 russh 覆盖不了的场景(GSSAPI、PKCS#11 直连、复杂 Match/canonicalize config) | +| **russh 连接归 daemon 层** | daemon 已负责 pane / 远程会话生命周期;shell channel 字节流替换本地 PTY 数据源,**必须完整复用 daemon pane 语义**:`DaemonMsg::Output` 帧、8 MiB replay ring、reattach、`OutputGate` 背压(详见 FR-C4) | +| **一条已认证 client 复用** | shell + SFTP + 转发共用同一连接(内存级复用,替代 ControlMaster) | + +> russh 路径下,loopback 一键转发改由 russh `direct-tcpip` channel 实现;兼容模式沿用现有 `ssh -O forward` 机制不变。 + +### 3.2 russh 能力边界 +| OpenSSH 特性 | 方案 | +|---|---| +| 证书认证(OpenSSH cert) | russh 原生支持 ✅ | +| FIDO `sk-*` 硬件密钥 / PKCS#11 | 经 **ssh-agent** 走 agent 认证(签名由 agent 完成)✅ | +| GSSAPI / Kerberos | russh 不支持 → 不进 v1 管理器;需要的用户走**兼容模式** | +| 复杂 ssh_config(Match / canonicalize / 深度嵌套 ProxyCommand) | russh 路径只解析常见字段;超出部分走**兼容模式** | + +### 3.3 ssh_config 处理 +| 决策 | v1 做法 | +|---|---| +| **alias 实时发现(已有)** | 保留 `core/ssh_config.rs` 的实时 alias 发现,palette 里 config alias 始终与文件同步,不吃"导入快照过期"的亏 | +| **导入为 profile(可选)** | 解析常见字段(Host/HostName/User/Port/IdentityFile/ProxyJump)导成 profile,给想要凭据/SFTP/转发管理的条目用;导入是显式动作,可重复执行(按 alias 去重更新) | +| **运行时完整解析(Match/canonicalize)** | v1 不做;复杂 config 用户对该 profile 勾"系统 ssh 兼容模式"(§3.1) | + +### 3.4 known_hosts +- 读写 OpenSSH 格式;**解析并原样保留**所有行类型(含 hashed host、`@cert-authority`、`@revoked`),绝不破坏文件。 +- 信任判定 v1 覆盖:明文 host、hashed host、`@revoked`(命中即硬拒绝)。`@cert-authority`(host 证书校验)尽力而为,russh 不支持时按"未知主机"走确认流程,不误报"已变更"。 +- russh 回调交出 host key,存储与信任决策由 tty7 管理。 + +--- + +## 4. 竞品对标(Tabby 功能覆盖) + +图例:✅ 覆盖 · 🟢 覆盖并强化 · ⚪ v1 暂缓 + +| 领域 | 功能 | v1 | 备注 | +|---|---|---|---| +| **连接** | 直连 | ✅ | | +| | Jump host / 跳板 | ✅ | profile 选跳板,自动多跳 | +| | ProxyCommand | ✅ | | +| | SOCKS5 / HTTP 代理 | ✅ | | +| | 会话复用 | 🟢 | 一条 russh client 复用 shell+SFTP+转发 | +| | 系统 ssh 兼容模式 | 🟢 | per-profile 逃生门;Tabby 社区强烈要求(#10162)而未提供 | +| **认证** | 密码(可记住) | ✅ | GUI sheet + keychain | +| | 公钥(占位符 `%h/%r`) | ✅ | | +| | 加密私钥 passphrase(可记住) | ✅ | | +| | ssh-agent | ✅ | 复用 `SSH_AUTH_SOCK` | +| | keyboard-interactive / 2FA | ✅ | | +| | Auto 全试 | ✅ | | +| | Agent forwarding | ✅ | | +| | 证书认证 | ✅ | russh 原生 | +| | FIDO `sk-*` / PKCS#11 | ✅ | 经 ssh-agent | +| | GSSAPI / Kerberos | ⚪ | russh 不支持;兼容模式可覆盖 | +| **安全** | 指纹展示 | ✅ | | +| | 未知/变更 host key 确认 | 🟢 | 区分"新主机"与"已变更"大警告 | +| | known_hosts 管理界面 | ✅ | 应用内查看/删除 | +| | 可选关闭校验 | ✅ | | +| **Profile** | 保存的连接 profile | ✅ | 完整字段 | +| | 凭据保险库 | ✅ | OS keychain | +| | Profile 编辑界面 | 🟢 | 渐进展开,4 字段起步 | +| | QuickConnect(含 IPv6) | 🟢 | palette 统一入口 | +| | profile ↔ 连接串互转 | ✅ | | +| | ssh_config 导入 | ✅ | | +| | 分组 / 文件夹 | ✅ | 默认扁平 | +| **转发** | Local | ✅ | | +| | Remote | ✅ | | +| | Dynamic / SOCKS | ✅ | | +| | profile 预配置 | ✅ | | +| | 运行时增删面板 | ✅ | | +| | localhost 链接一键转发 | 🟢 | **tty7 独有招牌,保留** | +| **SFTP** | 文件面板(浏览/面包屑/过滤) | ✅ | | +| | 上传 / 下载(含目录递归) | ✅ | | +| | 新建/删除/重命名/chmod | ✅ | | +| | 跟随 cwd | ✅ | | +| | WinSCP 集成 | ⚪ | Windows 专属,v1 暂缓 | +| **会话** | 连接状态行内提示 | ✅ | ` SSH ` 彩条 | +| | Banner 显示 / 跳过 | ✅ | | +| | 关闭确认(warnOnClose) | ✅ | | +| | 断线重连(热键) | ✅ | | +| | 登录脚本 | ✅ | | +| | X11 转发 | ✅ | | +| **传输** | 算法配置(KEX/Cipher/MAC/HostKey/压缩) | ✅ | 高级折叠 | +| | Keepalive / 超时 | ✅ | | +| | 会话恢复 | ✅ | 复用现有 session-restore | + +--- + +## 5. 功能需求(详细) + +### 5.1 连接(P0) +- **FR-C1** 支持直连、Jump host(指向另一 profile,可多级)、ProxyCommand、SOCKS5、HTTP 代理五种传输。ProxyCommand 必须支持 `%h`/`%p` token(Tabby 缺失的已知痛点,#11058)。 +- **FR-C2** 会话复用:相同(host/port/user/proxy/jump)在内存复用同一已认证 client;新 tab 秒开、不重认证。**爆炸半径明确**:底层连接断开时,共享它的所有 pane 同时进入"已断开"态并提示重连;任一 pane 触发重连即重建连接,其余 pane 随之恢复。 +- **FR-C3** Keepalive 间隔、count max、连接超时可配置。 +- **FR-C4** shell channel 终端管道对齐(russh 路径的隐性 P0): + - `pty-req` 携带 TERM 与 terminal modes;pane resize → `window-change` 请求 + - channel `exit-status`/`exit-signal` → 映射为现有 `DaemonMsg::Exited` 语义 + - 字节流接入 daemon 现有管线:`DaemonMsg::Output` 帧、8 MiB replay ring(GUI 重启后 reattach 保留 scrollback)、`OutputGate` 背压(反压落到 channel window,不无限缓冲) + - OSC 7 / OSC 133 sniffer(`core/osc.rs`)在 russh 字节流上原样工作——字节流端到端透明是硬约束 +- **FR-C5** 系统 ssh 兼容模式:profile 高级区勾选后,该 profile 以今天的 shell-out 方式连接(含 ControlMaster loopback 转发),SFTP / GUI 认证 / 保险库功能置灰并注明原因。 + +### 5.2 认证(P0) +- **FR-A1** 认证方式:`自动 | 密码 | 公钥 | agent | keyboard-interactive`;默认"自动"按顺序全试。 +- **FR-A2** 公钥支持多把私钥、`%h`/`%r` 占位符;`.pub` 误配自动识别并跳过。 +- **FR-A3** 加密私钥弹 passphrase sheet,可"记住"(按 key 内容 hash 存 keychain)。 +- **FR-A4** ssh-agent:复用 `SSH_AUTH_SOCK`;支持 agent identity 与 agent forwarding。 +- **FR-A5** keyboard-interactive:面板逐项输入;password 类提示位可用已存密码自动填。 +- **FR-A6** 认证成功且勾"记住"→ 存 keychain。**仅当服务端明确拒绝已存密码**(password 方法用存储值尝试且被拒)→ 重新弹 sheet 预告"已存密码被拒绝",用户提交新密码后覆盖;网络错误、超时、其它方法失败等**不得**触发清除。 + +### 5.3 Host key / 安全(P0) +- **FR-S1** 连接时展示 key 算法 + SHA256 指纹。 +- **FR-S2** 未知主机 → 确认 sheet;已存但**指纹变更** → 红色大警告 sheet,明确"可能中间人",绝不自动接受。 +- **FR-S3** known_hosts 读写 OpenSSH 格式;设置页可查看/删除已信任 key。 +- **FR-S4** 可选关闭校验(per-profile + 全局)。 + +### 5.4 Profile 管理 + 凭据(P0) +- **FR-P1** Profile 存储完整字段(见 §7 数据模型),支持分组。 +- **FR-P2** 密码 / passphrase 存 OS keychain(macOS Keychain;Windows Credential Manager;Linux libsecret),配置文件只存引用不存明文。 +- **FR-P3** palette 为统一入口:同框展示保存的 profile + ssh_config alias + "现连"项,按 frecency 排序。 +- **FR-P4** QuickConnect 解析 `[ssh] user@host[:port] [flags]`,支持 IPv6 `[::1]:port`。 +- **FR-P5** profile → `user@host:port` 一键复制;`~/.ssh/config` 一键导入。 + +### 5.5 端口转发(P0/P1) +- **FR-F1**(P0)Local / Remote / Dynamic 三种类型,走 russh channel。 +- **FR-F2**(P0)profile 预配置一组转发,连上自动建立。 +- **FR-F3**(P0)运行时上下文面板增删转发,每条带 description。 +- **FR-F4**(P0)保留 loopback 链接一键转发(Cmd-click `localhost:PORT`)。 + +### 5.6 SFTP(P0) +- **FR-T1** pane 内滑出可调宽分栏;远端文件树 + 面包屑 + 过滤。 +- **FR-T2** 上传 / 下载支持单文件与目录递归;上传走 `.tty7-upload-*` 临时文件 + rename 收尾(优先 `posix-rename@openssh.com` 扩展,不可用则退回 SFTP rename)。 +- **FR-T3** 新建目录 / 删除 / 重命名 / chmod / 跟随符号链接。 +- **FR-T4** 可选定位到 shell 当前 cwd;机制 = 现有 OSC 7 cwd 跟踪(依赖远端 shell 有集成脚本,与今天一致);无 OSC 7 信号时该按钮置灰,不猜测。 +- **FR-T5** 与 Finder 双向拖拽;传输进度走底部小托盘,不阻塞。 + +### 5.7 会话体验(P1) +- **FR-E1** 连接 / 转发 / 错误行内 ` SSH ` 彩条提示。 +- **FR-E2** 竖向 tab 侧栏每 tab 显示状态点(连接中/已连/断开)。 +- **FR-E3** 关闭 SSH tab 前可选二次确认(per-profile 覆盖全局)。 +- **FR-E4** 断线提示 + `restart-ssh-session` 热键重连;重连成功后自动重建 profile 预配置转发与断线前的运行时转发。 +- **FR-E5** 登录脚本:连上后自动发送命令序列。 +- **FR-E6** Banner 显示,可 skip。 + +### 5.8 高级 / 传输(P1) +- **FR-X1** 算法配置(KEX/Cipher/MAC/HostKey/压缩),从 russh 枚举可选项,高级折叠区。 +- **FR-X2** X11 转发开关。 + +--- + +## 6. UX 设计规范 + +### 6.1 三原则 +1. **一个入口框,三种来源** —— 不分裂"快连栏 vs profile 列表"。 +2. **上下文面板 > modal** —— 认证 / SFTP / 转发都在 pane 内滑出,零独立 OS 窗口、零常驻工具栏。 +3. **渐进式展开** —— profile 编辑器 4 字段起步,其余折叠。 + +### 6.2 五个界面 + +**① 连接入口(palette)** +``` +⌘K ┃ prod │ + ┃ ⭐ prod-web deploy@10.0.0.5 ↵ 连接 │ + ┃ 🔧 prod-bastion (~/.ssh/config) 灰 │ + ┃ ↵ 连接到 "prod.example.com" │ +``` +`↵` 连接 · `⌘↵`/`→` 进编辑 · 按 frecency 置顶。 + +**② Profile 编辑(全窗口页,复用设置页范式)** +``` +名称 [ prod-web ] +Host [ 10.0.0.5 ] 端口 [ 22 ] +用户 [ deploy ] +认证 ( 自动 ▾ ) +▸ 跳板 (无) +▸ 端口转发 (2 条) +▸ 高级 算法 / keepalive / 代理 / X11 / 登录脚本 / 系统 ssh 兼容模式 +``` + +**③ 认证 & host-key(pane 内 sheet,键盘优先,Esc 取消)** +``` +┌ prod-web ──────────────────────────────┐ +│ 🔑 deploy@10.0.0.5 的密码 │ +│ [ •••••••••• ] ☐ 记住(keychain) │ +│ ⏎ 连接 esc 取消 │ +└──────────────────────────────────────────┘ + +┌ ⚠ 主机密钥已变更 —— 可能存在中间人 ─────┐ +│ 10.0.0.5 ED25519 │ +│ SHA256:aX3f… (旧 SHA256:9Kp…) │ +│ ⏎ 仍然信任 esc 中断 │ +└──────────────────────────────────────────┘ +``` + +**④ SFTP(pane 右侧滑入分栏)** +``` + shell 区域 │ 📁 /home/deploy ⤴ ⤵ ⟳ + │ ▸ src/ + │ deploy.sh 4.2K +``` + +**⑤ 端口转发(上下文面板)** —— 列出活动转发 + 加/删 + 类型选 L/R/D。 + +### 6.3 禁止照搬 Tabby 的四点 +- ❌ 庞大配置树 → 用渐进展开 +- ❌ app-modal 弹窗 → 用 pane 内 sheet +- ❌ 常驻多按钮工具栏 → 用热键 + pane 右键菜单 + palette +- ❌ 快连栏与 profile 列表分裂 → 统一进 palette + +--- + +## 7. 数据模型 + +### 7.1 SSH Profile(serde,持久化到配置) +```rust +struct SshProfile { + id: Uuid, + name: String, + group: Option, + + // 连接 + host: String, + port: u16, // 默认 22 + user: String, + jump_host: Option, // 指向另一 profile + proxy_command: Option, + socks_proxy: Option, + http_proxy: Option, + + // 认证 + auth: AuthMode, // Auto | Password | PublicKey | Agent | KeyboardInteractive + identity_files: Vec, // 支持 %h/%r + agent_forward: bool, + // 凭据只存引用,明文在 keychain + credential_ref: Option, + + // 转发 + forwards: Vec, // {type: L|R|D, bind, target, description} + + // 会话 + keepalive_interval_s: Option, + keepalive_count_max: Option, + connect_timeout_s: Option, + warn_on_close: Option, + skip_banner: bool, + login_scripts: Vec, + x11: bool, + + // 高级 + algorithms: Algorithms, // kex/cipher/mac/hostkey/compression 列表,空=默认 + verify_host_keys: Option, + use_system_ssh: bool, // 兼容模式:走 shell-out `ssh`,管理器功能置灰 +} +``` + +### 7.2 凭据存储 +- keychain 条目按**端点**而非 profile 键控:密码用 `tty7-ssh:@:`,私钥 passphrase 用 `tty7-ssh-key:`。 + - 理由:QuickConnect(无 profile)也能"记住";多个 profile 指向同一端点时共享凭据、改密码只改一处。 +- 配置文件永不落明文密码;`credential_ref` 只是指向 keychain 条目的引用。 + +--- + +## 8. 非功能需求 + +| 类别 | 要求 | +|---|---| +| **安全** | 凭据仅存 OS keychain;host key 变更强警告;known_hosts 遵循 OpenSSH 语义;不弱化默认算法 | +| **性能** | 复用连接开新 shell < 200ms;SFTP 目录列举流畅;转发/文件传输不阻塞 UI 线程 | +| **可靠性** | 断线自动清理转发监听与子会话;认证失败清除坏凭据 | +| **跨平台** | 架构不锁死 macOS;keychain 抽象层预留 Windows/Linux 后端 | +| **可迁移** | ssh_config 导入让存量用户平滑迁入 | + +--- + +## 9. 交付拆分(一步到位 = 一个大版本,内部并行工作流) + +> 目标是**一个完整的连接管理器版本**,而非分期上线半成品。下列为内部并行 workstream,非对外分期。 + +| Workstream | 内容 | 依赖 | +|---|---|---| +| **WS1 数据层** | Profile 模型 + keychain 抽象 + ssh_config 导入 | 无(可先行) | +| **WS2 russh 会话** | daemon 内 russh 连接 + shell channel → pane 字节流;含 FR-C4 全部管道对齐(pty-req/resize/exit-status/replay ring/背压/OSC 透传) | 无 | +| **WS3 认证/安全** | GUI sheet(密码/passphrase/2FA)+ host-key 确认 + known_hosts 管理 | WS2 | +| **WS4 转发** | L/R/D + 预配置 + 上下文面板 + 保留 loopback 魔法(russh 路径改走 direct-tcpip) | WS2 | +| **WS5 SFTP** | russh-sftp channel + 文件面板 + 传输 | WS2 | +| **WS6 UX 集成** | palette 统一入口 + profile 编辑页 + tab 状态点 | WS1/2/3 | +| **WS7 路径收口** | 默认路径切到 russh;shell-out 降级为兼容模式并**冻结**(去掉入口层对它的直接依赖,不删除);ControlMaster 相关代码仅由兼容模式引用 | WS2/4 | + +**发布门槛(全绿才 GA):** +1. §4 对照表 P0 全部 ✅ + S1–S8 场景走通 +2. 安全项(FR-S1~S4)通过评审 +3. FR-C4 管道对齐验收:russh pane 的 reattach / session-restore / OSC 133 命令标记 / 背压行为与本地 PTY pane 无差异 +4. **内部 dogfood ≥ 2 周**:日常连接全走 russh 路径(Tabby 切 russh 后的事故均为上线后才暴露,必须用真实机器群淌过一遍) +5. 兼容模式可用且冻结,默认路径不再触碰 shell-out 代码 + +--- + +## 10. 风险与对策 + +| 风险 | 影响 | 对策 | +|---|---|---| +| **russh 兼容性长尾**(Tabby 前车之鉴:#10188/#10207/#11058) | 部分用户切换后连不上 | 系统 ssh 兼容模式逃生门 + GA 前 ≥2 周 dogfood + 认证失败信息可诊断(展示服务端拒绝原因) | +| russh 特性缺口(FIDO/PKCS#11) | 部分密钥类型连不上 | 引导用户经 ssh-agent 认证(签名由 agent 完成);仍不行走兼容模式 | +| GSSAPI/Kerberos 用户 | 管理器路径无法连接 | 兼容模式覆盖,文档说明 | +| ssh_config 复杂语义(Match/多跳) | 导入不完整 | 实时 alias 发现保底;v1 只导入常见字段;复杂场景引导兼容模式 | +| known_hosts 格式细节 | 误报/漏报 host key | 只做明文/hashed/@revoked 的信任判定,@cert-authority 尽力而为;解析层绝不改写无关行;充分测试 | +| 连接复用爆炸半径 | 一条连接挂掉拖垮多个 pane | FR-C2 明确断开语义:所有共享 pane 同步提示,一键重连全部恢复 | +| X11 转发依赖 XQuartz(macOS) | 开了不生效,用户困惑 | 检测不到 X server 时提示安装 XQuartz,而非静默失败 | +| 凭据安全事故 | 严重 | 只走 keychain,代码评审 + 不落盘明文 + 不写日志 | +| 自己维护加密栈的 CVE 面 | 安全责任 | 跟随 russh 上游,建立依赖告警 | +| UX 变复杂,失去 tty7 克制感 | 产品走味 | 严守 §6 三原则,拒绝 Tabby 式堆砌 | + +--- + +## 11. 未来(Out of Scope,后续版本) +- 跨设备云同步 / 团队凭据共享 +- 会话录制与审计 +- Telnet / Serial 协议 +- Windows 一等公民 + WinSCP 集成 +- ssh_config 运行时完整解析(Match / canonicalization) +- GSSAPI / Kerberos 认证(取决于 russh 上游支持) diff --git a/docs/ssh-native-architecture.md b/docs/ssh-native-architecture.md index 364fac54..7b6490a5 100644 --- a/docs/ssh-native-architecture.md +++ b/docs/ssh-native-architecture.md @@ -169,9 +169,9 @@ brief §5); SFTP opens a session channel and drives the subsystem. | Seam | State in WS2 | Owner | |---|---|---| -| Port forwards (L/R/D) | `NativeSshSpec.forwards` carried only; `open_direct_tcpip` provided | WS4 | -| `RemoteContext.control_path` | always `None` for native — `forward.rs` (ssh `-O`) correctly rejects native panes | WS4 | -| X11 forwarding | `NativeSshSpec.x11` carried only; no X11 channels | WS4/WS5 | +| Port forwards (L/R/D) | **DONE (WS4)** — `daemon::ssh::forward` (`SshForwardRegistry`): Local/Dynamic TCP listeners + `open_direct_tcpip`, Remote via `tcpip_forward` + `RemoteForwardTable` in the handler; preconfigured forwards established post-auth in `run_session`; protocol `AddForward`/`RemoveForward`/`ListForwards` (client kinds 20–22) → `ForwardList` (daemon kind 20) | WS4 | +| `RemoteContext.control_path` | always `None` for native; native loopback (FR-F4) now goes through `SshManager::ensure_loopback_forward` (a Local `direct-tcpip`), server-side branch on `RemoteKind::NativeSsh` | WS4 | +| X11 forwarding | `NativeSshSpec.x11` carried only; **seam documented** in `daemon::ssh::handler` (P1, deferred — needs `request_x11` + `server_channel_open_x11` + `$DISPLAY` bridge) | WS4/WS5 | | SFTP | none; `open_session_channel` provided for the subsystem | WS5 | | Agent forwarding channels | `agent_forward` requests `auth-agent-req` on the shell channel; incoming agent-channel bridging to `SSH_AUTH_SOCK` not wired | WS4/WS5 | | Session restore respawn | `SessionPane::Leaf.ssh_spec` (secret-free) persisted; reconnection UX not built | WS6 | diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index d854a379..42e105e8 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -36,8 +36,8 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system} use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ - AuthResponse, DaemonMsg, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, SshSpec, - WinSize, + AuthResponse, DaemonMsg, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, + SshSpec, WinSize, }; use crate::daemon::shell_integration; @@ -786,6 +786,7 @@ impl DaemonPane { // Kick off the connection on the SSH engine's runtime. crate::daemon::ssh::SshManager::global().spawn_native_session( + id, spec, size, broker, @@ -1360,6 +1361,14 @@ impl DaemonPane { impl Drop for DaemonPane { fn drop(&mut self) { + // A native-SSH pane's managed forwards (WS4) are attributed to this pane; + // tear them down as the pane dies so listeners close and remote bindings are + // cancelled — the FR-C2 blast radius when a shared connection drops takes + // every pane through here. Detached, so it never blocks this connection + // thread. + if matches!(self.backend, PaneBackend::NativeSsh(_)) { + crate::daemon::ssh::SshManager::global().teardown_pane_forwards(self.id); + } // Hang up the byte source: SIGHUP → SIGKILL for a PTY child + its group, or // channel close for a native-SSH session — so the reader's `read()` can EOF. self.hangup(); diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index cfb3677f..fb1f64dc 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -283,7 +283,7 @@ pub struct LoopbackForward { pub local_port: u16, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LoopbackForwardId { pub pane_id: u64, pub target: String, @@ -519,6 +519,38 @@ pub struct SftpJobProgress { pub remote: String, } +/// Runtime status of a live managed forward, surfaced to the GUI per row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ForwardStatus { + /// The forward's listener (Local/Dynamic) or remote binding (Remote) is up. + Listening, + /// The forward failed to come up (bind conflict, remote request denied, …). + /// The string is a human-readable reason with no secrets. + Error(String), +} + +/// One established managed forward on a native-SSH pane's connection (WS4). This +/// is the runtime counterpart of a [`SshForwardRule`]: it carries a daemon-issued +/// `id` (used to remove it), the pane it is attributed to (for per-pane listing), +/// the *resolved* bind port (a `bind_port` of 0 resolves to the OS-assigned port), +/// and a live `status`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedForward { + pub id: u64, + pub pane_id: u64, + pub kind: SshForwardKind, + pub bind_host: String, + pub bind_port: u16, + #[serde(default)] + pub target_host: String, + #[serde(default)] + pub target_port: u16, + #[serde(default)] + pub description: Option, + pub status: ForwardStatus, +} + fn default_term() -> String { "xterm-256color".to_string() } @@ -728,7 +760,10 @@ pub enum AuthPromptKind { pub enum AuthResponse { Secret(String), Secrets(Vec), - HostKeyDecision { accept: bool, remember: bool }, + HostKeyDecision { + accept: bool, + remember: bool, + }, /// The user dismissed the prompt; the daemon fails the auth step cleanly. Cancelled, } @@ -834,6 +869,16 @@ pub enum ClientMsg { /// Poll the transfer jobs for a pane (the GUI polls while its tray is /// visible). Daemon replies with a `SftpTransferProgress` list. SftpTransferList { pane_id: u64 }, + /// Establish a new managed port-forward (Local/Remote/Dynamic) on the native-SSH + /// pane `pane_id`'s connection (WS4). Control-connection message; the daemon + /// replies with a `ForwardList` reflecting the pane's forwards after the add. + AddForward { pane_id: u64, rule: SshForwardRule }, + /// Tear down one managed forward by its daemon-issued id. Control-connection + /// message; the daemon replies with the pane's remaining `ForwardList`. + RemoveForward { pane_id: u64, forward_id: u64 }, + /// Ask for the managed forwards attributed to `pane_id`. Control-connection + /// message; the daemon replies with a `ForwardList`. + ListForwards { pane_id: u64 }, } /// Messages the daemon sends back to the GUI client. @@ -890,6 +935,9 @@ pub enum DaemonMsg { SftpTransferStarted { job_id: u64 }, /// Reply to `SftpTransferList` / `SftpTransferCancel`: progress snapshots. SftpTransferProgress(Vec), + /// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed + /// forwards currently attributed to the requested pane (WS4). + ForwardList(Vec), /// A request failed (e.g. `Attach` to an unknown/dead pane id). Error(String), } @@ -936,6 +984,13 @@ mod kind { pub const SFTP_TRANSFER_START: u8 = 32; pub const SFTP_TRANSFER_CANCEL: u8 = 33; pub const SFTP_TRANSFER_LIST: u8 = 34; + // (16–19 reserved: WS3 auth extensions.) + /// `AddForward` — establish a managed port-forward (WS4). + pub const ADD_FORWARD: u8 = 20; + /// `RemoveForward` — tear down one managed forward by id (WS4). + pub const REMOVE_FORWARD: u8 = 21; + /// `ListForwards` — list a pane's managed forwards (WS4). + pub const LIST_FORWARDS: u8 = 22; // Daemon -> client pub const SPAWNED: u8 = 1; @@ -961,6 +1016,9 @@ mod kind { pub const SFTP_OP_RESULT: u8 = 31; pub const SFTP_TRANSFER_STARTED: u8 = 32; pub const SFTP_TRANSFER_PROGRESS: u8 = 33; + // (15–19 reserved: WS3 auth extensions.) + /// `ForwardList` — reply to the WS4 managed-forward messages. + pub const FORWARD_LIST: u8 = 20; } /// Write one framed message: `[u32 LE len][u8 kind][payload]`. @@ -1102,6 +1160,16 @@ impl ClientMsg { ClientMsg::SftpTransferList { pane_id } => { write_frame(w, kind::SFTP_TRANSFER_LIST, &to_json(pane_id)?) } + ClientMsg::AddForward { pane_id, rule } => { + write_frame(w, kind::ADD_FORWARD, &to_json(&(pane_id, rule))?) + } + ClientMsg::RemoveForward { + pane_id, + forward_id, + } => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?), + ClientMsg::ListForwards { pane_id } => { + write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?) + } } } @@ -1163,6 +1231,20 @@ impl ClientMsg { kind::SFTP_TRANSFER_LIST => ClientMsg::SftpTransferList { pane_id: from_json(&payload)?, }, + kind::ADD_FORWARD => { + let (pane_id, rule) = from_json(&payload)?; + ClientMsg::AddForward { pane_id, rule } + } + kind::REMOVE_FORWARD => { + let (pane_id, forward_id) = from_json(&payload)?; + ClientMsg::RemoveForward { + pane_id, + forward_id, + } + } + kind::LIST_FORWARDS => ClientMsg::ListForwards { + pane_id: from_json(&payload)?, + }, other => { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1223,6 +1305,7 @@ impl DaemonMsg { DaemonMsg::SftpTransferProgress(jobs) => { write_frame(w, kind::SFTP_TRANSFER_PROGRESS, &to_json(jobs)?) } + DaemonMsg::ForwardList(list) => write_frame(w, kind::FORWARD_LIST, &to_json(list)?), DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?), } } @@ -1266,6 +1349,7 @@ impl DaemonMsg { job_id: from_json(&payload)?, }, kind::SFTP_TRANSFER_PROGRESS => DaemonMsg::SftpTransferProgress(from_json(&payload)?), + kind::FORWARD_LIST => DaemonMsg::ForwardList(from_json(&payload)?), kind::ERROR => DaemonMsg::Error(from_json(&payload)?), other => { return Err(io::Error::new( @@ -1469,6 +1553,33 @@ mod tests { }), ClientMsg::SftpTransferCancel { job_id: 9 }, ClientMsg::SftpTransferList { pane_id: 4 }, + ClientMsg::AddForward { + pane_id: 7, + rule: SshForwardRule { + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: 8080, + target_host: "10.0.0.5".into(), + target_port: 80, + description: Some("web".into()), + }, + }, + ClientMsg::AddForward { + pane_id: 7, + rule: SshForwardRule { + kind: SshForwardKind::Dynamic, + bind_host: "127.0.0.1".into(), + bind_port: 1080, + target_host: String::new(), + target_port: 0, + description: None, + }, + }, + ClientMsg::RemoveForward { + pane_id: 7, + forward_id: 3, + }, + ClientMsg::ListForwards { pane_id: 7 }, ]; let mut buf = Vec::new(); for m in &msgs { @@ -1644,6 +1755,30 @@ mod tests { local: "/local".into(), remote: "/remote".into(), }]), + DaemonMsg::ForwardList(vec![ + ManagedForward { + id: 1, + pane_id: 7, + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: 8080, + target_host: "10.0.0.5".into(), + target_port: 80, + description: Some("web".into()), + status: ForwardStatus::Listening, + }, + ManagedForward { + id: 2, + pane_id: 7, + kind: SshForwardKind::Remote, + bind_host: "0.0.0.0".into(), + bind_port: 9000, + target_host: "127.0.0.1".into(), + target_port: 3000, + description: None, + status: ForwardStatus::Error("bind refused".into()), + }, + ]), DaemonMsg::Error("nope".into()), ]; let mut buf = Vec::new(); @@ -1931,10 +2066,9 @@ mod tests { /// Missing optional fields decode via `#[serde(default)]` (forward compat). #[test] fn native_ssh_spec_tolerates_minimal_json() { - let spec: NativeSshSpec = serde_json::from_str( - r#"{"host":"h","port":22,"user":"u","auth_mode":"auto"}"#, - ) - .unwrap(); + let spec: NativeSshSpec = + serde_json::from_str(r#"{"host":"h","port":22,"user":"u","auth_mode":"auto"}"#) + .unwrap(); assert_eq!(spec.term, "xterm-256color"); // defaulted assert!(spec.verify_host_keys); // defaulted true assert_eq!(spec.password, None); diff --git a/src/daemon/server.rs b/src/daemon/server.rs index d748e810..dd4e1dca 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -24,7 +24,8 @@ use std::sync::mpsc::{self, Receiver}; use std::sync::{Arc, Mutex}; use crate::daemon::pane::DaemonPane; -use crate::daemon::protocol::{ClientMsg, DaemonMsg}; +use crate::daemon::protocol::{ClientMsg, DaemonMsg, RemoteKind}; +use crate::daemon::ssh::SshConnection; use crate::daemon::transport::{self, Stream}; /// Shared pane registry: id → pane, plus a monotonic id source. @@ -294,7 +295,8 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(p) => p, Err(e) => { let mut w = write_stream; - let _ = DaemonMsg::Error(format!("native ssh spawn failed: {e}")).encode(&mut w); + let _ = + DaemonMsg::Error(format!("native ssh spawn failed: {e}")).encode(&mut w); return Err(e); } }; @@ -361,12 +363,28 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .encode(&mut w)?; return Ok(()); }; - match crate::daemon::forward::ForwardManager::global().ensure( - req.pane_id, - &remote, - &req.remote_host, - req.remote_port, - ) { + // Native-SSH panes have no ControlMaster socket (FR-F4): create/reuse a + // Local `direct-tcpip` forward on the pane's russh connection instead, + // returning the same reply shape so the GUI's Cmd-click flow is unchanged. + let result = if remote.kind == RemoteKind::NativeSsh { + match pane.ssh_connection() { + Some(conn) => crate::daemon::ssh::SshManager::global() + .ensure_loopback_forward( + req.pane_id, + conn, + &remote.target, + &req.remote_host, + req.remote_port, + ) + .map_err(|e| e.to_string()), + None => Err("native ssh connection is not ready".to_string()), + } + } else { + crate::daemon::forward::ForwardManager::global() + .ensure(req.pane_id, &remote, &req.remote_host, req.remote_port) + .map_err(|e| e.to_string()) + }; + match result { Ok(forward) => DaemonMsg::LoopbackForward(forward).encode(&mut w)?, Err(e) => DaemonMsg::Error(format!("forward failed: {e}")).encode(&mut w)?, } @@ -375,15 +393,22 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { ClientMsg::ListLoopbackForwards => { let mut w = write_stream; - let list = crate::daemon::forward::ForwardManager::global().list(); + // The loopback panel shows both ControlMaster (compat-mode) and native + // russh loopback forwards. + let mut list = crate::daemon::forward::ForwardManager::global().list(); + list.extend(crate::daemon::ssh::SshManager::global().list_loopback_forwards()); DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; Ok(()) } ClientMsg::CloseLoopbackForward(id) => { let mut w = write_stream; - crate::daemon::forward::ForwardManager::global().close(&id); - let list = crate::daemon::forward::ForwardManager::global().list(); + // Try both backends; only one owns the id. + if !crate::daemon::forward::ForwardManager::global().close(&id) { + crate::daemon::ssh::SshManager::global().close_loopback_forward(&id); + } + let mut list = crate::daemon::forward::ForwardManager::global().list(); + list.extend(crate::daemon::ssh::SshManager::global().list_loopback_forwards()); DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; Ok(()) } @@ -419,6 +444,19 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::AddForward { pane_id, rule } => { + let mut w = write_stream; + match forward_pane_connection(®istry, pane_id) { + Ok(conn) => { + let list = + crate::daemon::ssh::SshManager::global().add_forward(pane_id, conn, &rule); + DaemonMsg::ForwardList(list).encode(&mut w)?; + } + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + ClientMsg::SftpOp { pane_id, op } => { let mut w = write_stream; match ssh_connection_for(®istry, pane_id) { @@ -461,6 +499,23 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::RemoveForward { + pane_id, + forward_id, + } => { + let mut w = write_stream; + let list = crate::daemon::ssh::SshManager::global().remove_forward(pane_id, forward_id); + DaemonMsg::ForwardList(list).encode(&mut w)?; + Ok(()) + } + + ClientMsg::ListForwards { pane_id } => { + let mut w = write_stream; + let list = crate::daemon::ssh::SshManager::global().list_forwards(pane_id); + DaemonMsg::ForwardList(list).encode(&mut w)?; + Ok(()) + } + // `Input` / `Resize` / `Detach` as an opening message are meaningless (no // pane is bound yet); ignore and close. other => { @@ -470,6 +525,20 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { } } +/// Resolve a pane to its live native-SSH connection for a managed-forward request, +/// or a human-readable reason it can't (wrong pane, PTY/compat pane, or a +/// still-authenticating / dropped connection). +fn forward_pane_connection( + registry: &Registry, + pane_id: u64, +) -> Result, String> { + let pane = registry + .get(pane_id) + .ok_or_else(|| format!("no such pane {pane_id}"))?; + pane.ssh_connection() + .ok_or_else(|| "pane is not a ready native-ssh session".to_string()) +} + /// `Attach` path: subscribe the connection to an existing pane (sending the /// recorded `Size` + `Snapshot` + known cwd/prompt), then stream. Splitting /// this out keeps the `Spawn` path (which mustn't re-snapshot before its diff --git a/src/daemon/ssh/forward.rs b/src/daemon/ssh/forward.rs new file mode 100644 index 00000000..ca53fc9e --- /dev/null +++ b/src/daemon/ssh/forward.rs @@ -0,0 +1,865 @@ +//! Port forwarding for native-SSH panes (Workstream 4). +//! +//! Three forward types ride the pane's shared [`SshConnection`] (never a control +//! socket — that path is the frozen ssh-binary ControlMaster mode in +//! `daemon::forward`): +//! +//! - **Local** (FR-F1): a TCP listener on `bind_host:bind_port`; each accepted +//! connection opens a `direct-tcpip` channel to `target_host:target_port` on the +//! connection and [`bridge`]s the two with exact EOF/close propagation. +//! - **Dynamic / SOCKS5** (FR-F1): a local listener speaking a minimal, hand-rolled +//! SOCKS5 (no-auth greeting, CONNECT for IPv4/IPv6/domain; BIND/UDP rejected). +//! Each request opens a `direct-tcpip` to the negotiated target and bridges. +//! - **Remote** (FR-F1): a `tcpip-forward` global request on the connection; +//! incoming `forwarded-tcpip` channels (via the [`super::handler::ClientHandler`]) +//! are matched against [`RemoteForwardTable`] and bridged to a fresh local TCP +//! connection to the registered target. Unmatched channels are rejected. +//! +//! **Registry keying & blast radius.** [`SshForwardRegistry`] keys active forwards +//! by `pane_id` (so the UI lists them per pane) but each forward task holds an +//! `Arc`, so a forward keeps the shared connection alive exactly +//! like `ssh -N`. When a pane dies the daemon calls +//! [`SshForwardRegistry::teardown_pane`], which aborts its listener tasks and +//! cancels its remote bindings; dropping the last `Arc` then tears the connection +//! down. When the *transport* drops, every pane sharing the connection dies as a +//! unit (FR-C2), so every forward attributed to those panes is torn down together. + +use std::collections::HashMap; +use std::io; +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Instant; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::AbortHandle; + +use crate::daemon::protocol::{ + ForwardStatus, LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, + SshForwardKind, SshForwardRule, +}; + +use super::session::SshConnection; + +// --------------------------------------------------------------------------- +// Bidirectional socket<->channel bridge (Tabby brief §5). +// --------------------------------------------------------------------------- + +/// Bridge two duplex streams, propagating EOF and close in both directions: when +/// one side's read half hits EOF, the other side's write half is shut down (a +/// half-close), and once both directions have closed the bridge returns. This +/// mirrors Tabby's `setupSocketChannelEvents` (channel.eof→socket.end, +/// socket.end→channel.eof, close→destroy) so neither a socket nor a russh channel +/// is left half-open. +pub(super) async fn bridge(a: A, b: B) -> io::Result<()> +where + A: AsyncRead + AsyncWrite + Unpin, + B: AsyncRead + AsyncWrite + Unpin, +{ + let (mut ar, mut aw) = tokio::io::split(a); + let (mut br, mut bw) = tokio::io::split(b); + + let a_to_b = async { + tokio::io::copy(&mut ar, &mut bw).await?; + // Source EOF'd: signal it downstream so the peer sees a clean close + // rather than a stall. + bw.shutdown().await + }; + let b_to_a = async { + tokio::io::copy(&mut br, &mut aw).await?; + aw.shutdown().await + }; + + // Run both directions until each has hit EOF (or one errors). `try_join` + // surfaces the first error and drops the other future, which closes its + // half — the connection cannot be left half-open. + tokio::try_join!(a_to_b, b_to_a)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Minimal SOCKS5 (RFC 1928) for Dynamic forwards. +// --------------------------------------------------------------------------- + +/// Negotiate a SOCKS5 CONNECT request on `s`: read the (no-auth) greeting, reply +/// with the no-auth method, read the CONNECT request, and return the requested +/// `(host, port)`. Rejects SOCKS4 (version byte `0x04`), any command other than +/// CONNECT (so BIND/UDP-ASSOCIATE are refused), and unknown address types. The +/// caller opens the upstream channel and then writes the final reply with +/// [`socks5_reply`]. +pub(super) async fn socks5_negotiate(s: &mut S) -> io::Result<(String, u16)> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + // Greeting: VER, NMETHODS, METHODS... + let mut head = [0u8; 2]; + s.read_exact(&mut head).await?; + if head[0] != 0x05 { + // A SOCKS4 client sends 0x04 here; anything but 0x05 is unsupported. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported SOCKS version (only SOCKS5 is accepted)", + )); + } + let nmethods = head[1] as usize; + let mut methods = vec![0u8; nmethods]; + s.read_exact(&mut methods).await?; + if !methods.contains(&0x00) { + // No acceptable methods (0xFF) — we only implement no-auth. + let _ = s.write_all(&[0x05, 0xFF]).await; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 client offered no no-auth method", + )); + } + s.write_all(&[0x05, 0x00]).await?; + + // Request: VER, CMD, RSV, ATYP, ADDR, PORT. + let mut req = [0u8; 4]; + s.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 request had wrong version", + )); + } + if req[1] != 0x01 { + // Only CONNECT (0x01); reject BIND (0x02) / UDP-ASSOCIATE (0x03). + socks5_reply(s, 0x07).await?; // command not supported + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 command not supported (only CONNECT)", + )); + } + let host = match req[3] { + 0x01 => { + let mut a = [0u8; 4]; + s.read_exact(&mut a).await?; + Ipv4Addr::from(a).to_string() + } + 0x04 => { + let mut a = [0u8; 16]; + s.read_exact(&mut a).await?; + std::net::Ipv6Addr::from(a).to_string() + } + 0x03 => { + let mut len = [0u8; 1]; + s.read_exact(&mut len).await?; + let mut name = vec![0u8; len[0] as usize]; + s.read_exact(&mut name).await?; + String::from_utf8(name).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "SOCKS5 domain not UTF-8") + })? + } + other => { + socks5_reply(s, 0x08).await?; // address type not supported + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("SOCKS5 unsupported address type {other}"), + )); + } + }; + let mut port = [0u8; 2]; + s.read_exact(&mut port).await?; + Ok((host, u16::from_be_bytes(port))) +} + +/// Write a SOCKS5 reply with reply code `rep` (0x00 = success), a fixed +/// `0.0.0.0:0` bound address (clients ignore it for CONNECT). +pub(super) async fn socks5_reply(s: &mut S, rep: u8) -> io::Result<()> +where + S: AsyncWrite + Unpin, +{ + s.write_all(&[0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await +} + +// --------------------------------------------------------------------------- +// Remote-forward table (consulted by the connection's Handler). +// --------------------------------------------------------------------------- + +/// The set of `tcpip-forward` bindings registered on one connection, mapping a +/// remote bind address/port to the local target to connect incoming +/// `forwarded-tcpip` channels to. Shared (cheaply cloned `Arc`) between the +/// [`SshConnection`] and its [`super::handler::ClientHandler`]; a reused +/// connection keeps its bindings across panes. +#[derive(Clone, Default)] +pub struct RemoteForwardTable { + inner: Arc>>, +} + +impl RemoteForwardTable { + pub(super) fn register( + &self, + bind_host: &str, + bind_port: u16, + target_host: &str, + target_port: u16, + ) { + self.inner.lock().unwrap().insert( + (bind_host.to_string(), bind_port), + (target_host.to_string(), target_port), + ); + } + + pub(super) fn unregister(&self, bind_host: &str, bind_port: u16) { + self.inner + .lock() + .unwrap() + .remove(&(bind_host.to_string(), bind_port)); + } + + /// Move a binding to a new (server-assigned) port when the client requested + /// port 0. + pub(super) fn rekey(&self, bind_host: &str, from_port: u16, to_port: u16) { + let mut map = self.inner.lock().unwrap(); + if let Some(target) = map.remove(&(bind_host.to_string(), from_port)) { + map.insert((bind_host.to_string(), to_port), target); + } + } + + /// Resolve an incoming `forwarded-tcpip` channel's connected address/port to a + /// local target. Tries the exact `(address, port)` first, then any binding on + /// the same port (the server may report `127.0.0.1` for a `localhost` bind, or + /// `0.0.0.0` for an empty bind address). + pub(super) fn lookup( + &self, + connected_address: &str, + connected_port: u16, + ) -> Option<(String, u16)> { + let map = self.inner.lock().unwrap(); + if let Some(t) = map.get(&(connected_address.to_string(), connected_port)) { + return Some(t.clone()); + } + map.iter() + .find(|((_, p), _)| *p == connected_port) + .map(|(_, t)| t.clone()) + } +} + +// --------------------------------------------------------------------------- +// Managed-forward registry. +// --------------------------------------------------------------------------- + +/// A live forward's teardown handle. +enum ForwardCancel { + /// A Local/Dynamic accept loop; aborting it drops the `TcpListener`. + Task(AbortHandle), + /// A Remote binding to cancel via `cancel_tcpip_forward` on teardown. + Remote { + conn: Weak, + bind_host: String, + bind_port: u16, + }, + /// The forward never came up (bind/request failed); nothing to cancel. + None, +} + +struct ForwardEntry { + id: u64, + kind: SshForwardKind, + bind_host: String, + bind_port: u16, + target_host: String, + target_port: u16, + description: Option, + status: ForwardStatus, + cancel: ForwardCancel, +} + +impl ForwardEntry { + fn to_managed(&self, pane_id: u64) -> ManagedForward { + ManagedForward { + id: self.id, + pane_id, + kind: self.kind, + bind_host: self.bind_host.clone(), + bind_port: self.bind_port, + target_host: self.target_host.clone(), + target_port: self.target_port, + description: self.description.clone(), + status: self.status.clone(), + } + } +} + +/// A native-loopback ("Cmd-click a `localhost:PORT` link") forward on a native-SSH +/// pane (FR-F4). Kept separately from managed forwards so it surfaces in the GUI's +/// existing loopback list alongside the ControlMaster ones, with the same +/// `LoopbackForwardInfo` shape. +struct LoopbackEntry { + local_port: u16, + created_at: Instant, + last_used: Instant, + cancel: AbortHandle, +} + +/// The per-process registry of managed forwards, owned by [`super::SshManager`]. +#[derive(Default)] +pub struct SshForwardRegistry { + panes: Mutex>>, + loopback: Mutex>, + next_id: AtomicU64, +} + +impl SshForwardRegistry { + /// Establish a managed forward for `rule` on `conn`, attribute it to `pane_id`, + /// and return the resulting [`ManagedForward`] (with a resolved bind port and a + /// live status). Failures are reported as `ForwardStatus::Error`, never a hard + /// error — a preconfigured forward that fails must not kill the session. + pub async fn establish( + &self, + pane_id: u64, + conn: Arc, + rule: &SshForwardRule, + ) -> ManagedForward { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (bind_port, status, cancel) = match rule.kind { + SshForwardKind::Local => self.start_local(&conn, rule).await, + SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await, + SshForwardKind::Remote => self.start_remote(&conn, rule).await, + }; + let entry = ForwardEntry { + id, + kind: rule.kind, + bind_host: rule.bind_host.clone(), + bind_port, + target_host: rule.target_host.clone(), + target_port: rule.target_port, + description: rule.description.clone(), + status, + cancel, + }; + let managed = entry.to_managed(pane_id); + self.panes + .lock() + .unwrap() + .entry(pane_id) + .or_default() + .push(entry); + managed + } + + /// The managed forwards attributed to `pane_id`, sorted by id (creation order). + pub fn list(&self, pane_id: u64) -> Vec { + let panes = self.panes.lock().unwrap(); + let mut list: Vec<_> = panes + .get(&pane_id) + .into_iter() + .flatten() + .map(|e| e.to_managed(pane_id)) + .collect(); + list.sort_by_key(|m| m.id); + list + } + + /// Remove one managed forward by id from `pane_id`, tearing down its listener + /// or remote binding. Returns the pane's remaining forwards. + pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec { + let removed = { + let mut panes = self.panes.lock().unwrap(); + if let Some(entries) = panes.get_mut(&pane_id) { + if let Some(pos) = entries.iter().position(|e| e.id == forward_id) { + Some(entries.remove(pos)) + } else { + None + } + } else { + None + } + }; + if let Some(entry) = removed { + Self::cancel_entry(entry).await; + } + self.list(pane_id) + } + + /// Tear down every forward attributed to `pane_id` (called when the pane dies — + /// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are + /// aborted synchronously; remote bindings are cancelled best-effort. + pub async fn teardown_pane(&self, pane_id: u64) { + let entries = self.panes.lock().unwrap().remove(&pane_id); + for entry in entries.into_iter().flatten() { + Self::cancel_entry(entry).await; + } + // Also drop any native-loopback forwards belonging to this pane. + let loopback_ids: Vec = { + let map = self.loopback.lock().unwrap(); + map.keys() + .filter(|k| k.pane_id == pane_id) + .cloned() + .collect() + }; + for id in loopback_ids { + if let Some(entry) = self.loopback.lock().unwrap().remove(&id) { + entry.cancel.abort(); + } + } + } + + async fn cancel_entry(entry: ForwardEntry) { + match entry.cancel { + ForwardCancel::Task(handle) => handle.abort(), + ForwardCancel::Remote { + conn, + bind_host, + bind_port, + } => { + if let Some(conn) = conn.upgrade() { + conn.cancel_remote_forward(&bind_host, bind_port).await; + } + } + ForwardCancel::None => {} + } + } + + async fn start_local( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + let listener = match TcpListener::bind((rule.bind_host.as_str(), rule.bind_port)).await { + Ok(l) => l, + Err(e) => { + return ( + rule.bind_port, + ForwardStatus::Error(format!( + "bind {}:{} failed: {e}", + rule.bind_host, rule.bind_port + )), + ForwardCancel::None, + ); + } + }; + let bound = listener + .local_addr() + .map(|a| a.port()) + .unwrap_or(rule.bind_port); + let conn = conn.clone(); + let target_host = rule.target_host.clone(); + let target_port = rule.target_port; + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + let target_host = target_host.clone(); + tokio::spawn(async move { + match conn.open_direct_tcpip(&target_host, target_port).await { + Ok(channel) => { + let _ = bridge(sock, channel.into_stream()).await; + } + // Remote refused (or the connection died): drop the client + // socket. No secrets in the log. + Err(e) => { + log::info!("local forward to {target_host}:{target_port} rejected: {e}") + } + } + }); + } + }); + ( + bound, + ForwardStatus::Listening, + ForwardCancel::Task(handle.abort_handle()), + ) + } + + async fn start_dynamic( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + let listener = match TcpListener::bind((rule.bind_host.as_str(), rule.bind_port)).await { + Ok(l) => l, + Err(e) => { + return ( + rule.bind_port, + ForwardStatus::Error(format!( + "bind {}:{} failed: {e}", + rule.bind_host, rule.bind_port + )), + ForwardCancel::None, + ); + } + }; + let bound = listener + .local_addr() + .map(|a| a.port()) + .unwrap_or(rule.bind_port); + let conn = conn.clone(); + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + tokio::spawn(async move { + let mut sock = sock; + let (host, port) = match socks5_negotiate(&mut sock).await { + Ok(t) => t, + Err(e) => { + log::info!("dynamic forward: SOCKS5 negotiation failed: {e}"); + return; + } + }; + match conn.open_direct_tcpip(&host, port).await { + Ok(channel) => { + if socks5_reply(&mut sock, 0x00).await.is_err() { + return; + } + let _ = bridge(sock, channel.into_stream()).await; + } + Err(e) => { + // 0x05 = connection refused by destination host. + let _ = socks5_reply(&mut sock, 0x05).await; + log::info!("dynamic forward to {host}:{port} rejected: {e}"); + } + } + }); + } + }); + ( + bound, + ForwardStatus::Listening, + ForwardCancel::Task(handle.abort_handle()), + ) + } + + async fn start_remote( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + match conn + .add_remote_forward( + &rule.bind_host, + rule.bind_port, + &rule.target_host, + rule.target_port, + ) + .await + { + Ok(bound) => ( + bound, + ForwardStatus::Listening, + ForwardCancel::Remote { + conn: Arc::downgrade(conn), + bind_host: rule.bind_host.clone(), + bind_port: bound, + }, + ), + Err(e) => ( + rule.bind_port, + ForwardStatus::Error(format!("remote forward request denied: {e}")), + ForwardCancel::None, + ), + } + } + + // ---- Native loopback (FR-F4) -------------------------------------------- + + /// Ensure a native-SSH loopback forward `127.0.0.1: → host:port` + /// exists for `pane_id`, reusing an existing one for the same target. Mirrors + /// the ControlMaster `ForwardManager::ensure` reply shape so the GUI's + /// Cmd-click flow is unchanged. + pub async fn ensure_loopback( + &self, + pane_id: u64, + conn: Arc, + target: &str, + remote_host: &str, + remote_port: u16, + ) -> io::Result { + let id = LoopbackForwardId { + pane_id, + target: target.to_string(), + remote_host: remote_host.to_string(), + remote_port, + }; + if let Some(entry) = self.loopback.lock().unwrap().get_mut(&id) { + entry.last_used = Instant::now(); + return Ok(LoopbackForward { + local_port: entry.local_port, + }); + } + // Bind an ephemeral loopback listener and forward it to the remote target. + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local_port = listener.local_addr()?.port(); + let remote_host_owned = remote_host.to_string(); + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + let remote_host = remote_host_owned.clone(); + tokio::spawn(async move { + match conn.open_direct_tcpip(&remote_host, remote_port).await { + Ok(channel) => { + let _ = bridge(sock, channel.into_stream()).await; + } + Err(e) => log::info!( + "loopback forward to {remote_host}:{remote_port} rejected: {e}" + ), + } + }); + } + }); + self.loopback.lock().unwrap().insert( + id, + LoopbackEntry { + local_port, + created_at: Instant::now(), + last_used: Instant::now(), + cancel: handle.abort_handle(), + }, + ); + Ok(LoopbackForward { local_port }) + } + + /// The active native-loopback forwards, in the `LoopbackForwardInfo` shape the + /// GUI's loopback panel already renders. + pub fn list_loopback(&self) -> Vec { + let map = self.loopback.lock().unwrap(); + let mut list: Vec<_> = map + .iter() + .map(|(id, entry)| LoopbackForwardInfo { + id: id.clone(), + local_port: entry.local_port, + age_secs: entry.created_at.elapsed().as_secs(), + idle_secs: entry.last_used.elapsed().as_secs(), + }) + .collect(); + list.sort_by(|a, b| { + a.id.target + .cmp(&b.id.target) + .then_with(|| a.id.remote_host.cmp(&b.id.remote_host)) + .then_with(|| a.id.remote_port.cmp(&b.id.remote_port)) + .then_with(|| a.local_port.cmp(&b.local_port)) + }); + list + } + + /// Close one native-loopback forward. Returns whether it existed. + pub fn close_loopback(&self, id: &LoopbackForwardId) -> bool { + if let Some(entry) = self.loopback.lock().unwrap().remove(id) { + entry.cancel.abort(); + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// A SOCKS4 client (version byte `0x04`) is rejected outright. + #[tokio::test] + async fn socks5_rejects_v4() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x04, 0x01]).await.unwrap(); + let err = socks5_negotiate(&mut server).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + /// A well-formed v5 CONNECT to an IPv4 address is parsed and the method reply is + /// the no-auth selection. + #[tokio::test] + async fn socks5_v5_connect_ipv4() { + let (mut client, mut server) = tokio::io::duplex(64); + // Greeting (1 method: no-auth) + CONNECT to 1.2.3.4:80. + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + client + .write_all(&[0x05, 0x01, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50]) + .await + .unwrap(); + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "1.2.3.4"); + assert_eq!(port, 80); + // Method-selection reply is VER=5, METHOD=0 (no auth). + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 CONNECT with a domain-name address (ATYP=3). + #[tokio::test] + async fn socks5_v5_connect_domain() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let host = b"example.com"; + let mut req = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8]; + req.extend_from_slice(host); + req.extend_from_slice(&443u16.to_be_bytes()); + client.write_all(&req).await.unwrap(); + // Negotiate before draining the reply: on a single-threaded test runtime + // the writer must run first, or the reply read would deadlock. + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 CONNECT with an IPv6 address (ATYP=4). + #[tokio::test] + async fn socks5_v5_connect_ipv6() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut req = vec![0x05, 0x01, 0x00, 0x04]; + req.extend_from_slice(&std::net::Ipv6Addr::LOCALHOST.octets()); + req.extend_from_slice(&22u16.to_be_bytes()); + client.write_all(&req).await.unwrap(); + // Negotiate before draining the reply (see the domain test). + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "::1"); + assert_eq!(port, 22); + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 BIND command (0x02) is rejected with a "command not supported" reply. + #[tokio::test] + async fn socks5_rejects_bind_command() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + client + .write_all(&[0x05, 0x02, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50]) + .await + .unwrap(); + let err = socks5_negotiate(&mut server).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + // Method reply then a 0x07 (command not supported) reply. + let mut method = [0u8; 2]; + client.read_exact(&mut method).await.unwrap(); + assert_eq!(method, [0x05, 0x00]); + let mut rep = [0u8; 10]; + client.read_exact(&mut rep).await.unwrap(); + assert_eq!(rep[1], 0x07); + } + + /// The bridge forwards bytes A→B and propagates the A-side EOF as a clean close + /// on the B side (and streams a reply back B→A). + #[tokio::test] + async fn bridge_propagates_data_and_eof_both_directions() { + // client_a <-> a ...bridge... b <-> server_b + let (mut client_a, a) = tokio::io::duplex(64); + let (b, mut server_b) = tokio::io::duplex(64); + let bridged = tokio::spawn(async move { bridge(a, b).await }); + + // A→B data, then close A's write half. + client_a.write_all(b"ping").await.unwrap(); + client_a.shutdown().await.unwrap(); + + let mut got = Vec::new(); + server_b.read_to_end(&mut got).await.unwrap(); + assert_eq!( + got, b"ping", + "A→B data delivered and A-side EOF closed B read" + ); + + // B→A reply after the far side EOF'd — must still flow, then close. + server_b.write_all(b"pong").await.unwrap(); + server_b.shutdown().await.unwrap(); + let mut back = Vec::new(); + client_a.read_to_end(&mut back).await.unwrap(); + assert_eq!( + back, b"pong", + "B→A reply delivered and B-side EOF closed A read" + ); + + bridged.await.unwrap().unwrap(); + } + + /// The remote-forward table resolves exact matches and falls back to any binding + /// on the same port (server may report a different bind address). + #[test] + fn remote_forward_table_lookup() { + let table = RemoteForwardTable::default(); + table.register("localhost", 9000, "127.0.0.1", 3000); + assert_eq!( + table.lookup("localhost", 9000), + Some(("127.0.0.1".to_string(), 3000)) + ); + // The server reported 127.0.0.1 for a localhost bind → port fallback. + assert_eq!( + table.lookup("127.0.0.1", 9000), + Some(("127.0.0.1".to_string(), 3000)) + ); + assert_eq!(table.lookup("localhost", 9999), None); + table.unregister("localhost", 9000); + assert_eq!(table.lookup("localhost", 9000), None); + } + + /// The registry's add/list/remove/teardown bookkeeping, independent of a live + /// connection (entries are inserted directly, bypassing `establish` which needs + /// an authenticated `SshConnection`). Aborting the cancel task on remove/teardown + /// is what a real listener teardown does. + #[tokio::test] + async fn registry_add_list_remove_teardown_bookkeeping() { + let reg = SshForwardRegistry::default(); + let make = |id: u64, port: u16| { + let task = tokio::spawn(async { std::future::pending::<()>().await }); + ForwardEntry { + id, + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: port, + target_host: "h".into(), + target_port: 80, + description: None, + status: ForwardStatus::Listening, + cancel: ForwardCancel::Task(task.abort_handle()), + } + }; + { + let mut panes = reg.panes.lock().unwrap(); + let entries = panes.entry(7).or_default(); + entries.push(make(0, 8000)); + entries.push(make(1, 8001)); + } + // list is per-pane and sorted by id. + let list = reg.list(7); + assert_eq!(list.iter().map(|m| m.id).collect::>(), vec![0, 1]); + assert!(reg.list(99).is_empty(), "other panes see nothing"); + + // remove drops just the one forward and returns the remainder. + let remaining = reg.remove(7, 0).await; + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, 1); + + // teardown clears the pane entirely (blast-radius on death). + reg.teardown_pane(7).await; + assert!(reg.list(7).is_empty()); + } + + /// `rekey` moves a binding to the server-assigned port (bind_port 0 case). + #[test] + fn remote_forward_table_rekey() { + let table = RemoteForwardTable::default(); + table.register("", 0, "127.0.0.1", 3000); + table.rekey("", 0, 40000); + assert_eq!( + table.lookup("", 40000), + Some(("127.0.0.1".to_string(), 3000)) + ); + assert_eq!(table.lookup("", 0), None); + } +} diff --git a/src/daemon/ssh/handler.rs b/src/daemon/ssh/handler.rs index 4735d9a3..76dd906f 100644 --- a/src/daemon/ssh/handler.rs +++ b/src/daemon/ssh/handler.rs @@ -1,18 +1,35 @@ -//! The russh client [`Handler`]: host-key verification and auth banners. +//! The russh client [`Handler`]: host-key verification, auth banners, and +//! incoming forwarded channels. //! //! russh invokes `check_server_key` during the handshake (once per connection — //! reused connections never re-run it) and `auth_banner` if the server sends one. //! Both route through the [`PromptBroker`] so the *GUI* makes the trust decision //! and sees the banner; the daemon owns the `known_hosts` storage per PRD §3.4. +//! +//! `server_channel_open_forwarded_tcpip` implements the Remote-forward +//! (`tcpip-forward`) receive side (WS4): incoming channels are matched against the +//! connection's [`RemoteForwardTable`] and bridged to a local socket. +//! +//! **X11 seam (P1, FR-X2 — deferred).** WS2 carries `NativeSshSpec.x11` but never +//! requests `x11-req` on the shell channel, so no X11 channels arrive and the +//! default `server_channel_open_x11` (auto-reject on drop) is correct. Wiring X11 +//! would add: `channel.request_x11(..)` at shell start (with a MIT-MAGIC-COOKIE-1 +//! cookie), a `server_channel_open_x11` override here that resolves the local +//! display (`$DISPLAY` → `/tmp/.X11-unix/X` unix socket or `localhost:6000+n`), +//! and `forward::bridge` to that socket — mirroring the forwarded-tcpip path below. +//! Left unimplemented deliberately (macOS needs XQuartz; low priority). use std::sync::Arc; -use russh::client::Session; +use russh::Channel; +use russh::client::{ChannelOpenHandle, Msg, Session}; use russh::keys::PublicKey; +use tokio::net::TcpStream; use crate::daemon::protocol::{AuthPromptKind, AuthResponse}; use super::broker::PromptBroker; +use super::forward::{self, RemoteForwardTable}; use super::known_hosts::{self, HostKeyStatus}; pub struct ClientHandler { @@ -21,6 +38,10 @@ pub struct ClientHandler { pub verify_host_keys: bool, pub skip_banner: bool, pub broker: Arc, + /// The connection's Remote-forward bindings (WS4). Shared with its + /// [`super::session::SshConnection`]; incoming `forwarded-tcpip` channels are + /// matched against it and bridged to the registered local target. + pub remote_forwards: RemoteForwardTable, } impl ClientHandler { @@ -50,7 +71,10 @@ impl ClientHandler { impl russh::client::Handler for ClientHandler { type Error = russh::Error; - async fn check_server_key(&mut self, server_public_key: &PublicKey) -> Result { + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { // A per-profile / global opt-out (FR-S4): trust unconditionally. if !self.verify_host_keys { return Ok(true); @@ -93,10 +117,54 @@ impl russh::client::Handler for ClientHandler { } } - async fn auth_banner(&mut self, banner: &str, _session: &mut Session) -> Result<(), Self::Error> { + async fn auth_banner( + &mut self, + banner: &str, + _session: &mut Session, + ) -> Result<(), Self::Error> { if !self.skip_banner && !banner.is_empty() { self.broker.banner(banner.to_string()); } Ok(()) } + + /// An incoming connection on a Remote (`tcpip-forward`) binding. Match it + /// against this connection's registered forwards; on a hit, accept the channel + /// and bridge it to a fresh local TCP connection to the target. An unmatched + /// channel is rejected (dropping `reply` rejects) — a remote forward we don't + /// own must not be tunneled anywhere. + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + connected_address: &str, + connected_port: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + let Some((target_host, target_port)) = self + .remote_forwards + .lookup(connected_address, connected_port as u16) + else { + log::info!( + "rejecting unmatched forwarded-tcpip channel on {connected_address}:{connected_port}" + ); + // Dropping `reply` rejects the channel. + return Ok(()); + }; + reply.accept().await; + let stream = channel.into_stream(); + tokio::spawn(async move { + match TcpStream::connect((target_host.as_str(), target_port)).await { + Ok(sock) => { + let _ = forward::bridge(stream, sock).await; + } + Err(e) => log::info!( + "remote forward: local connect to {target_host}:{target_port} failed: {e}" + ), + } + }); + Ok(()) + } } diff --git a/src/daemon/ssh/mod.rs b/src/daemon/ssh/mod.rs index 7cc6ed2b..c81c520d 100644 --- a/src/daemon/ssh/mod.rs +++ b/src/daemon/ssh/mod.rs @@ -16,6 +16,7 @@ //! `DaemonPane::ssh_connection` (in `daemon::pane`) exposes a pane's connection. pub mod broker; +pub mod forward; pub mod known_hosts; pub mod session; pub mod sftp; @@ -25,6 +26,7 @@ mod connect; mod handler; pub use broker::PromptBroker; +pub use forward::SshForwardRegistry; pub use session::{ChannelCmd, SharedConnection, SshConnection, SshSessionHandle}; use std::collections::HashMap; @@ -35,8 +37,12 @@ use std::time::Duration; use russh::Pty; -use crate::daemon::protocol::{NativeSshSpec, SshPhase, WinSize}; +use crate::daemon::protocol::{ + LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec, + SshForwardRule, SshPhase, WinSize, +}; +use forward::RemoteForwardTable; use handler::ClientHandler; use session::drive_channel; @@ -75,6 +81,9 @@ type ConnSlot = Arc>>; pub struct SshManager { runtime: tokio::runtime::Runtime, conns: Mutex>, + /// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback), + /// driven on this manager's runtime. + forwards: SshForwardRegistry, } impl SshManager { @@ -91,6 +100,7 @@ impl SshManager { SshManager { runtime, conns: Mutex::new(HashMap::new()), + forwards: SshForwardRegistry::default(), } }) } @@ -105,6 +115,73 @@ impl SshManager { self.runtime.handle().clone() } + // ---- Synchronous forward API for the (std-thread) daemon server ---------- + // + // The server dispatch runs on plain std threads; these block on the runtime + // for the async establishment/teardown while returning results synchronously. + + /// Establish a managed forward on `conn` for `pane_id`; returns the pane's + /// forwards after the add. + pub fn add_forward( + &self, + pane_id: u64, + conn: Arc, + rule: &SshForwardRule, + ) -> Vec { + self.runtime.block_on(async { + self.forwards.establish(pane_id, conn, rule).await; + self.forwards.list(pane_id) + }) + } + + /// Remove a managed forward by id; returns the pane's remaining forwards. + pub fn remove_forward(&self, pane_id: u64, forward_id: u64) -> Vec { + self.runtime + .block_on(self.forwards.remove(pane_id, forward_id)) + } + + /// List a pane's managed forwards. + pub fn list_forwards(&self, pane_id: u64) -> Vec { + self.forwards.list(pane_id) + } + + /// Tear down every forward attributed to `pane_id` (pane death / blast radius). + /// Detached on the runtime so a pane's `Drop` (which runs on a connection + /// thread) never blocks on a remote `cancel_tcpip_forward` round-trip. + pub fn teardown_pane_forwards(&'static self, pane_id: u64) { + self.runtime.spawn(async move { + self.forwards.teardown_pane(pane_id).await; + }); + } + + /// Ensure a native-SSH loopback forward for a Cmd-clicked `localhost` URL (FR-F4). + pub fn ensure_loopback_forward( + &self, + pane_id: u64, + conn: Arc, + target: &str, + remote_host: &str, + remote_port: u16, + ) -> std::io::Result { + self.runtime.block_on(self.forwards.ensure_loopback( + pane_id, + conn, + target, + remote_host, + remote_port, + )) + } + + /// The active native-SSH loopback forwards, in the GUI's loopback list shape. + pub fn list_loopback_forwards(&self) -> Vec { + self.forwards.list_loopback() + } + + /// Close one native-SSH loopback forward. + pub fn close_loopback_forward(&self, id: &LoopbackForwardId) -> bool { + self.forwards.close_loopback(id) + } + /// Kick off a native-SSH shell for a pane. Returns immediately; the connect → /// auth → shell sequence runs on the runtime and drives the pane through the /// provided bridge ends. All progress/prompt frames go via `broker`. @@ -115,6 +192,7 @@ impl SshManager { /// to the rest of the daemon exactly like a shell that exited. pub fn spawn_native_session( &'static self, + pane_id: u64, spec: Box, size: WinSize, broker: Arc, @@ -124,7 +202,15 @@ impl SshManager { ) { self.runtime.spawn(async move { if let Err(reason) = self - .run_session(&spec, size, &broker, data_tx.clone(), cmd_rx, &conn_slot) + .run_session( + pane_id, + &spec, + size, + &broker, + data_tx.clone(), + cmd_rx, + &conn_slot, + ) .await { broker.status(SshPhase::Failed { @@ -141,6 +227,7 @@ impl SshManager { async fn run_session( &'static self, + pane_id: u64, spec: &NativeSshSpec, size: WinSize, broker: &Arc, @@ -166,6 +253,13 @@ impl SshManager { broker.status(SshPhase::Connected); + // Establish the profile's preconfigured forwards (FR-F2) now that the + // connection is authenticated. Failures are non-fatal — each surfaces as a + // `ForwardStatus::Error` on the forward row, never a killed session. + for rule in &spec.forwards { + self.forwards.establish(pane_id, conn.clone(), rule).await; + } + // Open the shell channel on the (possibly shared) connection. let channel = conn .open_session_channel() @@ -249,12 +343,16 @@ impl SshManager { .filter(|v| *v > 0) .map(|v| Duration::from_secs(u64::from(v))) .unwrap_or(DEFAULT_CONNECT_TIMEOUT); + // The connection's Remote-forward table, shared with its handler so + // incoming `forwarded-tcpip` channels resolve to a local target (WS4). + let remote_forwards = RemoteForwardTable::default(); let handler = ClientHandler { host: spec.host.clone(), port: spec.port, verify_host_keys: spec.verify_host_keys, skip_banner: spec.skip_banner, broker: broker.clone(), + remote_forwards: remote_forwards.clone(), }; let handshake = async { let transport = connect::build_transport(spec, jump).await?; @@ -274,7 +372,7 @@ impl SshManager { .await .map_err(anyhow::Error::msg)?; - let conn = SshConnection::new(handle, key); + let conn = SshConnection::new(handle, key, remote_forwards); *guard = Arc::downgrade(&conn); Ok(conn) }) diff --git a/src/daemon/ssh/session.rs b/src/daemon/ssh/session.rs index dadd5c91..49401d14 100644 --- a/src/daemon/ssh/session.rs +++ b/src/daemon/ssh/session.rs @@ -32,6 +32,7 @@ use russh::{Channel, ChannelMsg}; use crate::daemon::protocol::WinSize; use super::ConnectionKey; +use super::forward::RemoteForwardTable; /// Bounded depth (in messages) of the driver→reader data channel. Each message is /// one russh data chunk (≤ the channel's max packet size, ~32 KiB), so this caps @@ -258,6 +259,11 @@ pub struct SshConnection { /// as the stable identity WS4/WS5 will match against. #[allow(dead_code)] key: ConnectionKey, + /// The connection's active `tcpip-forward` bindings (WS4 Remote forwards). + /// Shared with this connection's [`super::handler::ClientHandler`] so incoming + /// `forwarded-tcpip` channels resolve to a local target. Empty for a connection + /// with no remote forwards. + remote_forwards: RemoteForwardTable, alive: AtomicBool, } @@ -265,10 +271,12 @@ impl SshConnection { pub(super) fn new( handle: russh::client::Handle, key: ConnectionKey, + remote_forwards: RemoteForwardTable, ) -> Arc { Arc::new(Self { handle: tokio::sync::Mutex::new(handle), key, + remote_forwards, alive: AtomicBool::new(true), }) } @@ -307,9 +315,65 @@ impl SshConnection { self.handle .lock() .await - .channel_open_direct_tcpip(host.to_string(), u32::from(port), "127.0.0.1".to_string(), 0) + .channel_open_direct_tcpip( + host.to_string(), + u32::from(port), + "127.0.0.1".to_string(), + 0, + ) .await } + + /// Request a `tcpip-forward` binding on `bind_host:bind_port`, routing incoming + /// `forwarded-tcpip` channels to `target_host:target_port` (WS4 Remote forward). + /// Registers the target *before* the request so an eager server channel finds + /// it. Returns the resolved bind port (the server assigns one when `bind_port` + /// is 0). On failure the registration is rolled back. + pub async fn add_remote_forward( + &self, + bind_host: &str, + bind_port: u16, + target_host: &str, + target_port: u16, + ) -> Result { + self.remote_forwards + .register(bind_host, bind_port, target_host, target_port); + let requested = self + .handle + .lock() + .await + .tcpip_forward(bind_host.to_string(), u32::from(bind_port)) + .await; + match requested { + Ok(assigned) => { + let real = if bind_port == 0 { + assigned as u16 + } else { + bind_port + }; + if real != bind_port { + self.remote_forwards.rekey(bind_host, bind_port, real); + } + Ok(real) + } + Err(e) => { + self.remote_forwards.unregister(bind_host, bind_port); + Err(format!("{e}")) + } + } + } + + /// Cancel a previously requested `tcpip-forward` binding (best effort) and drop + /// its target registration. + pub async fn cancel_remote_forward(&self, bind_host: &str, bind_port: u16) { + self.remote_forwards.unregister(bind_host, bind_port); + let _ = self + .handle + .lock() + .await + .cancel_tcpip_forward(bind_host.to_string(), u32::from(bind_port)) + .await; + } } #[cfg(test)] diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 07e935c7..31872f0f 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -38,9 +38,9 @@ use std::collections::VecDeque; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, - LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, NativeSshSpec, - RemoteContext, ShellSpec, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult, SftpTransferSpec, - SshPhase, WinSize, + LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, + ManagedForward, NativeSshSpec, RemoteContext, ShellSpec, SftpEntry, SftpJobProgress, SftpOp, + SftpOpResult, SftpTransferSpec, SshForwardRule, SshPhase, WinSize, }; use crate::daemon::transport::{self, Stream}; @@ -1126,6 +1126,56 @@ impl RemoteTerminal { } query(pane_id).unwrap_or_default() } + + /// Establish a managed forward (Local/Remote/Dynamic) on a native-SSH pane over + /// a short-lived control connection; returns the pane's forwards after the add. + /// One-shot, modeled on `list_loopback_forwards`. + pub fn add_forward(pane_id: u64, rule: SshForwardRule) -> Vec { + fn query(pane_id: u64, rule: SshForwardRule) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::AddForward { pane_id, rule }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)), + other => Err(anyhow::anyhow!("unexpected reply to AddForward: {other:?}")), + } + } + query(pane_id, rule).unwrap_or_default() + } + + /// Tear down one managed forward by id; returns the pane's remaining forwards. + pub fn remove_forward(pane_id: u64, forward_id: u64) -> Vec { + fn query(pane_id: u64, forward_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::RemoveForward { + pane_id, + forward_id, + } + .encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to RemoveForward: {other:?}" + )), + } + } + query(pane_id, forward_id).unwrap_or_default() + } + + /// List a native-SSH pane's managed forwards. + pub fn list_forwards(pane_id: u64) -> Vec { + fn query(pane_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::ListForwards { pane_id }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to ListForwards: {other:?}" + )), + } + } + query(pane_id).unwrap_or_default() + } } fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1174b056..cfa86255 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -3223,10 +3223,13 @@ impl TerminalView { fn can_forward_loopback(&self, cx: &mut Context) -> bool { cx.global::().ssh_loopback_forward - && self - .terminal - .remote_context() - .is_some_and(|remote| remote.control_path.is_some()) + && self.terminal.remote_context().is_some_and(|remote| { + // A compat-mode ssh pane forwards through its ControlMaster socket; a + // native russh pane (WS4, FR-F4) forwards through its in-memory + // connection — neither of which the other has, so accept either. + remote.control_path.is_some() + || remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh + }) } /// Update the remembered hovered link for the screen cell `(col, row)` and diff --git a/src/ui/app.rs b/src/ui/app.rs index bc364364..6967ab27 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -123,6 +123,15 @@ pub(crate) struct LoopbackForwardPanelState { pub(crate) host_input: Entity, pub(crate) port_input: Entity, pub(crate) editing: Option, + /// Managed forwards (Local/Remote/Dynamic) for the open native-SSH pane (WS4). + pub(crate) managed: Vec, + /// Add-forward form state (native-SSH panes only). + pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind, + pub(crate) mf_bind_host: Entity, + pub(crate) mf_bind_port: Entity, + pub(crate) mf_target_host: Entity, + pub(crate) mf_target_port: Entity, + pub(crate) mf_description: Entity, } pub struct Tty7App { @@ -266,6 +275,12 @@ impl Tty7App { .default_value("") }); let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx); + // Managed-forward add-form inputs (native-SSH panes). + let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1")); + let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080")); + let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); + let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80")); + let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description")); let sidebar_width = cx.global::().sidebar_width; // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the // `Config` global on every `config.json` change, which fires this. Theme @@ -349,6 +364,13 @@ impl Tty7App { host_input: loopback_host_input, port_input: loopback_port_input, editing: None, + managed: Vec::new(), + mf_kind: crate::daemon::protocol::SshForwardKind::Local, + mf_bind_host, + mf_bind_port, + mf_target_host, + mf_target_port, + mf_description, }, sftp_panel, sidebar_width: Rc::new(Cell::new(sidebar_width)), @@ -887,6 +909,114 @@ impl Tty7App { cx.notify(); } + /// Refresh the managed (Local/Remote/Dynamic) forwards for `pane_id` (WS4). + pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context) { + self.loopback_panel.managed = crate::terminal::RemoteTerminal::list_forwards(pane_id); + cx.notify(); + } + + /// Pick the kind for the add-forward form (native-SSH panes). + pub(crate) fn set_managed_forward_kind( + &mut self, + kind: crate::daemon::protocol::SshForwardKind, + cx: &mut Context, + ) { + self.loopback_panel.mf_kind = kind; + cx.notify(); + } + + /// Establish the add-form's managed forward on `pane_id`'s connection, then + /// clear the form. A blank/invalid bind port is ignored; Dynamic forwards need + /// no target. + pub(crate) fn add_managed_forward( + &mut self, + pane_id: u64, + window: &mut Window, + cx: &mut Context, + ) { + use crate::daemon::protocol::{SshForwardKind, SshForwardRule}; + let kind = self.loopback_panel.mf_kind; + let bind_host = self + .loopback_panel + .mf_bind_host + .read(cx) + .value() + .trim() + .to_string(); + let bind_host = if bind_host.is_empty() { + "127.0.0.1".to_string() + } else { + bind_host + }; + let Ok(bind_port) = self + .loopback_panel + .mf_bind_port + .read(cx) + .value() + .trim() + .parse::() + else { + return; + }; + let target_host = self + .loopback_panel + .mf_target_host + .read(cx) + .value() + .trim() + .to_string(); + let target_port = self + .loopback_panel + .mf_target_port + .read(cx) + .value() + .trim() + .parse::() + .unwrap_or(0); + // Local/Remote require a target; Dynamic (SOCKS) does not. + if kind != SshForwardKind::Dynamic && (target_host.is_empty() || target_port == 0) { + return; + } + let description = self + .loopback_panel + .mf_description + .read(cx) + .value() + .trim() + .to_string(); + let rule = SshForwardRule { + kind, + bind_host, + bind_port, + target_host, + target_port, + description: (!description.is_empty()).then_some(description), + }; + self.loopback_panel.managed = crate::terminal::RemoteTerminal::add_forward(pane_id, rule); + // Reset the value-carrying fields; keep bind host default. + for input in [ + &self.loopback_panel.mf_bind_port, + &self.loopback_panel.mf_target_host, + &self.loopback_panel.mf_target_port, + &self.loopback_panel.mf_description, + ] { + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + cx.notify(); + } + + /// Tear down one managed forward by id (native-SSH panes). + pub(crate) fn remove_managed_forward( + &mut self, + pane_id: u64, + forward_id: u64, + cx: &mut Context, + ) { + self.loopback_panel.managed = + crate::terminal::RemoteTerminal::remove_forward(pane_id, forward_id); + cx.notify(); + } + pub(crate) fn toggle_loopback_forward_panel(&mut self, pane_id: u64, cx: &mut Context) { let should_open = self.loopback_panel.open_pane_id != Some(pane_id); if should_open { @@ -900,6 +1030,7 @@ impl Tty7App { self.loopback_panel.editing = None; } self.refresh_loopback_forwards(cx); + self.refresh_managed_forwards(pane_id, cx); } else { self.loopback_panel.open_pane_id = None; self.loopback_panel.editing = None; diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 806d24e1..30a538e0 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -9,7 +9,9 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; -use crate::daemon::protocol::{LoopbackForwardInfo, RemoteContext}; +use crate::daemon::protocol::{ + ForwardStatus, LoopbackForwardInfo, ManagedForward, RemoteContext, RemoteKind, SshForwardKind, +}; use crate::ui::app::Tty7App; impl Tty7App { @@ -21,7 +23,13 @@ impl Tty7App { ) -> AnyElement { let foreground = cx.theme().foreground; let pane_forwards = self.loopback_forwards_for_pane(pane_id); - let active_count = pane_forwards.len(); + let is_native = remote.kind == RemoteKind::NativeSsh; + let managed_count = if is_native { + self.loopback_panel.managed.len() + } else { + 0 + }; + let active_count = pane_forwards.len() + managed_count; let panel_open = self.loopback_panel.open_pane_id == Some(pane_id); let label = if active_count == 0 { "Ports".to_string() @@ -82,12 +90,14 @@ impl Tty7App { .small() .on_click(cx.listener(|this, _, _w, cx| this.close_loopback_forward_panel(cx))); - let body = if forwards.is_empty() { + let is_native = remote.kind == RemoteKind::NativeSsh; + + let loopback_body = if forwards.is_empty() { v_flex().child( div() .text_sm() .text_color(muted_foreground) - .child("No active forwards for this host."), + .child("No loopback forwards for this host."), ) } else { let mut list = v_flex().gap_2(); @@ -99,7 +109,7 @@ impl Tty7App { v_flex() .w(px(460.)) - .max_h(px(420.)) + .max_h(px(560.)) .gap_3() .p_3() .overflow_hidden() @@ -132,10 +142,219 @@ impl Tty7App { ) .child(h_flex().gap_2().child(refresh).child(close)), ) - .child(self.render_loopback_forward_form(pane_id, cx)) + // Managed L/R/D forwards come first for native panes; the loopback + // one-click list stays below and is shown for both pane kinds. + .when(is_native, |this| { + this.child(self.render_managed_forwards_section(pane_id, cx)) + }) + .child( + v_flex() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Loopback (localhost links)"), + ) + .child(self.render_loopback_forward_form(pane_id, cx)) + .child(loopback_body), + ) + } + + /// The managed-forward (Local/Remote/Dynamic) section shown for native-SSH + /// panes: an add form with a kind selector and the live forward rows (WS4). + fn render_managed_forwards_section(&self, pane_id: u64, cx: &mut Context) -> Div { + let foreground = cx.theme().foreground; + let muted_foreground = cx.theme().muted_foreground; + let managed: Vec = self + .loopback_panel + .managed + .iter() + .filter(|m| m.pane_id == pane_id) + .cloned() + .collect(); + + let body = if managed.is_empty() { + v_flex().child( + div() + .text_sm() + .text_color(muted_foreground) + .child("No managed forwards."), + ) + } else { + let mut list = v_flex().gap_2(); + for forward in &managed { + list = list.child(self.render_managed_forward_row(forward, cx)); + } + list + }; + + v_flex() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Managed forwards"), + ) + .child(self.render_managed_forward_form(pane_id, cx)) .child(body) } + fn render_managed_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let kind = self.loopback_panel.mf_kind; + let selected = match kind { + SshForwardKind::Local => 0, + SshForwardKind::Remote => 1, + SshForwardKind::Dynamic => 2, + }; + // Dynamic (SOCKS) forwards have no fixed target — grey the target inputs. + let needs_target = kind != SshForwardKind::Dynamic; + + let bind_host = div() + .w(px(150.)) + .child(Input::new(&self.loopback_panel.mf_bind_host).small()); + let bind_port = div() + .w(px(80.)) + .child(Input::new(&self.loopback_panel.mf_bind_port).small()); + let target_host = div() + .w(px(150.)) + .child(Input::new(&self.loopback_panel.mf_target_host).small()); + let target_port = div() + .w(px(80.)) + .child(Input::new(&self.loopback_panel.mf_target_port).small()); + let description = div() + .w_full() + .child(Input::new(&self.loopback_panel.mf_description).small()); + + v_flex() + .gap_2() + .py_1() + .child(self.segmented( + "ssh-managed-forward-kind", + &["Local", "Remote", "Dynamic"], + selected, + cx, + move |this, ix, _window, cx| { + let kind = match ix { + 1 => SshForwardKind::Remote, + 2 => SshForwardKind::Dynamic, + _ => SshForwardKind::Local, + }; + this.set_managed_forward_kind(kind, cx); + }, + )) + .child( + h_flex() + .items_center() + .gap_1() + .child(div().w(px(48.)).text_xs().text_color(muted).child("bind")) + .child(bind_host) + .child(div().text_sm().text_color(muted).child(":")) + .child(bind_port), + ) + .child( + h_flex() + .items_center() + .gap_1() + .opacity(if needs_target { 1.0 } else { 0.4 }) + .child( + div() + .w(px(48.)) + .text_xs() + .text_color(muted) + .child(if needs_target { "target" } else { "SOCKS" }), + ) + .child(target_host) + .child(div().text_sm().text_color(muted).child(":")) + .child(target_port), + ) + .child( + h_flex().items_center().gap_2().child(description).child( + Button::new(("ssh-managed-forward-add", pane_id)) + .label("Add") + .small() + .primary() + .on_click(cx.listener(move |this, _, window, cx| { + this.add_managed_forward(pane_id, window, cx) + })), + ), + ) + } + + fn render_managed_forward_row(&self, forward: &ManagedForward, cx: &mut Context) -> Div { + let theme = cx.theme(); + let (badge, badge_color) = match forward.kind { + SshForwardKind::Local => ("L", theme.info), + SshForwardKind::Remote => ("R", theme.warning), + SshForwardKind::Dynamic => ("D", theme.success), + }; + let bind = format!("{}:{}", forward.bind_host, forward.bind_port); + let flow = if forward.kind == SshForwardKind::Dynamic { + format!("{bind} (SOCKS)") + } else { + format!("{bind} -> {}:{}", forward.target_host, forward.target_port) + }; + let (status_text, status_color) = match &forward.status { + ForwardStatus::Listening => ("listening".to_string(), theme.success), + ForwardStatus::Error(msg) => (format!("error: {msg}"), theme.danger), + }; + let pane_id = forward.pane_id; + let forward_id = forward.id; + + h_flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .border_1() + .border_color(theme.border) + .rounded_md() + .child( + div() + .flex_none() + .w(px(20.)) + .h(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .bg(badge_color.opacity(0.15)) + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(badge_color) + .child(badge), + ) + .child( + v_flex() + .gap_0p5() + .flex_1() + .min_w_0() + .child(div().text_sm().text_color(theme.foreground).child(flow)) + .when_some(forward.description.clone(), |el, desc| { + el.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(desc), + ) + }) + .child(div().text_xs().text_color(status_color).child(status_text)), + ) + .child( + Button::new(("ssh-managed-forward-del", forward_id as usize)) + .label("Delete") + .small() + .on_click(cx.listener(move |this, _, _window, cx| { + this.remove_managed_forward(pane_id, forward_id, cx) + })), + ) + } + fn render_loopback_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { let theme = cx.theme(); let host_input = self.loopback_panel.host_input.clone(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 9f95bafd..96061eb9 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -693,7 +693,7 @@ impl Tty7App { /// speak the same segmented language as the −│value│+ stepper; `small` pins /// every option control to the same 24px height as the selects beside them. /// `selected` is the active index; `on_pick` fires with the newly chosen one. - fn segmented( + pub(crate) fn segmented( &self, id: &'static str, options: &'static [&'static str],