返回博客

2026年9月20日

SwiftServer 为什么自研 SSH 引擎

Traversio 是 SwiftServer 底层的开源 Swift SSH2 库。我们替换掉了什么,它实现了什么,难点在哪里,以及实测数据。

SwiftServer 为什么自研 SSH 引擎

SwiftServer 的每个界面都建立在一条 SSH 连接上。终端、仪表板、Docker 面板、SFTP、端口转发、跳板机和代理,都以同样的方式抵达服务器。很长一段时间里,这一层是 libssh2 之上的一层 Swift 封装。现在它是 Traversio,我们自己用 Swift 写的 SSH2 客户端库,开源,也可以在 App 之外使用。

适配层暴露了问题

最明显的信号,是我们自己那层胶水代码的形状。

SwiftServer 支持密码认证,并在失败时回退到 keyboard-interactive,因为很多服务器宣告其中一种、却接受另一种。在旧引擎下,这要构造多份客户端配置:用第一份连接,捕获存下来的 lastError,再用下一份重连。加载私钥时,我们得自己试探存储的 PEM 文本:先试 Ed25519 加载器,再试 RSA,然后 ECDSA,还要为不支持的情况留一个分支。

这些都是封装层漏进 App 的控制流。C 引擎默认自己拥有整个进程,操作结束就退出。SwiftServer 长期运行,由界面驱动,用户关闭窗口时必须取消正在进行的工作。会话句柄、通道句柄和以重试为中心的控制流,都映射不到 Swift 的任务所有权模型上。

后来 Traversio 在 SSHClientConfiguration 和 SSHProxyJumpHost 上都接受有序的 authenticationMethods,.openSSHPrivateKey(...) 也能从 PEM 容器识别密钥类型。于是我们删掉了重试循环、本地密钥类型试探和多配置构造器。每台机器一份配置,一次 userauth 连接,一份有序的候选列表。这些删掉的代码,就是自研引擎的理由。

它实现了什么

密钥交换:curve25519-sha256、[email protected],以及 ecdh-sha2-nistp256 / 384 / 521,首次交换时带上 ext-info-c 和 [email protected] 标记。主机密钥:ssh-ed25519、ecdsa-sha2-nistp256、rsa-sha2-512、rsa-sha2-256,以及前两者的 OpenSSH 证书变体。加密算法:aes128-ctr、aes256-ctr、[email protected]、[email protected] 和 [email protected]。MAC:HMAC-SHA-2 和 OpenSSH UMAC,包含经典形式和 encrypt-then-MAC 形式。

旧版 ssh-rsa 默认关闭。如果某台服务器仍然需要,调用方可以为单条连接或单个跳板机跳转开启 SHA-1 RSA;这个开关与加载器接受哪些私钥文件格式是两件事。

认证方式包括密码认证(含改密回调)和 keyboard-interactive。公钥认证支持 Ed25519、ECDSA 和 RSA SHA-2 密钥,签名可以走回调,也可以通过 SSHAgentClient 交给 SSH agent。密钥加载支持 OpenSSH 私钥,包括经 bcrypt 加 AES 加密的,也支持 OpenSSL 风格的 PEM 容器和加密的传统 RSA PEM。它还能生成密钥对,并给出配套的 authorized key 文本。

传输层之上:exec、流式 exec、具名子系统、带环境变量请求的 PTY shell、窗口尺寸调整和退出信号上报。SFTP 支持元数据、目录列举、文件句柄和写操作,以及递归传输、断点续传和进度回调。另有单文件 SCP 辅助方法。通道层提供原始的 direct-tcpip 和 [email protected] 通道。转发包括本地转发、动态 SOCKS 转发,以及远程 TCP 与 streamlocal 监听。此外还有 SOCKS5 和 HTTP CONNECT 连接代理,以及 ProxyJump。

结构

这个包由一个 Swift target 和旁边一个小的 C target 组成,内部划分为 Transport、Wire、Algorithms、Authentication、Connection、Forwarding、SFTP、TransportProtocol 和 Client。

可变的协议状态放在 actor 里。SSHTransportProtocolClient 是一个 actor,持有版本交换、密钥交换、切入加密包流、认证顺序和通道生命周期。SFTP 也是一个 actor,在一个子系统会话内按请求 ID 把回复路由回等待方。因此,同一个客户端上的元数据读取和句柄读取可以交叠进行,无需开第二个子系统。转发监听器把各自的连接任务放在小的私有 actor 里。

再往下全是值类型,因此可以脱离网络测试。算法协商就是一个 struct:

// SSHClientKeyExchangePreferences.default
keyExchangeAlgorithms: [
    "curve25519-sha256",
    "[email protected]",
    "ecdh-sha2-nistp256",
    "ecdh-sha2-nistp384",
    "ecdh-sha2-nistp521",
    "ext-info-c",
    "[email protected]",
]

这些标记和算法放在同一个列表里,因为它们在线路上就是这样传输的;后续用于重新协商密钥的 KEXINIT 会把它们过滤掉。

传输层本身是一层抽象。Traversio 在运行时选择具体后端:在 Apple 平台 26 及以上版本使用较新的 Network.framework API,由结构化作用域负责确定性的拆除;在更早的系统上使用兼容后端。线路层、认证层、通道层、SFTP 层和转发层永远看不到 Apple 的网络类型。

在构造方式吻合的地方,密码学部分直接复用 Apple 的原语。CryptoKit 负责 Curve25519、Ed25519、ECDSA 和 AES-GCM。OpenSSH 的 [email protected] 用一把单独的密钥加密包长度,CryptoKit 没有这种构造。所以这个加密算法、UMAC 和 bcrypt_pbkdf 是 TraversioCCrypto 里的几个小 C 垫片。包层用统一的方式处理这三种保护形式:

