mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge branch 'ssh-ws4-forwards' into ssh-connection-manager
# Conflicts: # src/daemon/protocol.rs # src/daemon/server.rs # src/daemon/ssh/mod.rs # src/terminal/remote.rs # src/ui/app.rs
This commit is contained in:
@@ -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<String>,
|
||||
|
||||
// 连接
|
||||
host: String,
|
||||
port: u16, // 默认 22
|
||||
user: String,
|
||||
jump_host: Option<Uuid>, // 指向另一 profile
|
||||
proxy_command: Option<String>,
|
||||
socks_proxy: Option<HostPort>,
|
||||
http_proxy: Option<HostPort>,
|
||||
|
||||
// 认证
|
||||
auth: AuthMode, // Auto | Password | PublicKey | Agent | KeyboardInteractive
|
||||
identity_files: Vec<String>, // 支持 %h/%r
|
||||
agent_forward: bool,
|
||||
// 凭据只存引用,明文在 keychain
|
||||
credential_ref: Option<CredentialRef>,
|
||||
|
||||
// 转发
|
||||
forwards: Vec<ForwardRule>, // {type: L|R|D, bind, target, description}
|
||||
|
||||
// 会话
|
||||
keepalive_interval_s: Option<u32>,
|
||||
keepalive_count_max: Option<u32>,
|
||||
connect_timeout_s: Option<u32>,
|
||||
warn_on_close: Option<bool>,
|
||||
skip_banner: bool,
|
||||
login_scripts: Vec<String>,
|
||||
x11: bool,
|
||||
|
||||
// 高级
|
||||
algorithms: Algorithms, // kex/cipher/mac/hostkey/compression 列表,空=默认
|
||||
verify_host_keys: Option<bool>,
|
||||
use_system_ssh: bool, // 兼容模式:走 shell-out `ssh`,管理器功能置灰
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 凭据存储
|
||||
- keychain 条目按**端点**而非 profile 键控:密码用 `tty7-ssh:<user>@<host>:<port>`,私钥 passphrase 用 `tty7-ssh-key:<key-sha512>`。
|
||||
- 理由: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 上游支持)
|
||||
@@ -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 |
|
||||
|
||||
+11
-2
@@ -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();
|
||||
|
||||
+140
-6
@@ -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<String>,
|
||||
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<String>),
|
||||
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<SftpJobProgress>),
|
||||
/// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed
|
||||
/// forwards currently attributed to the requested pane (WS4).
|
||||
ForwardList(Vec<ManagedForward>),
|
||||
/// 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);
|
||||
|
||||
+80
-11
@@ -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<Registry>) -> 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<Registry>) -> 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<Registry>) -> 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<Registry>) -> 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<Registry>) -> 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<Registry>) -> 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<Arc<SshConnection>, 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
|
||||
|
||||
@@ -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<SshConnection>`, 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, B>(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>(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>(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<Mutex<HashMap<(String, u16), (String, u16)>>>,
|
||||
}
|
||||
|
||||
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<SshConnection>,
|
||||
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<String>,
|
||||
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<HashMap<u64, Vec<ForwardEntry>>>,
|
||||
loopback: Mutex<HashMap<LoopbackForwardId, LoopbackEntry>>,
|
||||
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<SshConnection>,
|
||||
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<ManagedForward> {
|
||||
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<ManagedForward> {
|
||||
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<LoopbackForwardId> = {
|
||||
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<SshConnection>,
|
||||
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<SshConnection>,
|
||||
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<SshConnection>,
|
||||
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:<ephemeral> → 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<SshConnection>,
|
||||
target: &str,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
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<LoopbackForwardInfo> {
|
||||
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<_>>(), 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);
|
||||
}
|
||||
}
|
||||
@@ -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<n>` 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<PromptBroker>,
|
||||
/// 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<bool, Self::Error> {
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
server_public_key: &PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
// 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<Msg>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
+101
-3
@@ -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<tokio::sync::Mutex<Weak<SshConnection>>>;
|
||||
pub struct SshManager {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
conns: Mutex<HashMap<ConnectionKey, ConnSlot>>,
|
||||
/// 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<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> Vec<ManagedForward> {
|
||||
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<ManagedForward> {
|
||||
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<ManagedForward> {
|
||||
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<SshConnection>,
|
||||
target: &str,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> std::io::Result<LoopbackForward> {
|
||||
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<LoopbackForwardInfo> {
|
||||
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<NativeSshSpec>,
|
||||
size: WinSize,
|
||||
broker: Arc<PromptBroker>,
|
||||
@@ -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<PromptBroker>,
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<super::handler::ClientHandler>,
|
||||
key: ConnectionKey,
|
||||
remote_forwards: RemoteForwardTable,
|
||||
) -> Arc<Self> {
|
||||
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<u16, String> {
|
||||
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)]
|
||||
|
||||
+53
-3
@@ -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<ManagedForward> {
|
||||
fn query(pane_id: u64, rule: SshForwardRule) -> anyhow::Result<Vec<ManagedForward>> {
|
||||
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<ManagedForward> {
|
||||
fn query(pane_id: u64, forward_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
|
||||
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<ManagedForward> {
|
||||
fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
|
||||
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 {
|
||||
|
||||
@@ -3223,10 +3223,13 @@ impl TerminalView {
|
||||
|
||||
fn can_forward_loopback(&self, cx: &mut Context<Self>) -> bool {
|
||||
cx.global::<Config>().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
|
||||
|
||||
+131
@@ -123,6 +123,15 @@ pub(crate) struct LoopbackForwardPanelState {
|
||||
pub(crate) host_input: Entity<InputState>,
|
||||
pub(crate) port_input: Entity<InputState>,
|
||||
pub(crate) editing: Option<LoopbackForwardId>,
|
||||
/// Managed forwards (Local/Remote/Dynamic) for the open native-SSH pane (WS4).
|
||||
pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>,
|
||||
/// Add-forward form state (native-SSH panes only).
|
||||
pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind,
|
||||
pub(crate) mf_bind_host: Entity<InputState>,
|
||||
pub(crate) mf_bind_port: Entity<InputState>,
|
||||
pub(crate) mf_target_host: Entity<InputState>,
|
||||
pub(crate) mf_target_port: Entity<InputState>,
|
||||
pub(crate) mf_description: Entity<InputState>,
|
||||
}
|
||||
|
||||
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::<Config>().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>) {
|
||||
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>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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::<u16>()
|
||||
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::<u16>()
|
||||
.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>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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;
|
||||
|
||||
+225
-6
@@ -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<Self>) -> Div {
|
||||
let foreground = cx.theme().foreground;
|
||||
let muted_foreground = cx.theme().muted_foreground;
|
||||
let managed: Vec<ManagedForward> = 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<Self>) -> 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<Self>) -> 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<Self>) -> Div {
|
||||
let theme = cx.theme();
|
||||
let host_input = self.loopback_panel.host_input.clone();
|
||||
|
||||
+1
-1
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user