← 全部文章

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-client 一份屏幕副本 本地预测回显 心跳 · 重传 · 漫游 终端界面 键盘输入 · 屏幕显示 服务器 mosh-server 真正的屏幕状态 内置终端模拟器 监听 UDP 60001 起 shell / tmux / vim 跑在伪终端 pty 里 按键 · 窗口大小 加密 UDP(SSP 协议) 屏幕差分 SSH:只在开头登录一次 手机 / 电脑 mosh-client 一份屏幕副本 本地预测回显 心跳 · 重传 · 漫游 终端界面 键盘 · 屏幕 服务器 mosh-server 真正的屏幕状态 内置终端模拟器 监听 UDP 60001 起 shell tmux · vim 跑在 pty 里 ↓ 按键 · 窗口大小 ↑ 屏幕差分 加密 UDP(SSP 协议) SSH:只在开头登录一次
图 1:mosh 的两端。SSH 只负责“开门”,之后的终端流量全部走加密 UDP。

mosh 由两个程序组成:

  • mosh-server 跑在服务器上。它在伪终端(pty)里启动你的 shell,把程序的输出喂给自己内置的终端模拟器,于是它随时知道“此刻屏幕应该长什么样”。
  • mosh-client 跑在你的手机或电脑上。它把按键发给服务器,收到屏幕变化后更新手里的副本,再画到你的终端里。

平时敲的 mosh user@server 其实是一个 Perl 脚本,它先用 SSH 登录把 mosh-server 启动起来,再把自己替换成 mosh-client。所以服务器上要装 mosh,但不需要 root 权限,也不需要常驻的守护进程。

第一步:借 SSH 登录,拿到端口和密钥

mosh(客户端) sshd mosh-server ① ssh user@server TCP 22,普通的 SSH 登录 ② 运行 mosh-server new 绑定 UDP 端口 生成 128 位随机密钥 ③ MOSH CONNECT 60001 4NeCCgvZFe2RnPgrcU1PQw 经 SSH 通道送回,别人看不到 ④ SSH 退出,mosh-server 转入后台 ⑤ 第一个加密 UDP 包(用拿到的密钥) ⑥ 屏幕状态……之后只走 UDP 客户端 sshd mosh-server ① ssh 登录 TCP 22 ② mosh-server new 绑定 UDP 端口 生成随机密钥 ③ MOSH CONNECT 60001 4NeCCgvZFe2RnPgrcU1PQw 经 SSH 通道送回 ④ SSH 退出,mosh-server 转入后台 ⑤ 第一个加密 UDP 包 ⑥ 屏幕状态……之后只走 UDP
图 2:启动过程。端口和密钥搭 SSH 的便车送到客户端,登录认证完全复用 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 Instruction { old_num, new_num, ack_num, throwaway_num, diff } “把你手里的第 old_num 号状态,用 diff 变成第 new_num 号” 压缩与分片 protobuf 编码 → zlib 压缩 → 切成几百字节的小片,避免 IP 分片 数据报层 AES-128-OCB 加密认证 · 序号 · 时间戳测往返时间 认准最新合法包的来源地址,实现漫游 UDP / IP 用户输入流 客户端 → 服务器 每个按键都要送到 屏幕状态 服务器 → 客户端 中间画面可以跳过 传输层 SSP old_num · new_num · ack_num throwaway_num · diff 把第 old_num 号状态用 diff 变成 new_num 号 压缩与分片 protobuf → zlib → 几百字节的小片 数据报层 AES-128-OCB 加密 · 序号 · 时间戳 认准最新合法包的来源地址(漫游) UDP / IP
图 3:mosh 的协议栈。没有 TCP,可靠性由上面的 SSP 自己负责。

从下往上看:数据报层保证“包是对方发的、没被改过”,并负责找到对方;分片层把大的更新拆小;SSP 负责“双方最终一致”;最上面是真正要同步的两样东西。下面挨个讲。

数据报层:加密、序号、测速和漫游

一个 UDP 包里有什么

明文 加密(AES-128-OCB) 认证 nonce 8 字节 时间戳 2 字节 回应时间戳 2 字节 分片头 10 字节 分片数据 压缩后的 Instruction 标签 16 字节 最高位:方向 其余 63 位:序号 发送时刻 (毫秒) 对方的时间戳 + 停留时间 指令编号 片号 · 末片标记 一条指令可能 拆成好几片 改动任何一位 都会校验失败 收到包先验标签:不是用会话密钥加密的,直接丢弃,不回任何东西。 加密(AES-128-OCB) 明文 / 认证标签 nonce 8 字节 最高位:方向;其余 63 位:序号 时间戳 2 字节 发送时刻(毫秒) 回应时间戳 2 字节 对方的时间戳 + 停留时间 分片头 10 字节 指令编号 · 片号 · 末片标记 分片数据 不定长 压缩后的 Instruction,可拆成多片 标签 16 字节 改动任何一位都会校验失败 收到包先验标签: 验不过直接丢弃,不回任何东西。
图 4:mosh 数据报的格式。除了开头 8 字节,其余内容都被加密。
  • nonce:OCB 模式要求每个包的 nonce 不重复。mosh 用“方向位 + 递增序号”当 nonce:客户端发的最高位是 0,服务器发的是 1,两个方向各自从 0 数起,永远不会撞车。序号顺便告诉接收方哪个包更新。
  • 时间戳:类似 TCP 的时间戳选项。A 发包时写上自己的毫秒时钟;B 回包时把它填进“回应时间戳”,并加上自己扣着这个包的时间。A 收到后一减,就得到往返时间(RTT),再按 TCP 的公式算出平滑 RTT 和重传超时。后面的发送节奏、预测回显都依赖这个数。
  • 认证标签:OCB 同时完成加密和防篡改。端口扫描、伪造包、被改过的包,标签都对不上,会被直接丢弃。