private enum SSHPacketProtection {
    case cipherAndMAC(cipher: SSHAESCTRCryptor, mac: SSHMessageAuthenticator)
    case aesGCM(cipher: SSHAESGCMCryptor)
    case chaChaPoly1305(cipher: SSHChaChaPoly1305Cryptor)
}

难点在所有权

密钥交换有明确的规范,可以用固定样本测试。路由不行。

一条 SwiftServer 连接可能先经过一个 HTTP CONNECT 代理,再经过两台跳板机,然后是一条 direct-tcpip 通道,最后才是目标 SSH 连接。每一段都是有生命周期的资源。我们在这块遇到的每个 bug,都源于两个持有者都认为自己拥有同一条边。

解决办法是把路由建模成一张图:每条边有一个明确的持有者,先拆子节点再拆父节点。任何连接发生之前,先由一份计划解析出第一个 socket 实际连到哪里:

private static func rootEndpoint(
    finalHost: String,
    finalPort: UInt16,
    connectionProxy: SSHConnectionProxy?,
    proxyJumpHosts: [SSHProxyJumpHost]
) -> SSHSocketEndpoint {
    if let connectionProxy {
        return connectionProxy.endpoint
    }
    if let firstHop = proxyJumpHosts.first {
        return SSHSocketEndpoint(host: firstHop.host, port: firstHop.port)
    }
    return SSHSocketEndpoint(host: finalHost, port: finalPort)
}

有两个 bug 代表了这类问题。

在 SwiftServer 里停止本地转发曾经会失败。终端、SFTP 和 Docker 的关闭路径都正常。问题出在我们的转发上下文:它关闭 SSHConnection 的同时,Traversio 的转发作用域也在回收这条连接。现在 App 只取消自己的任务,由库的作用域关闭连接,且只关一次。

本地转发和动态转发请求 localhost 上的固定端口时,会静默拿到一个系统分配的端口。两条监听路径都用 NWEndpoint.Host("localhost") 构造所需的本地端点。这是基于名称的端点,Network.framework 的监听器无法绑定。修复办法是只在绑定这一处把 localhost 规范化为数字形式的回环地址。对外报告的主机字符串和远端目标不变,两个监听后端现在都有针对固定端口的实连测试。

我们测了什么

在当前发布版本上,确定性测试套件运行 726 个测试,另有 warnings-as-errors 构建、严格并发构建和公开 API 检查。在此之外,我们还保留针对真实 OpenSSH 服务器的实连门禁:算法门禁、关闭泄漏门禁、网络生命周期门禁、重新协商密钥与压缩门禁,以及两轮长跑测试。

最清楚的一组数字来自一次长链路上感觉很慢的 SFTP 下载。对一台 RTT 为 269.951 ms 的真实 OpenSSH 服务器,下载速度停在 1.815 到 1.892 MB/s。SwiftServer 当时已经在用 64 KiB 分块、64 个请求的窗口,所以瓶颈在 App 和 SFTP 之下的 SSH 通道信用额度:1 MiB 的接收窗口,加上等窗口消耗一半才归还信用的策略。OpenSSH 的源码则在略多于三个最大包耗尽窗口之后就重新调整。

对齐 OpenSSH 的 2 MiB 通道窗口和它归还信用的节奏之后,同一次传输达到 5.597 到 5.634 MB/s。8 MiB 窗口大约快 5.5%,但每通道的信用额度要多占四倍,所以我们保留 2 MiB。在低 RTT 环境下对每种配置各跑三次 512 MiB 的对照测试,较小的窗口没有退步:126.65 MiB/s,8 MiB 是 117.25 MiB/s。高延迟路径上 CPU 保持在 12% 到 15%,常驻内存峰值大约 30 到 33 MB。

Mosh 是独立的包

SwiftServer 2.1.0 里的 Mosh 不走 Traversio 的协议核心。Mosh 基于 UDP,有自己的状态同步协议,因此放在 SSH 库之外。它在 TraversioMosh 里,这是一个采用 MIT 许可的独立 Swift 包,负责 UDP 传输、状态协议、重传与心跳、AES-128-OCB 和终端状态。Traversio 负责 SSH 这一侧:认证、遵守跳板链、运行 mosh-server new,然后交出密钥和端口。TraversioMosh 有自己的测试套件,26 个 suite 共 554 个测试,还有一张真实服务器兼容性矩阵。

SwiftServer 从中得到了什么

端口转发支持本地转发、远程转发和动态 SOCKS 规则。跳板机是真正的多跳链:一台机器经 A 跳转、A 又经 C 跳转,会解析成先 C 后 A,再接上最终连接。连接代理支持 SOCKS5 和 HTTP CONNECT。主机密钥指纹在你信任它之前就会显示出来,背后是首次使用即信任、指纹固定和 known_hosts 解析。

失败信息也更好读了。迁移进行到一半时,终端和 SFTP 的错误还是 Traversio.SSHClientError error 3 这样的桥接文本。现在它们带有类型化的诊断信息,包括服务器的认证横幅和改密提示的语言标签。App 在状态文本里给出简短摘要,在连接日志里给出完整报告。

仪表板上的延迟读数,以前靠定时器开一个短命的 TCP 探测。现在它直接读取活动 SSH 连接从自身服务请求流量中测出的往返时间。在一台线上主机上,ICMP 平均 93.062 ms,Traversio 在那条已经打开的连接上报告 91.664 到 94.988 ms。

Traversio 采用 AGPL-3.0-or-later,同时由 GitSwift LLC 提供单独的商业许可。源码、确定性测试和协议文档在 github.com/GitSwiftHQ/Traversio,API 文档在 traversio.org。

对以上内容有疑问:[email protected]