不能读源码的协议实现——把 mosh 从抓包里重新推导一遍
官方 mosh 是 GPLv3,Shellby 是闭源商业 App,不能读也不能抄。于是依据论文加自行抓包重新推导 SSP 协议:字段号全部靠实测,猜错一个就能把 mosh-server 直接搞崩;而且同一套协议要在 Swift 和 Dart 上逐字节对齐。
SSH 跑在 TCP 上,IP 一变连接就死。iOS App 切到后台 20 到 30 秒就被系统挂起,回前台必须重连——而重连意味着开一条新的远端 shell,服务端进程状态全丢:vim 关了,编译断了。
mosh 解决的正是这个。它的 SSP 协议在 UDP 上同步终端状态而非字节流,每个数据报独立认证,天然免疫 IP 变更和长时挂起。Shellby 接入之后,iOS 回前台可以瞬间恢复到服务端的真实当前状态。
先把边界说清楚:mosh 不能让 iOS App 后台常驻,普通 App 该被挂起还是被挂起。它给的是「回前台瞬间恢复且状态不丢」,不是后台保活。这条写进了我们的文案红线,对外一律不得宣称后台保活。
问题是,这套协议我不能照着官方实现写。
一堵法律墙
官方 mosh 是 GPLv3,Shellby 是闭源商业 App。这两者不兼容。
常被引用的 COPYING.iOS 例外,我见过不止一次被误读成「iOS 上可以随便用」。它豁免的只是 GPLv3 与 Apple ToS 之间的冲突,copyleft 义务原封不动地保留着。Blink Shell 能直接用官方 mosh,是因为它自己整体开源——那是同一套规则下的另一个解法,不是给闭源 App 开的口子。
所以只剩一条路:clean-room 自研。
- 依据 USENIX ATC’12 的 mosh 论文 加自行抓包实测重新推导协议;
- 不读、不抄 GPL 源码,不复制 mosh 的
.proto文件; - 加密用 AES-128-OCB3。OCB3 的专利已于 2021 年由作者放弃、进入公有领域,闭源商用无专利风险;RFC 7253 的测试向量来自 IETF 公开材料,可以安全使用。
论文给了设计思路,但论文不写字段号。所有线格式细节只能靠抓包一点点还原。
字段号必须实测,猜是要出事的
推导出来的分层是这样的,由内到外:
protobuf → zlib(标准封装 0x78 0x9C) → 分片 → Fragment 头(在加密内部)
→ Packet 时间戳头 → OCB3 → UDP
Nonce(96b) = 4 字节 0x00 || be64(direction<<63 | seq) // TO_SERVER=0, TO_CLIENT=1
UDP 载荷 = be64(direction<<63|seq) || OCB3_Encrypt(key, nonce, AD=∅, plaintext)
明文 Packet = be16(timestamp) || be16(timestampReply) || fragmentBytes
Fragment = be64(id) || be16(fragmentNum | 0x8000 if final) || payload
而实测出来的 protobuf 字段号,和我事先的任何一种猜测都不一样:
TransportInstruction: 1=protocolVersion(恒 2) 2=oldNum 3=newNum 4=ackNum
5=throwawayNum 6=diff(bytes) 7=chaff(bytes)
diff(HostMessage): .1=repeated Instruction → .2=HostBytes → .4=hoststring
UserMessage: .1 → .2=Keystroke → .4=keys
.1 → .3=ResizeMessage → {5=width, 6=height}
注意 ResizeMessage 的 width 和 height 是 5 和 6,不是直觉上的 1 和 2。这个细节不是抠字眼——猜错就会把 mosh-server 直接搞崩。填错字段号,服务端解析出来的终端尺寸是 0×0,然后以 Error: vector 崩溃退出。调试期间我实际把服务端搞挂过两次才定位到这里。
这类崩溃的麻烦之处在于,它长得不像协议问题:你只看到远端进程没了,而 UDP 层一切正常。现在这条由测试钉死。
protobuf 是手写的编解码。好消息是线上真正用到的只有三种情况:varint、length-delimited、重复字段号。proto2 里的 extend 在线格式上根本不存在——它等价于「字段号 N 的一个嵌套消息」,理解这一点之后,嵌套结构就没有神秘感了。
心跳间隔也是实测出来的:3.005 秒。
同一套协议,两个技术栈,逐字节对齐
Shellby 是六端:Apple 三端走 Swift,Android / Windows / Linux / 鸿蒙走 Flutter。mosh 实现必须在两个栈上都有,而且产出的字节必须完全一致——它们要跟同一个 mosh-server 对话。
分层是对称的:
| 层 | Swift | Dart |
|---|---|---|
| 纯核心(零 IO、注入时钟与压缩器) | Sources/MoshCore/ |
packages/mosh_core/ |
| 有 IO(UDP / zlib / bootstrap / 桥接) | Sources/MoshClient/ |
packages/mosh_client/ |
MoshCore 里是 Crypto/(AES128 · OCB3 · Nonce · CryptoSession)、Wire/(Varint · TransportInstruction · HostMessage/UserMessage · Packet · Fragment · FragmentAssembler)、Transport/(RTTEstimator · ClientCore)。
铁律是核心零 IO,时钟和压缩器一律注入。这样协议逻辑就是一个纯函数:(状态, 输入, now) → (新状态, 输出效果),可以被同一批 fixture 完全驱动,两栈跑同一组向量比对输出。
压缩器要注入,是因为踩过一次:deflate 的输出无法跨 zlib 实现保证逐字节相同。Dart 的 dart:io 内置的是 Chromium 版 zlib,它 level 6 的输出比系统 zlib 少 1 个字节。这件事在跨栈同步那篇里逼我改过一次契约,这里的处理是让 fixture 用 identity 压缩器,把 zlib 差异隔离在被测范围之外——加密和 wire 层的对齐才是要验的东西,不该被压缩实现的差异污染。
最终的验证是拿真实的 mosh 1.4.0 抓包来跑:47 个包全部解密成功,零认证失败。到这一步才敢说协议推导是对的。
商标是另一条红线
技术之外还有一条,而且踩了同样会出事:命名。
Mosh 是商标。Termius 在 2017 年被 mosh 作者要求过命名,这是公开的前车之鉴。所以我们定了一条铁律:面向用户的文案一律写「Mosh 兼容 / Mosh 相容 / Mosh-compatible」,绝不出现孤立的 “Mosh”。内部标识符(MoshSession 之类)不受限制,那不是面向用户的表述。
光靠自觉是守不住的——文案分散在 xcstrings 和 ARB 里,六个端、三种语言。所以这条规则落成了 CI lint:tools/mosh-naming-lint.sh 扫描所有本地化资源,发现孤立的 Mosh 就让构建失败。事实证明是必要的:它先是揪出了隐私门文案里漏网的一处;后来把鸿蒙的资源文件也纳入扫描范围,应用标语里又抓出一处。
值得注意的是第二次——问题不是规则没执行,而是扫描范围没跟上新平台。加一个端,就等于给所有全局规则开了一个不受检查的新地盘。规则的覆盖面需要和代码库一起长。
全局只有一个刻意的豁免:主机和会话列表上的传输方式徽标。它是一个小号文字 chip,内容是 SSH 或 Mosh,两者并排出现——这个语境下它显然是协议名而不是产品名,而协议名不翻译。实现上它用 Text(verbatim:) 和硬编码字面量写死,不进 xcstrings/ARB,所以天然落在 lint 的扫描范围之外。悬停提示和无障碍文案仍然写全「Mosh 兼容」。
这个豁免的理由写在 lint 脚本的头部注释里。规则的例外必须和规则写在同一个地方,否则半年后没人记得当初为什么放过它,要么误删,要么被当成先例扩大。
回头看
一个法律约束——「不能读源码」——最后塑造了整个工程方法:必须抓包,必须实测每一个字段号,必须用真实服务端做互操作验证。
有意思的是,这条路走下来的结果未必更差。照着源码抄,我大概不会知道 ResizeMessage 填错字段号会让服务端崩溃,不会实测出心跳是 3.005 秒,也不会有那 47 个包的解密验证。被迫从外部黑盒重新推导,反而逼出了一套比“抄对了”更强的验证方式。
留言