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 会话的环境变量是在服务器上拼起来的:
- sshd 自己设置几个变量。
- PAM 模块可能再加一些。在 Debian 和 Ubuntu 上,
/etc/pam.d/sshd里的pam_env会读取/etc/default/locale。 - 登录 shell 的配置脚本还能继续设置。
精简镜像、容器和部分发行版最后可能一个 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 列出允许的变量。
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
}
三种情况决定了这个设计:
- 只在本机有效的值。 macOS 常常导出
LC_CTYPE=UTF-8,一个只有编码名的写法,macOS 的 C 库认,glibc 不认。调用setlocale(LC_ALL, "")时,只要有一个类别无效,整个调用就失败,程序停留在 C locale。转发这个变量反而会造成它本想解决的问题,所以单独的UTF-8/UTF8值会被丢弃。 - 根本不是 locale 名的值。 环境变量由用户控制,所以只发送由 locale 名中会出现的字符(
[A-Za-z0-9._@-])组成、最长 64 字节的值。 - 完全没有 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 中文乱码怎么解决:客户端能做什么,服务端要放行什么
转发要起作用,服务器得在两件事上配合:
AcceptEnv要放行这个变量。 否则 sshd 拒绝请求,会话退回服务器自身配置给出的 locale,结果和用 OpenSSH 的ssh一样。解决办法在服务器上:加上AcceptEnv LANG LC_*,或者设置系统默认 locale。- 服务器上要有这个 locale。 如果那台机器没有生成
en_US.UTF-8,setlocale照样失败。glibc 系统上可以用locale -a查看已安装的 locale,很多精简系统也提供C.UTF-8。
排查服务器时,在会话里执行 locale,就能看到远端程序实际拿到的设置。
MistTerm 的本地 shell 标签页(本机上的 PTY,不经过 SSH)用直接设置环境变量的方式处理同样的问题:
- 总是设置
TERM=xterm-256color和COLORTERM=truecolor。 - 在 macOS 上,如果
LANG和LC_ALL都没有设置,就补上LANG=en_US.UTF-8。从访达或程序坞启动的应用不会继承登录环境,这条就是为这种情况准备的。
小结
- 行编辑器需要正确的
LC_CTYPE才能处理多字节输入,在 POSIX locale 下它们逐字节处理。 - locale 通过 RFC 4254 的
env请求传到服务器,在shell之前发送,由 sshd 的AcceptEnv过滤。 - 上游 OpenSSH 默认不发送任何变量。Debian 系的打包加上了
SendEnv LANG LC_*(以及COLORTERM、NO_COLOR)和对应的AcceptEnv。 - 客户端最好滤掉远端不认的值(比如 macOS 的裸
UTF-8),并在本机没有 locale 时补一个 UTF-8 默认值。
常见问题
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。