为一次登录做两套 SSH 栈——Shellby 的后端抉择
Shellby 复盘:iOS 上没有系统 SSH 库,纯 Swift 还是 C,两条路我都走了一遍。
Shellby 要在 iPhone、iPad、macOS 上用一套 Swift 代码建立 SSH 连接。听起来简单,但第一个拦路问题是:iOS 上没有系统 SSH 库。
这篇讲一个不算主流的决定:我为同一个「登录服务器」的能力,做了两套 SSH 后端——一套纯 Swift(Citadel,基于 SwiftNIO SSH),一套 C 库(libssh2)。为什么要两套、怎么让上层 UI 不在乎用的是哪套、以及最终怎么收敛。
问题:C 库和 Swift 并发不对付
SSH 客户端最成熟的选择是 libssh2 这类 C 库,久经考验。但把它用在一个跨 iOS 的 Swift App 里,有两处别扭:
- 跨平台要自己编 xcframework——libssh2 是 C,要在 iOS 上用得自己交叉编译、打包成 xcframework,还得管好各架构。
- 和 Swift 并发不契合——libssh2 是阻塞式的 C API,而 Shellby 全身是 Swift 并发:终端输出是
AsyncStream<Data>,UI 只跟 async 接口打交道。把一个阻塞库塞进 async 世界,得自己搭桥。
于是有了另一个选项:Citadel,一个纯 Swift 的 SSH 实现(底层是 SwiftNIO SSH)。纯 Swift 意味着在 iOS 上直接能编、能测,不用碰 xcframework,而且天生是 NIO 的事件循环模型,离 async 更近。
决定:Citadel 当主,libssh2 留作参考
最终的架构决策是:主后端选 Citadel——三端(含 iOS)统一可编译、可测,SFTP、端口转发、PTY 都齐。libssh2 保留为 macOS 备用和参考实现,被否掉的方案是「纯 libssh2 + xcframework」那条路。
但这里有个值得说的过程:早期两套后端都做了。先用 libssh2 端到端跑通——连真实 sshd,握手、指纹校验、公钥认证、执行命令、PTY 全走通,证明整条链路是对的;再补上跨平台的 Citadel 后端,最终做到「iOS App 在模拟器里连真实服务器,不再需要 libssh2 的 xcframework」。
先用最成熟的库趟通链路验证正确性,再换成更适合平台的实现——这不是浪费,是把「链路对不对」和「用哪个库」两个问题解耦,一个一个解。现在三端 App 一律走 Citadel,libssh2 只在命令行工具里还留着。
抽象:上层只认协议,不认库
两套后端能共存、能替换,靠的是中间一个纯协议层(叫 SSHCore)。它零 UI 依赖、也不把 NIO 的细节漏出去——SSHClient、SSHSession、ShellChannel、SFTPClient、Forward 全是 protocol,数据用 AsyncStream,错误统一成一个 SSHError。
两个后端各自实现同一组协议:一个 CitadelSSHClient,一个 Libssh2Client。App 只依赖 SSHClient 协议,加一个工厂函数——切换后端只改工厂那一行。这就是分层解耦的价值:上层永远在跟「一个能连 SSH 的东西」对话,而不是跟某个具体的库对话。
这条依赖规则是硬性的:领域层(那些 Sources/* 的包)禁止 import 任何 UI 框架,依赖只能自上而下。好处是核心层能脱离 App 单独测——SSHCore 的单测覆盖了 PTY 默认值、配置默认值、错误本地化这些;而端到端的真连接验证,靠一个独立的命令行工具去连真实 sshd 跑一整套握手/认证/exec/PTY/SFTP/转发。
桥接:把阻塞的 C 拽进 async
保留 libssh2 就得解决那个「阻塞 C API 塞进 async」的问题。做法是:把 libssh2 的所有调用关进一个专用的串行队列(shellby.ssh.io.<host>),用 withCheckedContinuation 加上 select/EAGAIN 轮询,把阻塞调用桥接成 async 接口。Citadel 那边就没这个问题——它本来就是 EventLoop 模型,NIO 的细节被 SSHCore 协议挡在下面,UI 只看到干净的 async。
一个语言模式上的小取舍:早期整个项目统一用 Swift 5 语言模式,就是为了降低 libssh2 那套 C 互操作在严格并发下的迁移成本。为了照顾那个 C 后端,把整个包的并发档位往回调了一格。
两套后端不是等价的
保留双后端,也得诚实面对它们能力不对等。libssh2 后端有几处功能缺口:
- 不支持 ProxyJump 多跳跳板——直接抛「跳板机功能在里程碑 3」;
- 不支持 App 内生成的原始 Ed25519 私钥——只吃 OpenSSH PEM 格式。
这些缺口恰恰是 Citadel 成为主后端的功能理由:主后端得功能全,备用/参考实现允许有短板。反过来 Citadel 也有代价——它要求 macOS 15+(withPTY / 流式执行依赖),这是选它付的账。
还有个能力细节值得记:Citadel 的流式执行不暴露退出码,所以拿退出码的那条路径目前只能用 0/1 近似——这类「底层库没给你想要的东西」的坑,是任何依赖第三方库的项目都躲不掉的,能做的是把它标清楚、别让上层以为拿到的是精确值。
为什么两个后端都对齐 OpenSSH 指纹
有一处两套后端刻意做成一致:主机指纹的算法。
无论 Citadel 还是 libssh2,主机指纹都算成同一个格式——把公钥序列化、SHA256、base64 去掉尾部填充、加上 SHA256: 前缀。这正是 OpenSSH 命令行显示的那个格式。
为什么较这个真?因为指纹是给用户看、用户拿去和别处(比如服务器上 ssh-keygen -l 的输出)核对的。如果 Shellby 显示的指纹格式和 OpenSSH 不一样,用户根本没法比对,「信任首次使用」这套机制就形同虚设。两个后端实现不同,但对用户呈现的指纹必须逐字节一致——面向用户的契约,不能因为内部换了实现就变。
小结
为一个能力做两套实现,听起来奢侈,但复盘下来是笔划算的账:
- 先用成熟库趟通,再换合适的实现——把「链路正确性」和「库选型」解耦,逐个击破,不是浪费;
- 协议层是可替换的前提——上层只认
SSHClient协议加一个工厂,换后端改一行;领域层禁依赖 UI,核心才能脱离 App 单测; - 双后端不等价,要诚实——能力缺口、平台门槛、库的限制(拿不到退出码),标清楚,别让上层踩空;
- 面向用户的契约要跨实现一致——两套后端算法不同,但呈现给用户的指纹格式必须一模一样。
现在的 Shellby 三端都跑 Citadel,libssh2 静静躺在命令行工具里当参考。但那段「两套都做一遍」的路没白走——它让我确信链路是对的,也留下了一个随时能对照的第二实现。
留言