Lirafy博客Wendo ↗
简体中文

博客

Flutter SSH 客户端:我们如何把 SSH、Mosh 和 Eternal Terminal 装进应用

Wendo 的 Flutter SSH 客户端并不是用 Dart 写的。应用通过平台通道和我们自己的插件 packages/wendo_ssh 通信,每个系统各用自己的库:Android 上是 JSch,iOS 上是 NMSSH(libssh2)。Mosh 和 Eternal Terminal 没有现成的库可用,两个协议都是我们自己用 Kotlin 实现的,目前只在 Android 上有。下面讲讲它是怎么搭起来的、一路上解决了哪些问题,以及还有哪些妥协。

用 Flutter 实现 SSH 客户端:一个契约很小的原生插件

插件放在 packages/wendo_ssh,Dart 这一侧很薄:一个 MethodChannel 负责下发指令,一个 EventChannel 负责接收服务器发来的一切。

Wendo 的 SSH 内部结构:Dart 写的应用通过平台通道和 wendo_ssh 插件通信,插件在 Android 上用 JSch、Mosh 和 ET,在 iOS 上用 NMSSH
static const _methods = MethodChannel('wendo_ssh');
static const _events = EventChannel('wendo_ssh/events');

Future<ConnectResult> connect(ConnectOptions options) async {
  final map = (await _call<Map>('connect', {'options': options.toMap()}))!;
  ...
}

connect、write、resize、exec、forwardPort 和 disconnect 对三种传输方式都一样。事件(onData、onClose、onTransportStatus)来自同一条原生事件流,由 WendoSsh 按名称分发。有两个细节很省心:服务器发来的字节以 Uint8List 的形式到达 Dart,中间不经过 base64;错误以带 code 的 SshException 到达(ERR_AUTH、ERR_MOSH_UDP、ERR_ET_GONE……),界面再把它翻译成有用的提示。

在原生一侧,所有会阻塞的操作都不在主线程上跑:Android 用 Dispatchers.IO 上的协程,iOS 用一个专用的 DispatchQueue(wendo.ssh.io)。事件总是回到主线程发出,这是 EventChannel 的要求。iOS 上还得加一个函数,把包在 Any 里的 Optional 解开,因为通道的编解码器不接受它们。

Android 上用什么实现 SSH:JSch

Android 上我们用的是 JSch 仍在维护的分支(com.github.mwiede:jsch),搭配 BouncyCastle。Android 会忽略 META-INF/versions,所以 JSch 最终用的是它的 Java 8 类,而这些类把 ed25519、curve25519 和 ML-KEM 交给 BouncyCastle 处理。没有这个依赖,应用生成的 ed25519 密钥就用不了。

有个问题只在 release 构建里出现。JSch 和 BouncyCastle 按名称加载类,R8 看不到有人用它们,就把它们删掉了。如果你搜过“Flutter SSH 客户端”,大概在 ssh2 包的页面上读到过同一个问题(com.jcraft.jsch.jce.Random 的 ClassNotFoundException)。在 wendo_ssh 里,这些规则写在 consumer-rules.pro 中,所以使用这个插件的应用不用改任何东西就能继承它们。

SshSession.kt 里还有三个在任何示例里都看不到的细节:

  • JSch 每次调用 getInputStream() 都会新建一条管道。必须在 channel.connect() 之前取,否则服务器最先发来的内容(提示符、MOTD)就丢了。
  • sshd 每个连接最多允许 10 个通道(MaxSessions),shell、端口转发和 agent 已经占掉了一些。同时跑很多个 exec 时会报“channel is not opened”,所以我们限制了并发数。
  • 每 20 ms 轮询一次 available() 来读取,速度连 100 KB/s 都不到,而聊天视图要读几百 KB 的对话记录。后来改成了阻塞读取。

iOS 上用什么实现 SSH:NMSSH

iOS 上的 SSH 用的是 NMSSH 2.3,内置 libssh2 1.8.0。应用在 Podfile 里用 :modular_headers => true 声明它,这样才能从 Swift 导入。libssh2 版本旧是有后果的。agent 转发需要 LIBSSH2_CALLBACK_AUTHAGENT 回调,它是 libssh2 1.9.0 才加入的;NMSSHAccessors.m 只在头文件定义了它时才注册,否则编译成一个空函数。NMSSH 把 libssh2 的原始通道存在一个私有属性里,为了拿到它,同一个文件用 ivar_getOffset 读取这个实例变量。

我们还得用 Swift 写一个 bcrypt_pbkdf(BcryptPbkdf.swift,照着 OpenSSH 的实现),用来解密带口令保护的 OpenSSH 私钥。另外,NMSSH 会在返回命令结果之前关闭通道,所以 libssh2_channel_get_exit_status 几乎总是返回 0:代码改为检查 NMSSH 的 error,免得失败的命令看起来像成功了。

arm64 模拟器

NMSSH 的库没有 arm64 模拟器版本,而 Apple Silicon 的 Mac 用的正是这个。解决办法在 Podfile 的 post_install 里:设置 WENDO_SSH_SIM_LIBS=1 时,它会把 NMSSH 的 .a 换成为 arm64-ios-simulator 编译的 libssh2 1.11.1 和 OpenSSL 3.4.0(放在 packages/wendo_ssh/ios/libs 里),并在所有 target 中排除 x86_64。

