通过 NGINX 流中继使用 SSH,而不将其弄坏
PROXY 头是发给期待它的接收方的消息,而经代理的 DNS 记录并不是 TCP 中继。请分别修复边缘与上游。
经中继的公开 SSH 会在两个互不相关的边界上失败,修好一个通常会暴露另一个。要么边缘返回 HTTP 响应而不转发流,要么中继在前面添加了后端从未同意读取的头。三种情况决定你看到的是哪一种:公开记录实际指向什么、stream 块向上游发送什么,以及 SSH 客户端验证哪个名称。请按这个顺序处理。
两个边界,两种症状
边缘的故障看起来像是 SSH 横幅该出现的位置来了一个 HTTP 状态页。连接在 TCP 层完成,最初的字节属于某个 Web 服务器。这是协议不匹配,不是丢包。它还告诉你,边缘确实在刻意以别的东西服务这个名称。
上游的故障看起来不同。连接到达 sshd,sshd 读到的字节不是它期待的身份交换,于是会话在从未提供密钥的情况下死亡或挂起。
两者很容易混淆,因为都会产生一个连上之后失败的客户端。你收到的第一个协议响应才是区分它们的依据,因此它应当写进工单。
PROXY 头是消息,不是开关
在 stream 模块中,proxy_protocol on 指示 NGINX 在负载之前向上游写入 PROXY 协议头。它并不会启用 TCP 代理,也与 SSH 加密无关。这是写给接收方的说明。如果接收方读不懂,它就会变成流开头的垃圾。
接收服务必须有意消费这个头。普通 sshd 期待把 SSH 身份交换当作连接上的第一批字节,因此意外的前导会以看似服务器损坏的方式破坏握手。
stream {
server {
listen 2222;
proxy_pass 10.77.0.20:22;
}
}
这个片段就是跨 VPN 可达后端的完整可用中继。没有出现 PROXY 指令,因为接收方不说这个协议,而这种缺席是决定,不是遗漏。
也要区分两个方向。在 listen 行上设置 proxy_protocol 意味着 NGINX 期待它自己的客户端先发送该头;为普通 SSH 客户端启用它,恰好产生人们通常想解决的问题的反面。
公开主机名实际抵达哪里
SSH 会话里出现 HTTP 400 页面,是关于边缘的证据。路径上有东西在说 HTTP,后端做多少密钥或账户方面的工作都改变不了这一点。在边缘查清之前,先别再改后端。
Cloudflare 的普通代理记录只在既定端口上承载其支持的 HTTP 和 HTTPS 流量。为某个主机名开启代理并不会造出通用 TCP 中继,把 SSH 挪到 443 端口也不会让它变成 HTTPS。
存在两条可行路径。仅 DNS 记录可以指向你自己的 TCP 中继,这把暴露的决定留在你手里。另一种做法是使用明确记录 SSH 承载的产品,用其自身的源站、客户端与访问配置。
无论选哪条,中继仍然需要通往后端 VPN 的路由、被允许的连接以及回程路径。路由不是可选项,不能因为公网一侧能通就省掉。
通过别名保持 SSH 身份稳定
用户通常希望继续输入现有的主机名。SSH 配置中的别名可以做到这一点,而不必改动中继,并把改动留在本该属于它的客户端一侧。
Host git.example.com
HostName ssh-entry.example.net
Port 2222
User git
HostKeyAlias git.example.com
没有 HostKeyAlias 时,OpenSSH 会按它连接的名字查找主机密钥,于是条目发生位移,每个用户在首次使用时都会看到密钥变更警告。设置别名可以让 known-hosts 条目保持稳定,同时连接仍然走向中继。
这个别名只适用于基于 SSH 的远端仓库。HTTPS 的 Git URL 不会查询它,而典型的远端 URL 必须与你实际配置的传输方式一致。
不该做的是为了让警告消失而关闭主机密钥校验。那会移除转发修复从未要求触碰的保护,把外观上的麻烦变成真实暴露。
逐跳分别验证
先单独测试公开监听器,再从边缘主机测试中继到后端的连接,最后才做端到端的客户端测试。这三项测试各自排除一个不同的层次。跳过其中一项,剩下两项结果就无法解释。三个观察,失败可能栖身的三处地方。
把目标地址、端口和最初的协议字节连同每个结果一起保留。这三样能区分边缘错误、VPN 路由问题和 SSH 认证失败,也正是修复与猜测之间的差别。
最后,把预期的暴露范围写进文档。把 SSH 承载到私有后端的 TCP 中继,是公网边界上有意打开的缺口,它应当得到与其他任何入站服务同等的审查。