SSH 中文乱码的根源:locale、SendEnv 与 env 请求

「SSH 终端是怎样做成的」系列第 4 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),只做删减。

要点

SSH 输入中文乱码、退格删掉半个字,通常是因为远端程序运行在 POSIX/C locale 下,readline 等行编辑器把每个字节当成一个字符。locale 通过 RFC 4254 的 env 请求传到服务端,而 sshd 只接受 AcceptEnv 列出的变量。上游 OpenSSH 默认不发送任何变量,Debian 系发行版的软件包加上了 SendEnv LANG LC_* 和对应的 AcceptEnv。解决办法是转发一个有效的 UTF-8 locale,并在服务端放行。

SSH 有个常见的怪现象:服务器输出的中文显示正常,可在 bash 或 mysql 提示符下输入中文,整行就乱了,或者按一次退格只删掉半个字。这不是终端的问题。服务器上的程序运行在错误的 locale 下,而解决办法藏在 SSH 协议里一个不太为人所知的地方。

SSH 输入中文为什么乱码:字节只有在 locale 里才是文字

第 1 篇说过,终端和远端程序之间交换的是字节。把字节变成字符是程序的事,C 程序通过 C 库的 locale 完成。调用 setlocale(LC_ALL, "") 之后,LC_CTYPE 这一类决定字符编码,它的取值来自环境变量,优先级依次是 LC_ALL、LC_CTYPE、LANG。在 zh_CN.UTF-8 或 en_US.UTF-8 下,E4 B8 AD 三个字节是一个字符「中」,占两列;在 C/POSIX locale 下,它们只是三个互不相干的字节。

locale 错了,输出通常还能正常显示,因为大多数程序只是原样转发字节,由终端自己解码 UTF-8。行编辑器不行。 readline(bash)和 libedit(mysql,以及某些系统上的 sqlite3)要知道每个字符从哪里开始,才能正确回显、移动光标、重绘和删除。在 C locale 下,它们把每个字节都当成一个字符。视 input-meta、output-meta 等配置不同,这些字节要么显示成八进制转义,要么被当成带 Meta 的按键,退格也只删掉多字节字符的一个字节。你看到的乱码就是这么来的。

远端的 locale 从哪里来

SSH 会话的环境变量是在服务器上拼起来的:

精简镜像、容器和部分发行版最后可能一个 LANG 都没有,也就是 POSIX locale。

客户端也可以出一份力,靠的是 RFC 4254 §6.4 定义的 env 通道请求:

byte      SSH_MSG_CHANNEL_REQUEST
uint32    recipient channel
string    "env"
boolean   want reply
string    variable name
string    variable value

RFC 提醒,随意传递环境变量有安全风险,服务器应当加以限制。OpenSSH 的 sshd 默认什么都不接受,必须在 sshd_config 里用 AcceptEnv 列出允许的变量。

locale 转发:env 请求发生在 pty-req 和 shell 之间
locale 转发:env 请求发生在 pty-req 和 shell 之间

OpenSSH 默认到底发不发 locale

很多人以为 ssh 客户端「会把本机 locale 带过去」。严格说,这是发行版的默认配置,不是 OpenSSH 的默认行为。上游 ssh_config(5) 手册对 SendEnv 的说明是:「The default is not to send any environment variables.」(默认不发送任何环境变量。)唯一的例外是 TERM,它根本不走 env 请求,而是包含在 pty-req 里。

大多数用户看到的行为来自发行版的打包。Debian 的 OpenSSH 包(Ubuntu 沿用)在 /etc/ssh/ssh_config 里写了:

SendEnv LANG LC_* COLORTERM NO_COLOR

并在 /etc/ssh/sshd_config 里写了对应的:

AcceptEnv LANG LC_* COLORTERM NO_COLOR

其他发行版也有类似的成对配置。所以在两台默认配置的 Debian 系机器之间,locale 确实会传过去。第三方客户端应该复现的正是这个行为。什么都不发的客户端,恰恰在配置了 AcceptEnv 的服务器上表现得比 ssh 差。

(MistTerm 源码注释把这一点写成了「OpenSSH 默认」,按上面的分析并不准确,已列入本系列的勘误说明。)

转发 locale 的细节

