ssh-gui 的架构:一个 Go 进程,一条看不见的边界

ssh-gui 技术系列 · 二 · 2026-10-23

ssh-gui 的整体形态一句话就能说完:一个 Go 进程,里面跑着 WebView 画的前端,SSH 连接、SFTP、端口转发、加密存储全部由 Go 侧承担。但这句话里藏着几层值得展开的设计——为什么是 Go,前端和 Go 之间怎么划边界,秘密在哪里止步。这篇把这些讲清楚。

为什么是 Go + Wails

桌面应用做 SSH 客户端,摆在前面的路通常是 Electron 或 Tauri。两条路我们都仔细看过,最后都放弃了,原因不在框架本身,而在 SSH 栈。

Electron 的主进程是 Node.js。Node 生态里没有能打的 SSH2 服务端级实现——有 ssh2 这个库,但它的成熟度和 golang.org/x/crypto/ssh 不在一个量级,后者是 OpenSSH 生态里被各类基础设施验证了十年的代码。Tauri 更干脆:核心逻辑要拿 Rust 写,意味着全部协议工作从零开始。

Go 这边,x/crypto/ssh 支持 direct-tcpip、tcpip-forward、pty-req 这些我们需要的全部通道类型,pkg/sftp 的 API 贴着 os 包设计。选 Wails v3 做外壳,是因为它把"Go 服务直接绑定成前端可调用的函数"这件事做成了工作流的一部分:Go 侧写一个 SessionService.OpenSession,命令行生成 TypeScript 绑定,前端调用时连参数类型都是对齐的。

代价也要认:Wails v3 目前还是 beta(我们锁在 v3.0.0-beta.33)。作为对冲,项目里有一条硬规矩——所有对 Wails 的访问收敛到 internal/ipc 一层,业务代码不许 import wails 包。哪天 v3 出了破坏性变更或者干脆要换壳,要改的只有这一层。

四层依赖,方向不可逆

Go 侧的代码分四层,依赖方向强制单向:

L3  cmd/sshgui, internal/app      装配与生命周期,不含业务逻辑
L2  internal/service               绑定边界(9 个服务,51 个方法)
L1  sshx / session / sftpx / tunnel / secret / store / stream
L0  internal/domain                纯实体与错误,不依赖任何内部包

L0 的 domain 只放实体和 AppError,谁都不依赖;L1 的每个包对应一块协议能力——sshx 管拨号、认证、known_hosts 和连接池,session 管 PTY,sftpx 管文件传输,tunnel 管三类转发,secret 管保险库。L2 的服务层是唯一允许出现在绑定生成器视野里的层,它的方法签名只用可绑定的类型,错误统一是带错误码的 AppError。

有一处边界值得单独说:sshx 自己不读数据库。它需要"某台主机的认证材料"时,靠构造时注入的回调去取。这样协议层和存储层彻底解耦,测试时用一个内存实现的回调就能跑通整条拨号链路——项目里那套嵌入式 SSH 测试床就是靠这个边界才轻得起来。

绑定是生成的,不是手写的

前端和 Go 之间所有的方法调用都走生成产物:wails3 generate bindings 扫描 cmd/sshgui 的服务定义,产出 15 个 TypeScript 文件,覆盖 9 个服务的 51 个方法。前端不手写调用代码,types/domain.ts 里的实体类型直接复用生成模型。

生成物最怕漂移——Go 侧改了签名,前端还拿着旧类型,编译期未必能发现。所以仓库里有一个 verify-bindings.mjs,重新生成一遍和提交的产物逐字对比,有任何不一致就失败,CI 里同样跑这道。这条检查救过我们不止一次:改了 Go 方法忘了重新生成,本地一跑校验立刻红。

Go 是真相源,前端是投影

这是整个架构里影响最深的决定。会话的状态——连着没连、断了还是退出了、回看缓冲里有什么——全部由 Go 侧持有,前端只是订阅这份状态的视图。

理由很实际:断线重连、重启恢复、会话录制,这三件事都要求状态能从一个干净进程里重建。如果状态散在前端组件里,重连就变成了"关掉旧标签再开一个新标签",用户丢掉滚动缓冲和会话上下文;录制更是无从谈起,因为录制器需要挂在会话输出泵上,而输出泵在 Go 进程里。

落地后的形态是:会话因网络问题断开时保留为 disconnected 状态留在会话表里,重连命令原地复用同一个会话 ID,重建 PTY、保留回看缓冲,用户看到的是同一个标签"活"了过来,而不是闪一下变成新标签。远端正常退出或用户主动关闭才记为 closed,不可重连。

秘密不出 Go 进程

安全设计里我们最看重的一条边界:明文口令、私钥字节、私钥口令,永远不进入前端。

这意味着前端没有密码输入框。口令认证的主机,连接时弹出的是 Windows 系统凭据对话框(CredUIPromptForWindowsCredentialsW),口令直接进入 Go 进程,WebView 从头到尾不知道它的存在。私钥口令同理。前端界面上能看到的只有"这把密钥已解锁"这样的状态。

这样设计的出发点是控制爆炸半径。前端是跑在 WebView 里的 JS,攻击面比 Go 进程大得多——一个 XSS、一个被投毒的依赖,都可能把内存里的东西捞走。秘密不进前端,前端被攻破时能泄露的东西就少一层。配套的还有日志脱敏:所有写入日志的文本先过一遍 redact.Scrub,口令模式、私钥 PEM 块、token= 之类的键值对一律打码。

保险库本身用的是 scrypt(N=32768)从主口令派生 32 字节主密钥,每条密文是 nonce || AES-256-GCM(...),附加认证数据绑定条目 ID,防止密文被互换位置。空闲 15 分钟自动锁定,锁定时内存里的主密钥清零。

其余两个选型的理由

SQLite 而不是 JSON 文件,用的是 modernc.org/sqlite——纯 Go 实现,不依赖 CGO。这是硬约束推着走的:Wails 的交叉编译链路对 CGO 不友好,纯 Go 的 SQLite 让"一个命令出四个平台的包"成为可能。数据层带版本化迁移,启动时把上次残留的 running 传输任务标记为 interrupted,这类崩溃恢复的细节都收敛在 internal/store。

错误必须可执行。Go 侧定义了稳定的错误码表(E_NET_*、E_AUTH_*、E_TUNNEL_*……),所有抛给用户的提示是中文、说人话、带下一步动作的——"本地端口 5432 已被占用,请更换端口",而不是把 Go 原始错误字符串直接甩到界面上。堡垒机禁止转发这种场景,错误信息会带上是第几跳拒绝的,用户不需要拿着报错去猜。

架构文档(architecture.md)里还有一份 78KB 的完整契约,包括 IPC 事件目录、状态机和威胁清单,本文只是抽了几根主梁。下一篇我们钻进最有趣的那条链路:终端字节是怎么从远端 shell 一路跑到 xterm.js 屏幕上的。

posted @ 2026-10-23 23:35  cliffdev  阅读(3)  评论(0)    收藏  举报