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:
l0ng-ai
2026-07-14 01:37:37 +08:00
14 changed files with 2152 additions and 44 deletions
+398
View File
@@ -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 上游支持)
+3 -3
View File
@@ -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 2022) → `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
View File
@@ -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
View File
@@ -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;
// (1619 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;
// (1519 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
View File
@@ -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(&registry, 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(&registry, 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
+865
View File
@@ -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);
}
}
+72 -4
View File
@@ -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
View File
@@ -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)
})
+65 -1
View File
@@ -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
View File
@@ -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 {
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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],