SSH 协议与反向代理实战:从握手原理到多容器接入网关
这是一篇尽量「一篇讲透」的 SSH 长文。上半部分把 SSH 的协议分层、密钥体系、认证流程、信道多路复用、端口转发等原理讲清楚;下半部分落到一个真实工程问题——如何让每个用户通过统一入口 SSH 进入各自动态创建的容器——并用 Go 从零实现一个可跑通的 SSH 反向代理。读完你应该能回答:SSH 握手到底发生了什么、
host key有什么用、为什么 nginx 代理不了 SSH 的路由、以及怎么把「用户 → Proxy → 容器」两条加密连接对接起来。
一、SSH 是什么:三层协议
SSH(Secure Shell)不是单一协议,而是 RFC 4251~4254 定义的一套分层协议。理解这三层,后面的一切才有坐标系:
| 层 | RFC | 职责 | 关键产物 |
|---|---|---|---|
| 传输层 Transport | 4253 | 版本协商、密钥交换、服务器认证、加密与完整性 | 共享密钥、session_id、加密信道 |
| 认证层 User Auth | 4252 | 在加密信道里验证「用户是谁」 | publickey / password 等方法 |
| 连接层 Connection | 4254 | 在一条连接上多路复用多个 channel | session / 端口转发 / sftp 等 |
一句话:传输层建立加密管道并验证服务器 → 认证层验证用户 → 连接层在管道里跑多个逻辑信道。 顺序很重要,因为它决定了「谁在哪一步才能看到什么信息」,这正是后面代理设计的命门。
二、一次连接的完整生命周期
下面是 ssh user@gw.example.com 从敲回车到拿到 shell 的完整时序。注意那条加密边界:它之后的所有内容,任何不持有私钥、不终止 SSH 的中间设备都看不见。
记住这条加密边界:第 ③ 步 NEWKEYS 之后全是密文。中间盒子(LB、nginx)若不终止 SSH,只能看到 ①②——而 ①② 里没有用户名、没有目标信息。这条事实贯穿全文。
三、密钥体系:把三类密钥彻底厘清
SSH 里最容易混的就是密钥。先建立一张对照表,把「谁证明给谁看」钉死:
| host key 主机密钥 | user key 用户密钥 | |
|---|---|---|
| 回答的问题 | 我连的这台服务器是真的吗? | 连进来的人是谁? |
| 属于谁 | 服务器/机器 | 用户 |
| 私钥在哪 | 服务器 /etc/ssh/ssh_host_* | 用户笔记本 ~/.ssh/id_* |
| 公钥在哪 | 客户端 ~/.ssh/known_hosts | 服务器 ~/.ssh/authorized_keys |
| 验证方向 | 服务器 → 证明给客户端 | 客户端 → 证明给服务器 |
| 出问题表现 | REMOTE HOST IDENTIFICATION HAS CHANGED 拒连 | Permission denied (publickey) |
3.1 host key:服务器的身份证
host key 防的是中间人攻击。在 KEX 阶段,服务器用 host 私钥对交换哈希 H 签名,客户端用收到的 host 公钥 K_S 验签,并和 known_hosts 里记录的比对。
- 为什么签
H?H包含了本次握手的临时 DH 值和双方版本串。签它既证明「我持有 host 私钥」,又证明「我是这次握手的活体参与者」(防重放),把服务器身份绑定到这条会话。 - TOFU(Trust On First Use,首次信任): 第一次连陌生服务器看到的提示,就是 host key 还没在
known_hosts里:
The authenticity of host 'gw.example.com' can't be established.
ED25519 key fingerprint is SHA256:hQ9c...kZ0.
Are you sure you want to continue connecting (yes/no)?
你打 yes,客户端把这把 host 公钥写进 ~/.ssh/known_hosts。
known_hosts按[主机]:端口存。 所以[gw.example.com]:2222和[gw.example.com]:2223是两条独立记录——同主机不同端口出示不同 host key 不会冲突。- 指纹(fingerprint) 是 host 公钥的 SHA256 摘要,人肉核对用:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# 256 SHA256:hQ9c...kZ0 root@host (ED25519)
3.2 user key:用户的身份证(publickey 认证)
- 私钥在你笔记本,公钥放进服务器
~/.ssh/authorized_keys。 - 私钥永不离开客户端;服务器靠「公钥匹配 + 验签」确认你持有私钥。
3.3 怎么生成
# 用户密钥(推荐 ed25519,短而强)
ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519
# 服务器/代理的主机密钥(无 passphrase,供服务自动加载)
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
一台服务器可同时持有多把不同算法的 host key,握手时按客户端偏好(server_host_key_algorithms)挑一把出示。现在默认推 ed25519,rsa 兼容老客户端,ecdsa 可选,dsa 已废弃。
一句话区分
user key 决定「放不放你进来」,host key 决定「你信不信这台机器」。 前者在 authorized_keys,后者在 known_hosts。
四、认证机制详解
USERAUTH_REQUEST 支持多种 method,客户端通常逐个尝试:
4.1 publickey:两阶段(关键细节)
公钥认证是两阶段的,这个细节直接影响后面代理的实现:
- 试探阶段:客户端先发公钥(不带签名)问「这把你认不认?」。服务器查
authorized_keys,认 → 回SSH_MSG_USERAUTH_PK_OK。 - 签名阶段:客户端用私钥对
session_id签名发过去,服务器用公钥验签。
为什么要两阶段?——避免没必要的签名计算,也让代理可以「先拿到授权公钥集再决定接不接受」。
多把私钥的副作用
ssh 会逐把尝试本地/agent 里的私钥,每把都触发一次 USERAUTH_REQUEST。在代理里这意味着回调可能被调多次,结果要按连接缓存(见第八章)。
4.2 其他 method
- password:明文密码在加密信道内传输(信道安全,但口令本身弱)。生产建议
PasswordAuthentication no。 - keyboard-interactive:服务器驱动的问答式,常用于 2FA/OTP。
- hostbased:基于客户端主机身份,较少用。
- GSSAPI:Kerberos 等企业单点登录。
4.3 ssh-agent 与 agent forwarding
ssh-agent 在内存里托管解密后的私钥,避免反复输 passphrase:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh -A(agent forwarding)把本地 agent「转发」到远端,让你从跳板机继续用本地私钥登录下一跳——方便但有风险:远端 root 能借用你的 agent。优先用 ProxyJump(见 6.2)替代 -A。
五、连接层:channel 多路复用
认证通过后,所有交互都跑在channel上。一条 SSH 连接可同时承载多个 channel(SSH_MSG_CHANNEL_OPEN 分配 channel_id)。常见类型:
| channel 类型 | 用途 | 谁发起 |
|---|---|---|
session | 跑 shell / exec / sftp | 客户端 |
direct-tcpip | 本地端口转发 -L | 客户端 |
forwarded-tcpip | 远程端口转发 -R | 服务器 |
x11 | X11 图形转发 | 服务器 |
每个 session channel 之上还有channel request:
| request | 含义 | 方向 |
|---|---|---|
pty-req | 申请伪终端(TERM、行列、终端模式) | 客户端 → 服务器 |
env | 设置环境变量(需服务端 AcceptEnv) | 客户端 → 服务器 |
shell / exec / subsystem | 跑登录 shell / 单条命令 / 子系统(如 sftp) | 客户端 → 服务器 |
window-change | 终端窗口大小变化 | 客户端 → 服务器 |
exit-status / exit-signal | 命令退出码 / 终止信号 | 服务器 → 客户端 |
数据通道有两条:CHANNEL_DATA(stdout/stdin)和 CHANNEL_EXTENDED_DATA(stderr)。
代理实现的命门
如果你要写 SSH 代理,这三类东西都得双向转发:data、extended-data(stderr)、request。漏 exit-status → 客户端拿不到退出码会卡;漏 window-change → vim/top 显示错乱;漏 stderr → 报错信息丢失。
六、常用能力速查
6.1 端口转发
# 本地转发 -L:把本地 9000 转到远端能访问的 db:5432
ssh -L 9000:db.internal:5432 user@gw.example.com
# 远程转发 -R:把远端 8080 暴露回本地 3000(内网穿透常用)
ssh -R 8080:localhost:3000 user@gw.example.com
# 动态转发 -D:在本地起一个 SOCKS5 代理
ssh -D 1080 user@gw.example.com
6.2 ProxyJump:多跳跳板
# 经 bastion 跳到内网 target,无需在跳板机落地私钥
ssh -J user@bastion.example.com user@target.internal
~/.ssh/config 写法:
Host target
HostName target.internal
User app
ProxyJump bastion.example.com
6.3 文件传输与 config
scp file.tar user@gw.example.com:/data/ # 拷文件
sftp user@gw.example.com # 交互式文件传输
# ~/.ssh/config —— 把常用参数固化
Host myserver
HostName gw.example.com
User app
Port 2222
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30 # 保活,防空闲断连
ServerAliveCountMax 6
七、应用场景:从堡垒机到多容器接入
SSH 远不止「登服务器敲命令」。典型场景:
- 跳板机 / 堡垒机:统一入口 + 审计,内网机器不直接暴露。
- Git over SSH:
git@github.com走的就是 SSH。 - 远程开发:VS Code Remote-SSH、JetBrains Gateway 全靠它。
- 内网穿透:
-R把家里服务暴露到公网跳板。 - 多租户 / 在线实验室 / 云 IDE:为每个用户动态创建一个容器,通过统一入口 SSH 接入各自容器——这是本文下半部分的主角,也是最能体现 SSH 协议特性的设计。
下面我们就用第 5 个场景,把前面所有原理串起来。
八、核心实战:按用户名路由到动态容器的 SSH 反向代理
8.1 需求
- 每个用户有一个动态创建的容器(IP、端口随启停变化)。
- 用户用统一入口接入:
ssh 容器名@ssh.example.com。 - 目标链路:用户 ─ssh─ Proxy ─ssh─ 容器。
8.2 为什么 nginx 路由不了 SSH
很多人第一反应是「用 nginx 转发」。nginx 的 stream 模块确实能转发 SSH(L4 TCP),但只能转发,不能按用户路由。原因就是第二章那条加密边界:
- HTTP 有
Host头、TLS 有 SNI,nginx 能据此分发。 - SSH 没有:目标用户名在第 ④ 步认证阶段才发,且在加密信道里。nginx 不终止 SSH、没有私钥,读不到用户名,自然没有路由键。
所以 nginx 对 SSH 只有两种现实用法:① 按端口一对一映射;② TCP 透传。要「按用户名进不同容器」,必须有一个终止 SSH 的代理。
8.3 方案对比
| 方案 | 路由依据 | 是否终止 SSH | 适用 |
|---|---|---|---|
| nginx stream | 端口 | 否(纯透传) | 边缘入口、TCP 搬运 |
| SSH 反向代理 / SSHPiper | 用户名 / 公钥 | 是 | 每用户一容器,单端口 |
宿主机 sshd + ForceCommand | 系统账号 | 是 | 登录后 docker exec 进容器,容器零 sshd |
| ProxyJump 跳板 | SSH 原生 | 是(跳板自身) | 标准内网跳转 |
| Web 终端(ttyd/wetty)+ nginx | HTTP 路径/子域 | —— | 浏览器里的终端,nginx 名正言顺 |
本文取自研 SSH 反向代理,因为它最能讲清协议;思路与 SSHPiper 一致。
8.4 双跳模型:三把钥匙各管一段
用户 ─ssh─ Proxy ─ssh─ 容器 本质是两条独立加密的 SSH 连接被 Proxy 桥接。中间涉及三把钥匙,千万别混成一把:
| 钥匙 | 私钥在哪 | 公钥在哪 | 用途 |
|---|---|---|---|
| 用户密钥 | 用户笔记本 | 元数据库(API 返回) | 下游验证:Proxy 验「连进来的人」 |
| Proxy 密钥 | Proxy 本地(持久化) | 预置进每个容器 authorized_keys | 上游认证:Proxy 登容器 |
| 容器 host key | 容器内 | Proxy 侧 known_hosts(可选) | Proxy 校验容器,内网可跳过 |
「查数据库里的公钥」查的是用户公钥(验下游);登容器用的是 Proxy 私钥(连上游)。两段用不同的钥匙——这就是「密钥翻译」。
8.5 完整时序
九、桥接的本质:为什么不能在 TCP 层对接
这是整个实现里最核心、也最容易做错的地方。
SSH 会话 A(密钥A 加密) SSH 会话 B(密钥B 加密)
用户 <════════════════════════> Proxy <════════════════════════> 容器
│
在 channel 逻辑层,把 A、B 的
data / stderr / request 互相转发
两条 SSH 连接是各自独立加密的(不同会话密钥、不同 host key)。你不能直接 io.Copy 两个 TCP socket——那是两段互相看不懂的密文。
正确做法:Proxy 同时终止两条——对下游它是 server(解出用户明文),对上游它是 client(自己加密发给容器)。中间搬运解密后的逻辑消息(channel 数据/请求),不是密文字节。对接发生在 connection 层(channel),不是 TCP 层。
十、代码实现(Go x/crypto/ssh)
下面是可跑通的核心。分四块:启动 → 终止下游+建立上游 → 桥接单个 channel → 请求转发。
10.1 启动与认证回调
func main() {
hostKey := mustLoadSigner("/data/ssh-proxy/host_ed25519") // 持久化, 重启不变
proxyKey := mustLoadSigner("/data/ssh-proxy/proxy_ed25519") // 登容器用
cfg := &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
container := conn.User() // ← 路由键 = 容器名
info, err := lookupAPI(container) // 调元数据 API(按连接缓存)
if err != nil {
return nil, err
}
if !info.AllowKey(key) { // 库会自动验签, 这里只判授权
return nil, fmt.Errorf("key not authorized")
}
return &ssh.Permissions{Extensions: map[string]string{ // 带到 post-auth
"ip": info.IP, "port": info.Port, "user": info.UpstreamUser,
}}, nil
},
}
cfg.AddHostKey(hostKey)
ln, _ := net.Listen("tcp", ":2222")
for {
c, err := ln.Accept()
if err != nil {
continue
}
go handle(c, cfg, proxyKey)
}
}
认证回调的时机
回调里就完成了「读容器名 → 查 API → 验证用户公钥」。一次 API 调用同时返回下游验证用的公钥集 + 上游连接用的 ip/port/账号,一举两得。结果用 Permissions.Extensions 带到认证之后的桥接阶段。
10.2 终止下游、建立上游
func handle(downTCP net.Conn, serverCfg *ssh.ServerConfig, proxyKey ssh.Signer) {
// 1) 终止下游:解出「用户那条 SSH」
downConn, downChans, downReqs, err := ssh.NewServerConn(downTCP, serverCfg)
if err != nil {
downTCP.Close()
return
}
defer downConn.Close()
ext := downConn.Permissions.Extensions
addr := net.JoinHostPort(ext["ip"], ext["port"])
// 2) 建立上游:用 NewClientConn(而非 ssh.Dial),以便拿到容器侧主动开的 channel(支持 -R)
upTCP, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return
}
upConn, upChans, upReqs, err := ssh.NewClientConn(upTCP, addr, &ssh.ClientConfig{
User: ext["user"],
Auth: []ssh.AuthMethod{ssh.PublicKeys(proxyKey)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网; 或 pin 容器 host key
})
if err != nil {
upTCP.Close()
return
}
defer upConn.Close()
// 3) 双向对接
go forwardGlobalReqs(downReqs, upConn) // 用户 → 容器(全局请求)
go forwardGlobalReqs(upReqs, downConn) // 容器 → 用户
go func() { // 用户主动开的 channel(session / direct-tcpip)
for nc := range downChans {
go bridgeChannel(nc, upConn)
}
}()
go func() { // 容器主动开的 channel(forwarded-tcpip / x11)
for nc := range upChans {
go bridgeChannel(nc, downConn)
}
}()
downConn.Wait() // 任一侧断开, defer 关掉另一侧
}
10.3 桥接单个 channel
// ServerConn 内嵌 ssh.Conn, ClientConn 也是 ssh.Conn, 所以本函数双向通用
func bridgeChannel(nc ssh.NewChannel, target ssh.Conn) {
// 在对端开同类型 channel; ExtraData 原样透传
// (direct-tcpip 的「目标 host:port」就藏在 ExtraData 里, 必须原封不动)
targetCh, targetReqs, err := target.OpenChannel(nc.ChannelType(), nc.ExtraData())
if err != nil {
if oce, ok := err.(*ssh.OpenChannelError); ok {
nc.Reject(oce.Reason, oce.Message)
} else {
nc.Reject(ssh.ConnectionFailed, err.Error())
}
return
}
originCh, originReqs, err := nc.Accept()
if err != nil {
targetCh.Close()
return
}
// 转发 channel 内请求: pty-req/shell/exec/window-change ↔ exit-status/exit-signal
go forwardChannelReqs(originReqs, targetCh)
go forwardChannelReqs(targetReqs, originCh)
// 双向拷贝 stdout/stdin + stderr; 某方向读完用 CloseWrite 传 EOF(而非 Close)
var wg sync.WaitGroup
cp := func(dst, src ssh.Channel) {
wg.Add(1)
go func() { defer wg.Done(); io.Copy(dst, src); dst.CloseWrite() }()
}
cpErr := func(dst, src ssh.Channel) {
wg.Add(1)
go func() { defer wg.Done(); io.Copy(dst.Stderr(), src.Stderr()) }()
}
cp(targetCh, originCh)
cp(originCh, targetCh)
cpErr(targetCh, originCh)
cpErr(originCh, targetCh)
wg.Wait()
originCh.Close()
targetCh.Close()
}
10.4 请求转发(注意两种签名不同)
// channel 请求: 回复不带 payload
func forwardChannelReqs(in <-chan *ssh.Request, target ssh.Channel) {
for r := range in {
ok, err := target.SendRequest(r.Type, r.WantReply, r.Payload)
if err != nil {
if r.WantReply {
r.Reply(false, nil)
}
continue
}
if r.WantReply {
r.Reply(ok, nil)
}
}
}
// 全局请求: 回复可带 payload(如 tcpip-forward 端口为 0 时返回分配的端口)
func forwardGlobalReqs(in <-chan *ssh.Request, target ssh.Conn) {
for r := range in {
ok, payload, err := target.SendRequest(r.Type, r.WantReply, r.Payload)
if err != nil {
if r.WantReply {
r.Reply(false, nil)
}
continue
}
if r.WantReply {
r.Reply(ok, payload)
}
}
}
这套 handle + bridgeChannel + 两个 forward,约 120~150 行就能跑通完整交互、exec、sftp、端口转发。不想自研,直接用 SSHPiper(写个 plugin 调你的 API 返回上游 + 验下游公钥)等价于这套。
十一、配套:API 契约、容器准备、nginx 透传
11.1 元数据 API / 库表
按容器名查询,一次返回全量业务数据:
{
"container_name": "user-box-01",
"ip": "10.244.3.17",
"port": 22,
"upstream_user": "root",
"allowed_pubkeys": ["ssh-ed25519 AAAAC3Nz... user@laptop"],
"status": "running"
}
库表最小字段:container_name(主键,= SSH 用户名)、ip/port(启停时回写)、upstream_user、allowed_pubkeys、status。
热路径
该 API 在连接建立热路径上:每次 connect = 1 次 API + 1 次 DB 查。生产应加 TTL 短缓存 + 熔断降级,否则 API 抖动会放大成全站 SSH 不可用。
11.2 容器创建的前置条件
Proxy 能登上容器,前提是容器侧已就绪。创建容器时三件事一起做,漏一条上游那跳就认证失败:
- 容器内运行 sshd:22。
- 把 Proxy 公钥写入容器
authorized_keys(对应upstream_user)。 - 在库里注册 容器名 → ip/port/upstream_user/allowed_pubkeys。容器销毁/迁移同步更新。
11.3 nginx stream 多级透传
边缘/中心/集群多级入口,全程纯 TCP 透传,按 listen 端口区分集群:
# 任意一级 nginx 的 stream 块
stream {
server {
listen 2222;
proxy_pass next-hop.internal:2222; # 下一跳: 另一级 nginx 或 SSH Proxy
proxy_timeout 1h; # 关键! 默认仅 10min, 空闲会被掐断
proxy_connect_timeout 10s;
proxy_socket_keepalive on;
}
server {
listen 2223; # 第二个集群用第二个端口
proxy_pass cluster2.internal:2222;
proxy_timeout 1h;
proxy_socket_keepalive on;
}
}
十二、注意事项与坑位清单(非安全)
这些都是「看似通其实会断/会卡」的功能性坑:
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| ① | 多级 stream 空闲超时(默认仅 10min) | 终端空闲后静默卡死 | 每一跳 proxy_timeout 1h+ + 客户端 ServerAliveInterval |
| ② | Proxy host key 变了 | ssh 报 IDENTIFICATION CHANGED 并拒连 | host key 持久化、重启不变;多副本共用同一把 |
| ③ | 端口必须对应容器所在集群 | 用错端口 → Proxy 查不到/连不到 | 系统告知正确端口;或上移到单端口 + 用户名查集群 |
| ④ | 长连接被中间设备回收 | 偶发断连 | proxy_socket_keepalive on + TCP keepalive |
| ⑤ | exit-status 没转发 | 客户端拿不到退出码、脚本卡住 | 第十章已覆盖,前提是真把 channel 请求双向转发 |
| ⑥ | stderr 没单独转发 | 报错信息丢失 | io.Copy(dst.Stderr(), src.Stderr()) |
| ⑦ | window-change 没转发 | vim/top 显示错乱 | 同 channel 请求转发 |
| ⑧ | PROXY protocol 不一致 | 握手直接失败 | 要么全开并让 Proxy 解析, 要么全不开 |
| ⑨ | 源 IP 没透传 | 审计里全是内网 IP | 各跳 PROXY protocol + Proxy 解析真实源 IP |
| ⑩ | 单实例 FD/内存上限 | 高并发新连接失败 | 调 ulimit -n;每会话 = 2 条 TCP + 一组加解密 |
十三、安全实践(单独成节)
工程能跑通之后,安全才是长期成本所在:
- 下游授权要显式:用户名是容器名而非身份,「谁能进哪个容器」必须由
allowed_pubkeys(或独立 authz)表达,并有注册/吊销流程。否则知道容器名 = 能进。 - 禁用弱认证:
PasswordAuthentication no、PermitRootLogin收紧、只留 publickey。 - 容器收口:
--cap-drop、资源 quota、网络隔离;拿到 shell 不等于能逃逸。 - 审计在终止点做:会话级(命令/keystroke)审计只能在终止 SSH 的 Proxy 录制——nginx stream 那几层是密文,审计不了。
- 上游 host key 别长期
InsecureIgnoreHostKey:容器 host key 可在创建时登记,Proxy 侧 pin,防内网 MITM。 - Web 终端反代要校验后端地址:若用
/.../{host}/{port}/形式反代,务必把 host/port 限定为「当前用户名下、实际存在的端点」白名单,否则是 SSRF/越权。
十四、总结
把全文压成几条带走:
- SSH 是三层协议:传输层(建管道+验服务器)→ 认证层(验用户)→ 连接层(channel 多路复用)。
- 加密边界在 KEX 之后:不终止 SSH 的中间设备读不到用户名,所以 nginx 转发得了 SSH、路由不了 SSH。
- 三把钥匙:host key 验服务器、user key 验用户、Proxy 私钥登上游;别混。
- 「用户 → Proxy → 容器」是两条独立加密连接,必须在 channel 逻辑层桥接(data + stderr + request),不能在 TCP 层拼接。
- 落地稳定靠四件事:host key 持久化、
proxy_timeout调大、channel 三类消息全转发、端口/集群对应关系明确。
原理清楚之后,无论是自研 Proxy 还是直接用 SSHPiper / ForceCommand,都只是这套模型的不同实现。