(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug

这些库带有 agent 回调,所以在模拟器上转发是能用的。真机上不会链接它们:编译时不设这个变量。

Flutter 里的 Mosh:用 Kotlin 写成,只在 Android 上

Mosh 只在启动时用到 SSH。Wendo 用 JSch 打开一个连接,然后这样启动服务器(MoshBootstrap.command):

sh -lc 'PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin"; export PATH; echo "WENDO_SSH_CONNECTION=$SSH_CONNECTION"; exec mosh-server new -s -c 256 -l LANG=en_US.UTF-8 --'

整条命令用单引号传给 sh,因为用户的 shell 可能是 fish。$SSH_CONNECTION 那一行是给“UDP 使用服务器 IP”选项用的。如果服务器没有 en_US.UTF-8 这个 locale,就换成 C.UTF-8 重试。从输出里读出 MOSH CONNECT <端口> <密钥>,之后一切都走 UDP。

客户端用 Kotlin 实现了 Mosh 1.4 的协议,照着 network.cc 和传输层的那些文件写:用 BouncyCastle 实现的 AES-128-OCB 数据包(MoshCrypto.kt)、手写的四条必需的 protobuf 消息(MoshProto.kt)、按 MTU 分片、确认、重传,以及端口跳变。一段时间内没有往返时,客户端会用另一个源端口开一个新 socket,服务器一看到新地址发来的包就跟过去。靠这个,从 Wi-Fi 切到移动数据时会话不会断。

屏幕是最容易踩坑的部分。Mosh 发送的不是应用的字节流,而是两帧屏幕之间的差异,这个差异只有在它所基于的那个状态上才有效。MoshScreen.kt 维护一个影子屏幕,只解释 Display::new_frame 输出的那套方言,并不是完整的终端模拟器。在它之上是 MoshPrediction.kt,即我们自己实现的预测回显(PredictionEngine),支持 adaptive、always 和 never 三种模式:每次应用服务器的 diff 之前要先撤掉预测,之后再重新画上,而且要在同一个块里完成,这样 xterm 才不会显示半截的画面。

还有两个细节。mosh-server 默认终端以应用模式发送方向键,所以 cursorKeysToSs3 会把 ESC [ A 换成 ESC O A,不然 vim 或 tmux 认不出来。如果第一个 UDP 包没到(选“自动”时等 6 秒,手动选 Mosh 时等 12 秒),就杀掉刚启动的 mosh-server,错误提示会让你检查防火墙。

JVM 测试(MoshServerTest.kt)会和本地一个真实的 mosh-server 1.4 通信,其中包括一条模拟链路,每个方向 120 ms 延迟,用来验证打完字之后 xterm 显示的正好是服务器的屏幕。

Eternal Terminal,以及会被系统杀掉的应用

Mosh 会话会随应用进程一起结束,这一点任何设计都避免不了。服务器不保存任何可以用来追上进度的东西,而 OCB 的 nonce 就是数据包的序号,每个进程都从 0 开始。恢复会话就会用同一个密钥重复 nonce,在 OCB 中这会泄露明文并破坏认证。所以 Wendo 从不保存 Mosh 的状态。

Eternal Terminal 7.0 是可以恢复的,它的客户端也是 Kotlin 写的:EtClient.kt,加密用的是基于 BouncyCastle 搭建的 crypto_secretbox(XSalsa20-Poly1305)。TCP 断开时,它每秒重连一次,双方各自报告读了多少个包,好让对方把缺的补发过来。为了在 Android 杀掉应用之后还能活下来,EtStateStore.kt 在应用的私有文件里保存会话的 id 和密钥(用 Android Keystore 中的一把 AES-256-GCM 密钥封存),以及每个加密包的记录,这条记录在包从 socket 发出之前就写好。ET 的 nonce 是一个不允许跳号的连续计数器,所以正是这条提前写下的记录保证了下一个 nonce 是对的。重新打开应用时,restoreRecoverableSessions() 把原生层保存的内容和 Dart 里保存的卡片对上,会话就重新出现在“首页”。在应用里,ET 仍然标为实验性功能。

Mosh 和 ET 还需要一条 SSH 通道来跑 exec 和端口转发。SideConnection.kt 保留启动时的那条连接,但每次使用前都会检查它;如果它在网络切换时断了,就用同样的主机指纹开一条新的,并在原来的本地端口上恢复转发。

Dart 这一侧做了什么

lib/core/sessions.dart 会保存每个会话的输出(最多 512 KB),即使没有打开终端也一样,这样回来时可以重新绘制。用 Mosh 时,收到完整的一屏(reset)就丢掉之前的内容。onTransportStatus 事件带有 silentMs 和 rebindInMs,界面据此提示服务器没有响应,而不是关闭会话。如果你关掉“后台保持连接”,应用进入后台时 SSH 会话会被关闭;Mosh 和 ET 的会话会保留,因为断开它们会杀死远端会话。

“自动”模式写在 openSession() 里:先试 Mosh,然后只有在配置了端口或者 2022 端口有响应时才试 ET(免得叠加长时间等待),最后是 SSH。使用跳板机(Jump Host)或 agent 转发时直接走 SSH;认证错误或指纹错误不会让它退到下一个,因为换成 SSH 也一样会失败。

还剩下的妥协

  • iOS 上只有 SSH。请求 Mosh 或 ET 会返回 ERR_MOSH_UNSUPPORTED 或 ERR_ET_UNSUPPORTED,“自动”会直接改用 SSH。
  • Mosh 和 ET 都不支持跳板机。
  • Mosh 只同步屏幕上看得到的内容:想要往回翻的历史,就在 tmux 里工作,文档里有说明。
  • Mosh 和 ET 属于 Wendo Pro;没有 Pro 时,limitToPlan 会把连接限制为 SSH。

如果你想要的是选一种传输方式,在手机上跟进 Claude Code,而不是了解它怎么实现,可以看实用对比手机上的 Mosh 与 SSH,每个连接字段的说明在抗断线会话的文档里。应用怎样用同一套 SSH 来传递通知、不靠自己的服务器,我们写在了无后端的手机应用里。