mosh 是怎么工作的:图解客户端、服务器和它们之间的协议
mosh(mobile shell)是 MIT 的 Keith Winstein 等人在 2012 年发布的远程终端。它和 SSH 最大的不同只有一句话:SSH 传的是字节流,mosh 同步的是屏幕状态。服务器上维护一块“真正的屏幕”,客户端手里有一份副本,双方不停地把副本对齐。换网络、锁屏、丢包,都只是让副本晚一点对齐,连接本身不会断。
下面从整体结构讲起,再按登录、数据报层、状态同步、预测回显的顺序拆开。细节按 mosh 1.4.0 的源码和 mosh 论文《Mosh: An Interactive Remote Shell for Mobile Clients》核对。
整体结构:两个程序、两条通道
mosh 由两个程序组成:
- mosh-server 跑在服务器上。它在伪终端(pty)里启动你的 shell,把程序的输出喂给自己内置的终端模拟器,于是它随时知道“此刻屏幕应该长什么样”。
- mosh-client 跑在你的手机或电脑上。它把按键发给服务器,收到屏幕变化后更新手里的副本,再画到你的终端里。
平时敲的 mosh user@server 其实是一个 Perl 脚本,它先用 SSH 登录把 mosh-server 启动起来,再把自己替换成 mosh-client。所以服务器上要装 mosh,但不需要 root 权限,也不需要常驻的守护进程。
第一步:借 SSH 登录,拿到端口和密钥
脚本执行的命令大致是:
ssh -t user@server -- mosh-server new -s -l LANG=en_US.UTF-8
mosh-server 从 60001 开始找一个空闲的 UDP 端口(范围 60000–61000),生成一把随机的 AES-128 密钥,把下面这行打印到标准输出,然后脱离终端转入后台:
MOSH CONNECT 60001 4NeCCgvZFe2RnPgrcU1PQw
[mosh-server detached, pid = 12345]
22 个字符是 base64 编码的 128 位密钥。客户端从 SSH 输出里读到这一行,SSH 随即退出,脚本再运行:
MOSH_KEY=4NeCCgvZFe2RnPgrcU1PQw mosh-client 203.0.113.5 60001
这样设计的好处是:密码、公钥、两步验证这些认证工作全交给成熟的 SSH,mosh 自己不用再做一套;密钥只在加密的 SSH 通道里出现过一次,每个会话都不一样。
注意 mosh-server 此时并不知道客户端在哪儿,它只是守着端口,等第一个用这把密钥加密、能通过校验的 UDP 包。
协议分几层
SSH 退出以后,双方之间只剩 UDP。mosh 在 UDP 上叠了几层,每层只管一件事:
从下往上看:数据报层保证“包是对方发的、没被改过”,并负责找到对方;分片层把大的更新拆小;SSP 负责“双方最终一致”;最上面是真正要同步的两样东西。下面挨个讲。
数据报层:加密、序号、测速和漫游
一个 UDP 包里有什么
- nonce:OCB 模式要求每个包的 nonce 不重复。mosh 用“方向位 + 递增序号”当 nonce:客户端发的最高位是 0,服务器发的是 1,两个方向各自从 0 数起,永远不会撞车。序号顺便告诉接收方哪个包更新。
- 时间戳:类似 TCP 的时间戳选项。A 发包时写上自己的毫秒时钟;B 回包时把它填进“回应时间戳”,并加上自己扣着这个包的时间。A 收到后一减,就得到往返时间(RTT),再按 TCP 的公式算出平滑 RTT 和重传超时。后面的发送节奏、预测回显都依赖这个数。
- 认证标签:OCB 同时完成加密和防篡改。端口扫描、伪造包、被改过的包,标签都对不上,会被直接丢弃。
漫游:服务器只认最新的合法包
TCP 连接由四元组(双方 IP 和端口)确定,手机 IP 一变,连接就作废了。mosh 的服务器没有这种绑定:每收到一个通过认证、序号比之前都大的包,就把回包地址改成这个包的来源地址。手机从 Wi-Fi 换到 4G,下一个包从新地址发出,服务器验完标签就跟着改,用户什么都感觉不到。
攻击者没有密钥,造不出能通过校验的包;把旧包录下来重放,序号不是最新的,不会让服务器改地址,而且 SSP 的更新本身是幂等的,重复收到也不会出错。
为了让漫游及时生效,客户端即使无事可做也每 3 秒发一次心跳,NAT 映射也因此不会过期。如果客户端超过 10 秒没收到服务器的任何包,它会换一个新的本地 UDP 端口再发,用来对付 NAT 映射被路由器悄悄回收、旧端口再也收不到回包的情况。
状态同步协议 SSP:只追最新的屏幕
为什么 TCP 字节流不适合
终端用户真正关心的是“现在屏幕上是什么”,而不是“中间每一帧是什么”。TCP 不知道这一点:它会把所有字节按顺序送到,丢一个包后面的全都得等;网络慢的时候,发送缓冲区里积压的旧输出会让 Ctrl-C 迟迟不生效。
SSP(State Synchronization Protocol)换了个思路:双方各自给状态编号,每次只发“从你手里那一版到最新一版”的差分。网络慢,就少发几次、每次跨度大一点;丢了包,下一次差分自然会把漏掉的内容带上,不需要逐个重传旧包。
一条指令里有什么
SSP 来回传递的是一条条 Instruction(protobuf 编码):
| 字段 | 含义 |
|---|---|
old_num | 差分的基准:“在你手里的第几号状态上改” |
new_num | 改完之后是第几号状态 |
diff | 差分内容。为空时这条指令只是心跳或确认 |
ack_num | 捎带确认:“你发来的状态,我已经收到第几号了” |
throwaway_num | “比这号更旧的状态我不会再拿来当基准了,你可以扔掉” |
protocol_version | 协议版本,目前是 2,两端不一致就拒绝连接 |
chaff | 随机填充字节,让包的长度不那么容易泄露输入内容 |
接收方收到一条指令,先看自己有没有 old_num 这一版:有,就把 diff 应用上去得到 new_num;没有(比如基准那一版的包丢了),就当没收到,等发送方重发。为了让这一步更快,发送方会乐观地假设最近发出的状态已经到达,直接在它上面做差分;猜错了也没关系,超时以后它会退回到对方确认过的那一版重新算。throwaway_num 让接收方可以放心地删掉旧状态,内存不会越积越多。
发送节奏
SSP 不是一有变化就立刻发包,而是按网络状况控制“帧率”:
- 两次发送之间至少隔 RTT 的一半,并夹在 20–250 毫秒之间。网络好时最多每秒约 50 帧;网络差时自动降速,不会把排队的旧画面塞满链路。
- 服务器发现屏幕有变化后会先等几毫秒,把程序紧接着的输出攒在一起再发,避免一次
ls拆成好几个包。 - 确认(ack)最多延迟 100 毫秒,能捎带在数据包里就不单独发。
- 什么都没有时,每 3 秒一个心跳,双方都借此知道对方还在。
两个方向,两种“状态”
SSP 是通用的,同步什么由上层决定。mosh 里两个方向同步的东西不一样:
| 方向 | 同步的对象 | diff 里装的是 | 能跳过中间状态吗 |
|---|---|---|---|
| 客户端 → 服务器 | 用户输入流 | 从基准以来的按键(Keystroke)和窗口大小变化(ResizeMessage) | 不能,每个按键都要原样送到 |
| 服务器 → 客户端 | 整块屏幕:文字、颜色、光标、标题 | 一串终端转义序列,能把旧屏幕画成新屏幕(HostBytes);外加窗口大小(ResizeMessage)和回显确认(EchoAck) | 能,只要最后一版对 |
服务器那边的差分很巧妙:它拿客户端手里那版屏幕和最新屏幕比较,生成一段最短的转义序列,比如“光标移到第 3 行第 10 列,写 done”。客户端把这段序列喂给自己的终端模拟器,副本就和服务器一致了。
本地预测回显:网慢也能跟手打字
SSH 里每敲一个字,都要等服务器把它回显回来才能看到。往返 300 毫秒的网络上,打字就像隔着一层胶水。mosh 的客户端会猜:大多数时候,在命令行里敲一个普通字符,它就会出现在光标处,光标右移一格。于是客户端不等服务器,先把字符画上去。
它怎么知道猜对没有?靠服务器发来的 EchoAck:服务器收到某一号输入状态约 50 毫秒后,会告诉客户端“到第 N 号输入为止,该有的回显都已经反映在屏幕里了”。客户端拿到这个确认,就去对比服务器发来的屏幕:预测的字符真的出现了,预测转正;没出现(比如密码提示符不回显),就撤掉,并在这一段输入里暂停预测。
几个细节让它不容易添乱:
- 默认的
--predict=adaptive只在 RTT 较高时才显示预测,局域网里你看不到它;RTT 更高时预测的字符带下划线。也可以用always或never强制开关。 - 按下回车、Esc、Ctrl 组合键之类“结果难猜”的键后,会开启新的一轮(epoch)。新一轮的预测先藏着,等服务器确认过一次猜得对,才开始显示。所以在 vim 普通模式里乱跳,也不会满屏错字。
- 预测只是叠在副本上的一层“覆盖”,不会写进副本本身。服务器的屏幕永远是最终答案。
这样设计的代价
同步屏幕而不是字节流,让 mosh 抗断线、抗丢包,但也带来几处和 SSH 不同的地方:
| 现象 | 原因 |
|---|---|
| 没有滚动历史,往上翻看不到旧输出 | 只同步“当前这一屏”,跳过的中间画面客户端根本没收到。需要回看就在里面跑 tmux |
| 没有端口转发、X11 转发、ssh-agent 转发 | SSH 在启动后就退出了,mosh 的协议里只有终端 |
| 防火墙要放行 UDP 60000–61000 | 终端流量走 UDP,只开 TCP 22 的服务器连不上 |
| 客户端消失后 mosh-server 默认一直等 | 服务器分不清“客户端没了”和“手机睡着了”。可以用 MOSH_SERVER_NETWORK_TMOUT 设超时 |
| 两端都要 UTF-8 语言环境 | mosh 的终端模拟器按 UTF-8 解析字符,服务器缺语言包时会报 needs a UTF-8 native locale |
| 长时间没收到服务器的包时,顶部出现 “mosh: Last contact … ago” | 心跳停了,客户端提醒你网络有问题;网络恢复后自动消失,按 Ctrl-^ . 可以直接退出 |
服务器配置、常见报错和 tmux 搭配,见手机 SSH 不断线:mosh 与 tmux 配置指南。
Roamtty 里的 mosh
Roamtty 用 Kotlin 实现了 mosh 客户端这一侧的协议(版本 2):同样先用 SSH 运行 mosh-server new 读取 MOSH CONNECT,同样的 AES-128-OCB 数据报、3 秒心跳和 10 秒换端口,收到的 HostBytes 直接交给应用自己的终端模拟器绘制。服务器那边用的就是官方的 mosh-server,不需要装任何额外的东西。
目前 Roamtty 不做本地预测回显(会忽略 EchoAck)。tmux 模式下 mosh-server 一启动就附着到 tmux 会话,滚动历史由 tmux 补上。8 秒收不到 UDP 回应会自动改走 SSH,具体行为见配置指南的 Roamtty 一节。
参考资料
- Keith Winstein、Hari Balakrishnan,Mosh: An Interactive Remote Shell for Mobile Clients,USENIX ATC 2012
- mosh 官网与源码:
src/network/是数据报层和 SSP,src/protobufs/是上文提到的几种消息,src/frontend/terminaloverlay.cc是预测回显