rz/sz 与 ZMODEM 协议原理:SSH 终端如何在字节流里识别文件传输
「SSH 终端是怎样做成的」系列第 5 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),只做删减。
要点
ZMODEM 是一种文件传输协议,lrzsz 的 rz/sz 实现了它。它直接在终端会话里运行,所以不需要另开连接就能走 SSH。客户端要在普通输出中找出 ZMODEM 帧头,再把 PTY 字节流从终端模拟器转交给协议。MistTerm 只有在看到合法帧头、并且用户刚执行过 sz 或 rz 时才开始传输。
在很多生产服务器上,传文件最快的办法仍然是 sz file.tar.gz 下载、rz 上传。这两个命令出自 lrzsz 工具集,实现的是 ZMODEM:一个 1986 年为调制解调器和串口设计的文件传输协议。它能在 SSH 上用,是因为它从来不需要另开连接,而是直接在终端会话里跑,和其他内容共用同一条 PTY 字节流。
这也正是它让终端头疼的地方。客户端得在一条本来要显示到屏幕上的文本流里,认出传输的开始;然后把这条流从终端模拟器手里接过来,在上面跑一个协议,结束后再干净地交还回去。
ZMODEM 协议原理:够用的部分
协议规范是 Chuck Forsberg 的 ZMODEM 文档。实现里碰到的是这几条:
-
帧以帧头开始。 帧头字节包括
ZPAD(*)和ZDLE(0x18,与 ASCII 的 CAN 同值),后面是格式字节:B表示 hex 帧头,全部由可打印的十六进制字符组成,以 CR LF 结尾。A表示带 CRC-16 的二进制帧头。C表示带 CRC-32 的二进制帧头。
hex 帧头的样子是
**ZDLEB,加两位十六进制帧类型、八位标志和四位 CRC。二进制帧头在规范上只用一个ZPAD。 -
与启动相关的帧类型。
ZRQINIT(类型 0)由发送方发出,请求一个接收方;ZRINIT(类型 1)由接收方发出,表示已就绪并声明自身能力。 - ZDLE 转义。 二进制数据里的 ZDLE 和部分控制字符会被转义,帧在会解释 XON/XOFF 的链路上也能完好通过。
- 取消。 连续五个或更多 CAN(0x18)字节会中止会话。
角色由命令决定:
- 服务器上的
sz是发送方。它先打印rz\r,提示支持自动接收的终端启动接收程序,再发出一个 hex 格式的ZRQINIT。客户端要成为接收方,回复ZRINIT。 - 服务器上的
rz是接收方。它用ZRINIT宣告自己,lrzsz 还会打印一行文字「rz waiting to receive.」。客户端要成为发送方,从文件头(ZFILE)开始发。
终端怎样识别 rz/sz:别把文本当成帧
如果终端一看到 **\x18B00 滚过就变身成文件接收器,那么 cat 一个二进制文件、看日志,甚至有人在 SSH 里阅读这篇文章,都可能误触发。MistTerm 把三种信号结合起来判断。
扫描一段有上限的尾部窗口,而不只是最近一次读到的数据。 帧头可能被拆在两次读取之间(第 2 篇)。会话输出追加到一个检测尾巴上,只保留最近的 512 字节,扫描器在这个窗口上运行:
fn push_zmodem_detect_tail(&mut self, data: &[u8]) {
const TAIL_CAP: usize = 512;
self.zmodem_pty_detect_tail.extend_from_slice(data);
if self.zmodem_pty_detect_tail.len() > TAIL_CAP {
let drop = self.zmodem_pty_detect_tail.len() - TAIL_CAP;
self.zmodem_pty_detect_tail.drain(..drop);
}
}
按结构解析帧头。 扫描器检查完整的前缀(ZPAD ZPAD ZDLE 加格式字节),并解码类型位。实际环境里见到的几种变体它都接受:
- 去掉奇偶位的
ZPAD(0x80)。 - 单
ZPAD和双ZPAD两种二进制帧头。 - 不带 ZDLE 的 ASCII
**B…形式。代码注释说这是 Windows 版 lrzsz 发出的。
fn hex_scan_for_type(data: &[u8], want: u8) -> bool {
// 至少 6 字节:`** ZDLE 'B' HH`(HH 为类型十六进制对)
for i in 0..data.len().saturating_sub(5) {
if !is_zpad(data[i]) || !is_zpad(data[i + 1]) || data[i + 2] != zmodem::ZDLE {
continue;
}
let hb = data[i + 3];
if hb != zmodem::ZHEX && hb != b'B' && hb != b'b' { continue; }
if let Some(t) = hex_pair(data[i + 4], data[i + 5]) {
if t == want { return true; }
}
}
false
}
要求有用户意图。 光有帧头不会启动下载。客户端还会记录用户提交的命令行:首个单词是 sz/lsz(允许带路径和 .exe),就开启 30 秒的下载检测;上传同理,rz/lrz 开启 45 秒:
pub fn is_sz_shell_command(line: &str) -> bool {
let base = shell_word_basename(first_shell_word(line));
let lower = base.to_ascii_lowercase();
matches!(lower.as_str(), "sz" | "lsz" | "sz.exe" | "lsz.exe")
}
下载只在检测到 ZRQINIT 且检测窗口打开时才启动,echo sz、where rz 都不会触发。上传则在以下任一情况下启动:
- 出现 rz 自己的提示文字:「rz waiting to receive.」「Awaiting rz」「rz rz rz」。
- 出现 Windows 的 ASCII
**B01形式。 - 在预期上传期间看到帧头。
文字匹配特意只针对 lrzsz 的提示语,帧头匹配则由用户意图把关。
阅读源码时要注意一个命名问题:这条代码路径上有些注释说 rz「发 ZRQINIT」。按规范,接收方发的是 ZRINIT,ZRQINIT 来自发送方。这一点已列入本系列的勘误说明。
接管字节流
传输开始后,PTY 输出就归协议所有。把 ZMODEM 帧喂给终端模拟器,屏幕上会出现二进制乱码,更糟的是其中的字节可能被当成控制序列解释。交接过程有几条约束:
-
只能有一个读者。 SSH 通道由 shell 泵线程独占(第 2 篇),传输过程不能另开一个读者去和它抢字节,而是由泵线程把 PTY 输出经旁路转给 ZMODEM 引擎。源码注释写道:
上传(本机 sz→远端 rz)时,PTY 上的 ZMODEM 帧只能经 SSH 泵线程到达此处;必须旁路给 lrzsz,不得在另一线程对 Channel 再 read。
-
在弹出文件框之前就开始截获。 上传时,用户还在选文件,远端的
rz就已经开口了。MistTerm 一识别出握手,就立即开始截获(begin_rz_handshake_capture),然后才打开文件选择框。否则接收方的ZRINIT会被终端模拟器吃掉,发送方只能干等一个早已过去的帧头。 - 缓冲有上限。 等待第一个帧头时,以及重组帧的过程中,入站字节最多缓冲 512 KiB(
RX_INCOMING_CAP),超过就中止接收,而不是无限增长。
协议本身的工作,包括帧编码、CRC、ZDLE 转义、ZRPOS/ZACK 交互,由仓库里内置(vendored)的 zmodem2 crate 完成。做对照测试时,设置 MISTTERM_ZMODEM_USE_EXTERNAL_SZ=1,可以让 MistTerm 改为调用本机的 sz 程序,代替内置的发送端。
两个安全细节
文件名由远端决定,要当作不可信输入。 文件名放在发送方 ZFILE 帧头之后的数据子包里。恶意的或者有缺陷的服务器可能发来 ../../.bashrc 或者绝对路径。MistTerm 只保留路径的最后一段,而且从不覆盖已有文件:
fn disambiguate_download_path(download_dir: &Path, raw_name: &[u8]) -> Result<PathBuf, String> {
let base = String::from_utf8_lossy(raw_name);
let only = Path::new(base.trim())
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("zmodem.bin");
let mut file_path = download_dir.join(only);
let mut counter = 1u32;
while file_path.exists() {
// name_1.ext、name_2.ext……
}
Ok(file_path)
}
对以 .. 结尾的名字,Path::file_name 返回 None,于是退回 zmodem.bin。
取消必须插队。 用户取消上传时,会话队列里可能还排着文件数据。rz/sz 运行期间 PTY 处于原始模式,单独一个 Ctrl+C 不一定能让它们停下。所以取消走的是第 2 篇讲过的优先队列:
// ZMODEM 协议态下单个 Ctrl+C 不一定被远端 rz/sz 识别;高优先级发 CAN 序列再补 ETX。
let cancel = vec![
0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, // CAN x8
0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, // erase noise
0x03, // ETX
];
handle.send_priority_interrupt(cancel)
八个 CAN 超过了规范要求的五个。后面的退格仿照 lrzsz 自己的取消串(先 CAN 再退格),如果这一行最后回到了 shell,可以把 CAN 擦掉。最后的 ETX 用来应付远端程序已经退出的情况。
小结
- ZMODEM 跑在终端字节流里,客户端必须在普通输出中找出帧头。
- 识别得三样都在:有上限的扫描窗口、按结构解析帧头,以及用户刚刚提交了
sz/rz。 - 传输期间,PTY 字节流必须经由唯一的通道持有者绕过终端模拟器,缓冲要有上限。
- 发送方给的文件名不可信;取消指令要排在已入队的数据前面。
常见问题
rz/sz 是怎样通过 SSH 传文件的?
ZMODEM 在终端会话内部运行,和普通输出走同一条 PTY 字节流,所以不需要另开连接。终端要识别传输开始,并接管字节流。
ZRQINIT 和 ZRINIT 分别是什么?
ZRQINIT(帧类型 0)由发送方发出,请求一个接收方;ZRINIT(帧类型 1)由接收方发出,表示已就绪并声明自身能力。
为什么 MistTerm 看到 ZMODEM 帧头不会立刻开始下载?
帧头也可能出现在普通输出里,比如用 cat 查看二进制文件。MistTerm 还要求用户刚刚提交过 sz 或 rz 命令。
怎样取消 ZMODEM 传输?
连续五个或更多 CAN(0x18)字节会中止会话。MistTerm 发送八个,并让它排在已排队的文件数据之前。