HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于

SSH 协议与反向代理实战:从握手原理到多容器接入网关

这是一篇尽量「一篇讲透」的 SSH 长文。上半部分把 SSH 的协议分层、密钥体系、认证流程、信道多路复用、端口转发等原理讲清楚;下半部分落到一个真实工程问题——如何让每个用户通过统一入口 SSH 进入各自动态创建的容器——并用 Go 从零实现一个可跑通的 SSH 反向代理。读完你应该能回答:SSH 握手到底发生了什么、host key 有什么用、为什么 nginx 代理不了 SSH 的路由、以及怎么把「用户 → Proxy → 容器」两条加密连接对接起来。

  • 一、SSH 是什么:三层协议
  • 二、一次连接的完整生命周期
  • 三、密钥体系:把三类密钥彻底厘清
    • 3.1 host key:服务器的身份证
    • 3.2 user key:用户的身份证(publickey 认证)
    • 3.3 怎么生成
  • 四、认证机制详解
    • 4.1 publickey:两阶段(关键细节)
    • 4.2 其他 method
    • 4.3 ssh-agent 与 agent forwarding
  • 五、连接层:channel 多路复用
  • 六、常用能力速查
    • 6.1 端口转发
    • 6.2 ProxyJump:多跳跳板
    • 6.3 文件传输与 config
  • 七、应用场景:从堡垒机到多容器接入
  • 八、核心实战:按用户名路由到动态容器的 SSH 反向代理
    • 8.1 需求
    • 8.2 为什么 nginx 路由不了 SSH
    • 8.3 方案对比
    • 8.4 双跳模型:三把钥匙各管一段
    • 8.5 完整时序
  • 九、桥接的本质:为什么不能在 TCP 层对接
  • 十、代码实现(Go x/crypto/ssh)
    • 10.1 启动与认证回调
    • 10.2 终止下游、建立上游
    • 10.3 桥接单个 channel
    • 10.4 请求转发(注意两种签名不同)
  • 十一、配套:API 契约、容器准备、nginx 透传
    • 11.1 元数据 API / 库表
    • 11.2 容器创建的前置条件
    • 11.3 nginx stream 多级透传
  • 十二、注意事项与坑位清单(非安全)
  • 十三、安全实践(单独成节)
  • 十四、总结

一、SSH 是什么:三层协议

SSH(Secure Shell)不是单一协议,而是 RFC 4251~4254 定义的一套分层协议。理解这三层,后面的一切才有坐标系:

层RFC职责关键产物
传输层 Transport4253版本协商、密钥交换、服务器认证、加密与完整性共享密钥、session_id、加密信道
认证层 User Auth4252在加密信道里验证「用户是谁」publickey / password 等方法
连接层 Connection4254在一条连接上多路复用多个 channelsession / 端口转发 / 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:两阶段(关键细节)

公钥认证是两阶段的,这个细节直接影响后面代理的实现:

  1. 试探阶段:客户端先发公钥(不带签名)问「这把你认不认?」。服务器查 authorized_keys,认 → 回 SSH_MSG_USERAUTH_PK_OK。
  2. 签名阶段:客户端用私钥对 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服务器
x11X11 图形转发服务器

每个 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 远不止「登服务器敲命令」。典型场景:

  1. 跳板机 / 堡垒机:统一入口 + 审计,内网机器不直接暴露。
  2. Git over SSH:git@github.com 走的就是 SSH。
  3. 远程开发:VS Code Remote-SSH、JetBrains Gateway 全靠它。
  4. 内网穿透:-R 把家里服务暴露到公网跳板。
  5. 多租户 / 在线实验室 / 云 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)+ nginxHTTP 路径/子域——浏览器里的终端,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 能登上容器,前提是容器侧已就绪。创建容器时三件事一起做,漏一条上游那跳就认证失败:

  1. 容器内运行 sshd:22。
  2. 把 Proxy 公钥写入容器 authorized_keys(对应 upstream_user)。
  3. 在库里注册 容器名 → 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 + 一组加解密

十三、安全实践(单独成节)

工程能跑通之后,安全才是长期成本所在:

  1. 下游授权要显式:用户名是容器名而非身份,「谁能进哪个容器」必须由 allowed_pubkeys(或独立 authz)表达,并有注册/吊销流程。否则知道容器名 = 能进。
  2. 禁用弱认证:PasswordAuthentication no、PermitRootLogin 收紧、只留 publickey。
  3. 容器收口:--cap-drop、资源 quota、网络隔离;拿到 shell 不等于能逃逸。
  4. 审计在终止点做:会话级(命令/keystroke)审计只能在终止 SSH 的 Proxy 录制——nginx stream 那几层是密文,审计不了。
  5. 上游 host key 别长期 InsecureIgnoreHostKey:容器 host key 可在创建时登记,Proxy 侧 pin,防内网 MITM。
  6. 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,都只是这套模型的不同实现。