MistTerm 在 pty-req 之后、shell 之前发送 env 请求。顺序很重要:shell 一旦启动,环境就固定了。发哪些变量、一个都没有时怎么办,由 remote_locale_env 决定:

// src/ssh/client.rs
pub(crate) fn remote_locale_env(get: impl Fn(&str) -> Option<String>) -> Vec<(String, String)> {
    const NAMES: &[&str] = &[
        "LANG", "LC_ALL", "LC_CTYPE", "LC_COLLATE",
        "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME",
    ];
    fn plausible(v: &str) -> bool {
        // macOS Terminal 常设 `LC_CTYPE=UTF-8`:这是 macOS 独有写法,Linux glibc 不认,
        // 发过去反而让远端 setlocale 整体失败、退回 POSIX,所以当作没设。
        let bare_codeset = v.eq_ignore_ascii_case("UTF-8") || v.eq_ignore_ascii_case("UTF8");
        !bare_codeset
            && !v.is_empty()
            && v.len() <= 64
            && v.chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@'))
    }
    let mut out: Vec<(String, String)> = NAMES.iter().filter_map(|k| {
        let v = get(k)?;
        let v = v.trim();
        plausible(v).then(|| (k.to_string(), v.to_string()))
    }).collect();
    let has_ctype_source = out.iter()
        .any(|(k, _)| k == "LANG" || k == "LC_ALL" || k == "LC_CTYPE");
    if !has_ctype_source {
        out.insert(0, ("LANG".to_string(), DEFAULT_REMOTE_LANG.to_string())); // "en_US.UTF-8"
    }
    out
}

三种情况决定了这个设计:

  1. 只在本机有效的值。 macOS 常常导出 LC_CTYPE=UTF-8,一个只有编码名的写法,macOS 的 C 库认,glibc 不认。调用 setlocale(LC_ALL, "") 时,只要有一个类别无效,整个调用就失败,程序停留在 C locale。转发这个变量反而会造成它本想解决的问题,所以单独的 UTF-8/UTF8 值会被丢弃。
  2. 根本不是 locale 名的值。 环境变量由用户控制,所以只发送由 locale 名中会出现的字符([A-Za-z0-9._@-])组成、最长 64 字节的值。
  3. 完全没有 locale。 Windows 桌面通常没有 LANG,不经登录环境启动的 GUI 应用也没有。如果 LANG、LC_ALL、LC_CTYPE 一个都没留下,客户端就发送 LANG=en_US.UTF-8,让一台没有任何配置的服务器也能拿到 UTF-8 的 LC_CTYPE。

每个请求单独发送,被拒绝也不算错误:

for (name, value) in remote_locale_env(|k| std::env::var(k).ok()) {
    match channel.setenv(&name, &value) {
        Ok(()) => log::info!("SSH setenv {}={}", name, value),
        Err(e) => log::info!("SSH setenv {} rejected by server: {}", name, e),
    }
}

SSH 中文乱码怎么解决:客户端能做什么,服务端要放行什么

转发要起作用,服务器得在两件事上配合:

排查服务器时,在会话里执行 locale,就能看到远端程序实际拿到的设置。

MistTerm 的本地 shell 标签页(本机上的 PTY,不经过 SSH)用直接设置环境变量的方式处理同样的问题:

小结

常见问题

SSH 输入中文为什么会乱码?

远端程序通常运行在 POSIX/C locale 下,readline 等行编辑器按字节而不是按字符处理输入,于是回显乱码、退格只删半个字。

ssh 默认会把 LANG 发给服务器吗?

上游 OpenSSH 默认不发送任何环境变量。Debian 和 Ubuntu 的软件包在 ssh_config 里配置了 SendEnv LANG LC_*,并在 sshd_config 里配置了对应的 AcceptEnv。

服务端怎么解决 SSH 中文乱码?

在 sshd_config 中加入 AcceptEnv LANG LC_*,或设置系统默认 locale;同时确认服务器上确实有该 locale(用 locale -a 查看)。

为什么不应该转发 macOS 的 LC_CTYPE=UTF-8?

glibc 不接受只有编码名的 locale。只要有一个类别无效,setlocale(LC_ALL, "") 就会失败,程序仍停留在 C locale。

相关文章

← 第 3 篇 · 系列目录 · 第 5 篇 →