用 SSH ProxyJump 访问内网主机
内网主机通常没有公网地址,只允许从跳板机进入。最直接的做法是先登录跳板机,再从跳板机执行第二次 ssh;但这样会割裂本机的 SSH 配置,文件传输、端口转发和主机密钥管理也会变得麻烦。
ProxyJump 让本机 SSH 客户端通过跳板机建立到目标机的连接。私钥仍留在本机,最终主机仍由本机验证,日常命令也可以继续使用同一个主机别名。
最短答案¶
一次性连接只需要 -J:
ssh -J ops@bastion.example.com deploy@app-01.internal
跳板机使用非默认端口时,把端口写在跳板机后面:
ssh -J ops@bastion.example.com:2222 deploy@app-01.internal
-J 是 ProxyJump 的命令行写法。参数格式为 [user@]host[:port];多个跳板机用逗号连接,并按顺序访问:
ssh -J ops@edge.example.com,ops@bastion.internal deploy@app-01.internal
这里的逗号两边不要加空格。IPv6 地址需要放进方括号。
ProxyJump 实际做了什么¶
根据 OpenSSH 的 ssh(1) 与 ssh_config(5) 手册,连接分成两层:
- 本机先建立到跳板机的 SSH 连接。
- 跳板机为本机打开一条通向目标机 SSH 端口的 TCP 转发。
- 本机 SSH 客户端在这条转发上与目标机完成握手、主机密钥校验和用户认证。
它不是“先进入跳板机 Shell,再执行第二次 ssh”。因此:
| 项目 | 由谁处理 |
|---|---|
| 跳板机认证 | 本机 SSH 客户端 |
| 目标机认证 | 本机 SSH 客户端 |
| 两台机器的主机密钥 | 本机 known_hosts |
| 到目标机的 TCP 连接 | 跳板机发起 |
| 目标机的交互会话 | 本机与目标机之间的 SSH 连接 |
跳板机能看到目标地址、连接时间和流量大小,但在正确验证目标机主机密钥的前提下,不能直接读取内层 SSH 会话的明文。
推荐的 SSH 配置¶
长期使用时,不要把用户名、端口和密钥路径塞进每条命令。先准备配置目录:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
touch ~/.ssh/config
chmod 600 ~/.ssh/config
然后写入一份完整配置:
Host bastion
HostName bastion.example.com
User ops
Port 2222
IdentityFile ~/.ssh/id_ed25519_bastion
IdentitiesOnly yes
ForwardAgent no
Host app-prod
HostName app-01.internal
User deploy
Port 22
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump bastion
之后只需要:
ssh app-prod
几个关键配置项:
| 配置 | 作用 |
|---|---|
Host | 本机使用的别名 |
HostName | 实际主机名或 IP |
IdentityFile | 该主机使用的本地私钥 |
IdentitiesOnly yes | 只尝试明确配置的身份,避免 agent 中密钥过多 |
ProxyJump bastion | 先通过 bastion 连接目标机 |
ForwardAgent no | 不把本机 agent 暴露给远端 |
目标机的命令行选项和配置不会自动套用到跳板机。跳板需要独立用户、端口或密钥时,应为它定义单独的 Host 配置;这是很多“明明指定了密钥,跳板机却仍然认证失败”的根源。
多台目标机与多级跳板¶
如果同一内网域名下的机器都经过同一个跳板机,可以使用 Host 通配规则:
Host bastion
HostName bastion.example.com
User ops
IdentityFile ~/.ssh/id_ed25519_bastion
IdentitiesOnly yes
Host *.internal
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump bastion
现在可以直接连接:
ssh app-01.internal
ssh worker-02.internal
最终目标名由最后一台跳板机所在网络解析并连接,因此它可以是只在内网 DNS 中存在的名称。多级跳转时,每台中间跳板则由它的前一跳访问。
多级跳板也可以直接引用已经定义好的别名:
Host app-prod
HostName app-01.internal
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump edge,inner-bastion
连接路径变成 本机 → edge → inner-bastion → app-prod。edge 与 inner-bastion 也应像前面的 bastion 一样,各自拥有完整的 Host 配置。
文件传输¶
配置好 Host app-prod 后,其他基于 SSH 的工具可以复用同一个别名。
上传文件:
scp ./release.tar.gz app-prod:/tmp/
打开 SFTP:
sftp app-prod
同步目录:
rsync -av --progress -e ssh ./dist/ app-prod:/srv/app/
端口映射与转发¶
ProxyJump 只改变本机如何到达 app-prod;-L、-R 和 -D 仍建立在本机与最终目标机之间。判断方向时先看监听端口开在哪里:
| 模式 | 监听端 | 目标连接由谁发起 | 常见用途 |
|---|---|---|---|
-L 本地转发 | 本机 | 最终 SSH 主机 | 从本机访问内网数据库、管理后台 |
-R 远程转发 | 最终 SSH 主机 | 本机 | 把本地开发服务临时提供给远端 |
-D 动态转发 | 本机 | 最终 SSH 主机 | 建立 SOCKS4/5 代理 |
只建立隧道、不需要远程 Shell 时使用 -N。下面的监听地址都显式写成 127.0.0.1,避免端口意外暴露给局域网。
本地转发:把远端服务映射到本机¶
语法为:
-L [本地监听地址:]本地端口:目标地址:目标端口
把内网数据库映射到本机 15432 端口:
ssh -N -L 127.0.0.1:15432:db.internal:5432 app-prod
数据路径为:
本机 127.0.0.1:15432
→ SSH 隧道
→ app-prod
→ db.internal:5432
随后本机程序连接 127.0.0.1:15432。db.internal 由最终 SSH 主机 app-prod 解析并连接,不是由本机或跳板机连接。
若服务只监听在 app-prod 自己的回环地址,可以这样映射:
ssh -N -L 127.0.0.1:18080:127.0.0.1:8080 app-prod
右侧的 127.0.0.1:8080 指 app-prod 自己,而不是本机。需要同时映射多个端口时,可以重复传入 -L。
远程转发:把本机服务映射到远端¶
语法为:
-R [远端监听地址:]远端端口:目标地址:目标端口
假设本机开发服务监听 127.0.0.1:3000,希望只让 app-prod 本机通过 19000 端口访问:
ssh -N -R 127.0.0.1:19000:127.0.0.1:3000 app-prod
数据方向与 -L 相反:
app-prod 127.0.0.1:19000
→ SSH 隧道
→ 本机 127.0.0.1:3000
这里右侧的目标由本机 SSH 客户端连接,所以 127.0.0.1:3000 指本机服务。
动态转发:建立 SOCKS 代理¶
-D 不固定目标地址,而是在本机启动一个 SOCKS4/5 服务;每个目标由使用代理的应用决定:
ssh -N -D 127.0.0.1:1080 app-prod
支持 SOCKS5 的应用连接 127.0.0.1:1080 后,请求会从 app-prod 所在网络发出。例如使用 curl 验证:
curl --proxy socks5h://127.0.0.1:1080 https://example.com/
对于 curl,socks5h 会把域名随 SOCKS 请求交给远端解析,而 socks5 会先在本机解析域名。
写入 SSH 配置¶
固定使用的映射适合单独定义一个别名,避免每次普通登录都占用本地端口:
Host app-prod-db
HostName app-01.internal
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump bastion
LocalForward 127.0.0.1:15432 db.internal:5432
SessionType none
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
启动时只需要:
ssh app-prod-db
连接成功后会保持前台且通常没有输出;按 Ctrl-C 关闭隧道。
对应关系如下:
| 命令行 | ~/.ssh/config |
|---|---|
-L | LocalForward |
-R | RemoteForward |
-D | DynamicForward |
-N | SessionType none |
ExitOnForwardFailure yes 能在监听端口创建失败时立即退出,但不会因为最终服务拒绝连接或超时而退出。调试时建议保持前台运行;确认无误后若需要后台启动,可以使用:
ssh -fNT \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:15432:db.internal:5432 \
app-prod
-f 在认证后进入后台,-T 禁止分配伪终端。该命令不会自动重连,长期隧道应交给服务管理器监控。
密钥与主机身份边界¶
私钥只留在本机¶
ProxyJump 不要求把目标机私钥复制到跳板机,也不要求开启 agent forwarding。跳板机只承担 TCP 转发;目标机认证仍由本机 SSH 客户端完成。
不要为了“第二跳能用密钥”执行以下操作:
- 把私钥复制到跳板机;
- 全局设置
ForwardAgent yes; - 为绕过首次连接直接设置
StrictHostKeyChecking no。
OpenSSH 手册明确提醒:拿到远端 agent socket 访问权限的用户虽然不能导出私钥材料,却可以借用 agent 中的密钥执行签名认证。只有明确需要在远端继续发起 SSH 连接时,才应对单个可信主机临时启用 agent forwarding。
分别验证两台主机¶
首次连接会分别确认跳板机和目标机的主机密钥。应通过云控制台、运维平台或其他可信渠道核对指纹,再接受写入 ~/.ssh/known_hosts。
服务端转发限制¶
ProxyJump 和业务端口转发是两层独立策略:跳板机控制到 app-prod:22 的代理连接,-L、-R、-D 则由 app-prod 的 sshd 决定是否放行。
| 客户端请求 | 最终目标机的 sshd 策略 |
|---|---|
-L、-D | AllowTcpForwarding local;可用 PermitOpen 限制目标 |
-R | AllowTcpForwarding remote;可用 PermitListen 限制监听地址和端口 |
-R 0.0.0.0:... | 还需要 GatewayPorts clientspecified 或 yes |
ProxyJump 依赖每一台跳板机的 SSH 服务允许 TCP forwarding。若你负责维护跳板机,可以把该账户允许转发的目标限制到指定主机:
Match User jump
AllowTcpForwarding local
PermitOpen app-01.internal:22
这段属于跳板机的 /etc/ssh/sshd_config,不是本机的 ~/.ssh/config。它只限制 TCP 转发目标,并不自动禁用该账户的 Shell;完整的专用跳板账户还需要单独设计登录权限。
DisableForwarding yes 会覆盖其他转发配置并全部禁用。若账户仍拥有正常 Shell,仅禁用 SSH forwarding 并不能阻止用户自行运行其他转发程序。具体限制语义见 OpenSSH sshd_config(5) 手册。
如果服务端策略看起来正确但仍被拒绝,还要检查 authorized_keys 中的 restrict、no-port-forwarding、permitopen 和 permitlisten 等单个密钥限制。
ProxyJump 与 ProxyCommand¶
早期配置经常使用:
Host app-prod
HostName app-01.internal
ProxyCommand ssh -W %h:%p bastion
对普通 SSH 跳板机,ProxyJump bastion 更直接,也原生支持逗号分隔的多级跳转。ProxyCommand 仍适合 HTTP/SOCKS 代理或需要自定义连接程序的场景。
不要在同一个目标配置中同时设置 ProxyJump 和 ProxyCommand。两者会竞争同一条代理连接,OpenSSH 使用先取得的值,后面的配置不会覆盖它。
排查顺序¶
1. 查看最终生效配置¶
ssh -G 会展开 Host 和 Match 后输出最终配置:
ssh -G app-prod |
grep -E '^(hostname|user|port|identityfile|proxyjump) '
ssh -G bastion |
grep -E '^(hostname|user|port|identityfile|identitiesonly) '
第一条命令应显示目标地址、目标用户、目标密钥和 proxyjump bastion;第二条用于确认第一跳实际使用的用户、端口与密钥。
2. 分别验证每一段¶
先确认本机能完成跳板认证,并建立到目标 SSH 端口的 TCP forwarding:
ssh -vvv -W app-01.internal:22 bastion
成功后终端通常会出现目标 SSH banner;按 Ctrl-C 结束。若跳板账户允许交互 Shell,也可以直接运行 ssh bastion,但专用跳板账户可能只允许转发。
再从跳板机检查目标端口;以下命令要求跳板机安装了 nc:
ssh bastion 'nc -vz app-01.internal 22'
最后开启详细日志:
ssh -vvv app-prod
日志应依次出现跳板机认证、stdio forwarding 建立,以及目标机认证。
3. 单独验证端口转发¶
先以前台模式建立映射并保留详细日志:
ssh -vvv -N \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:15432:db.internal:5432 \
app-prod
另开终端确认本机正在监听。Ubuntu 使用:
ss -lntp | grep ':15432'
macOS 使用:
lsof -nP -iTCP:15432 -sTCP:LISTEN
监听成功只说明隧道入口已经建立。先从本机发起一次连接,验证完整的 -L 路径:
nc -vz 127.0.0.1 15432
如果失败,再从目标连接的发起端检查业务地址,以区分隧道问题和 app-prod → db.internal 的连通问题:
ssh app-prod 'nc -vz db.internal 5432'
对于 -R,保持隧道运行后从远端验证监听端口:
ssh app-prod 'nc -vz 127.0.0.1 19000'
对于 -D,使用前文的 socks5h:// curl 命令验证代理和远端 DNS。
4. 对照常见错误¶
| 现象 | 优先检查 |
|---|---|
| 跳板机认证失败 | Host bastion 的 User、Port、IdentityFile |
Too many authentication failures | 两个 Host 是否都设置了 IdentitiesOnly yes |
administratively prohibited | 用 ssh -vvv 确认是哪台 sshd 拒绝,再检查 sshd 与 authorized_keys 的转发限制 |
建立 ProxyJump 时目标 HostName 无法解析 | 最后一台跳板机能否解析目标机地址 |
Address already in use | 转发监听端口是否已被占用 |
remote port forwarding failed | 远端端口占用,或 AllowTcpForwarding、PermitListen、GatewayPorts 拒绝 |
connect failed: Name or service not known | -L、-D 检查最终 SSH 主机的解析;-R 检查本机解析 |
| SOCKS 可连接但内网域名失败 | 应用可能在本机解析 DNS;curl 改用 socks5h:// |
| 目标机主机密钥冲突 | 实际目标是否改变;不要直接删除记录后盲目接受 |
| 设置了 ProxyJump 却走 ProxyCommand | 配置文件中谁先取得代理配置 |
若通配规则默认启用了跳板,但某台主机可以直连,可单次禁用:
ssh -o ProxyJump=none public-host
验证通过后,日常只需要记住 app-prod 这个别名。跳板地址、端口、用户和两套密钥由 SSH 配置统一管理,ssh、scp、sftp、rsync 与端口转发共享同一条连接路径。
参考资料¶
评论区将在滚动到这里时加载。