漫游:服务器只认最新的合法包

手机 mosh-client 服务器 mosh-server Wi-Fi 出口 203.0.113.7 旧路径:走出 Wi-Fi 就不通了 蜂窝网络 出口 198.51.100.24 序号 1042 通过认证 + 序号最大 → 改往新地址回包 手机 mosh-client 服务器 mosh-server Wi-Fi 出口 203.0.113.7 旧路径:已不通 蜂窝网络 出口 198.51.100.24 序号 1042 通过认证 + 序号最大 → 改往新地址回包
图 5:漫游。服务器不记“连接”,只记“最近一次收到合法新包的地址”。

TCP 连接由四元组(双方 IP 和端口)确定,手机 IP 一变,连接就作废了。mosh 的服务器没有这种绑定:每收到一个通过认证、序号比之前都大的包,就把回包地址改成这个包的来源地址。手机从 Wi-Fi 换到 4G,下一个包从新地址发出,服务器验完标签就跟着改,用户什么都感觉不到。

攻击者没有密钥,造不出能通过校验的包;把旧包录下来重放,序号不是最新的,不会让服务器改地址,而且 SSP 的更新本身是幂等的,重复收到也不会出错。

为了让漫游及时生效,客户端即使无事可做也每 3 秒发一次心跳,NAT 映射也因此不会过期。如果客户端超过 10 秒没收到服务器的任何包,它会换一个新的本地 UDP 端口再发,用来对付 NAT 映射被路由器悄悄回收、旧端口再也收不到回包的情况。

状态同步协议 SSP:只追最新的屏幕

为什么 TCP 字节流不适合

SSH 跑在 TCP 上:字节流,按顺序一个都不能少 客户端 服务器 1 2 3 4 5 6 7 8 … cat 一个大日志:几 MB 输出已经在管道和缓冲区里排队,按 Ctrl-C 后还得等它们全部送完 mosh:服务器只把对方手里的状态直接改到最新 客户端 持有 #1 服务器上的屏幕,每次输出都生成新状态 #1 #2 #3 #4 #5 #2–#4 不用发 diff:#1 → #5 SSH(TCP):字节流,一个都不能少 客户端 服务器 1 2 3 4 … cat 大日志:几 MB 输出已在排队, 按 Ctrl-C 后还得等它们送完 mosh:直接改到最新状态 #1 #2 #3 #4 #5 #2–#4 不用发 服务器上的屏幕,每次输出一个新状态 diff:#1 → #5 客户端 持有 #1,收到后变成 #5
图 6:字节流必须一个不落地按顺序送达;状态同步只关心“对方手里是哪一版、最新是哪一版”。

终端用户真正关心的是“现在屏幕上是什么”,而不是“中间每一帧是什么”。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 的客户端会猜:大多数时候,在命令行里敲一个普通字符,它就会出现在光标处,光标右移一格。于是客户端不等服务器,先把字符画上去。

① 按下 l、s $ ls 还没收到回显,先画上去 网慢时加下划线提示“这是猜的” ② 服务器确认 $ ls 服务器的屏幕里也出现了 ls 下划线去掉,预测转正 ③ 猜错了,比如输入密码 Password: 服务器确认收到了按键,屏幕上却没有 撤回预测,一切以服务器为准 靠 EchoAck 判断:输入送达约 50 毫秒后,服务器屏幕上该出现的回显应该已经出现了。 $ ls ① 按下 l、s 还没收到回显,先画上去 网慢时加下划线:这是猜的 $ ls ② 服务器确认 服务器屏幕里也有 ls 下划线去掉,预测转正 Password: ③ 猜错了(如输入密码) 按键已确认,屏幕上却没有 撤回预测,以服务器为准 靠 EchoAck 判断: 输入送达约 50 毫秒后,回显应已出现
图 7:预测回显。猜对了让打字跟手,猜错了也只是短暂闪一下,最终屏幕总是服务器那一份。

它怎么知道猜对没有?靠服务器发来的 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 一节。

参考资料