SSH 隧道与跳板机:让流量穿过中间那台机器
跳板机存在的原因是一个路由事实:你的笔记本没有通往内网的路由,而你能连上的某台机器恰好还有第二条腿伸进内网。至于人们额外期待的东西——一次登录、一把共用密钥、一个显眼的目标——都是在通路之上叠加的管理决策,而每一层都可能独立地出问题。
同一条 SSH 连接还能承载任意 TCP 流,这正是 -L、-R、-D 在做的事。这两个话题通常被分开讲,但实际工作中它们互相咬合:跳板机让端口转发能够抵达一台你原本看不见的机器,而端口转发往往也是唯一能证明跳板链路真的通了的手段。把"我能通过跳板机登录"和"流量确实到了数据库"当成同一次成功,是绝大多数疑难工单的起点。
SSH 隧道到底把流量送到了哪里
在争论参数之前,先拆开两个经常被混为一谈的问题:
- 哪一端在监听——你的机器,还是 SSH 服务器那台机器?
- 哪一端发起连接——因此又是谁的 routing table 和 DNS 视图决定了目标地址能否解析?
-L 在本机监听、由服务器一端向外连接;-R 在服务器一端监听、把连接带回你的机器;-D 在本机监听、由应用为每个连接自己指定目标。把这两列想清楚,"隧道明明是通的但服务不响应"这类报障大半能在碰防火墙之前解释掉。
客户端版本决定了有哪些选项、默认值是什么,所以先确认自己在跑什么:
$ ssh -V
OpenSSH_9.2p1 Debian-2+deb12u5, OpenSSL 3.0.15 3 Sep 2024ssh -G 会在不连接的情况下把某个主机的配置完整解析出来。想和默认值讲道理,这是最有用的单条命令,因为它打印的是客户端实际决定的东西,而不是配置文件看起来写的东西:
$ ssh -G -L 8080:localhost:80 example.com | head -20
host example.com
user root
hostname example.com
port 22
addressfamily any
batchmode no
canonicalizefallbacklocal yes
canonicalizehostname false
checkhostip no
compression no
controlmaster false
enablesshkeysign no
clearallforwardings no
exitonforwardfailure no
fingerprinthash SHA256
forwardx11 no
forwardx11trusted yes
gatewayports no
gssapiauthentication yes其中三行决定了隧道在故障现场的表现。clearallforwardings no 意味着命令行上写的转发不会被配置合并冲掉;exitonforwardfailure no 意味着即使转发没建立起来,会话照样保留——于是隧道失败了,你却仍然有一个能用的 shell,这正是人们误判"隧道没问题"的原因;gatewayports no 则是那条让远程转发只能绑回流环的默认值。
ProxyJump、多跳链路,以及 -W 的区别
ProxyJump 并不会变魔术般地做包路由。客户端先和跳板机建立一条 SSH 连接,再把第二条通往最终目标的 SSH 连接装进第一条里。每一跳都按各自的规则认证,有自己的用户、密钥和主机指纹。在链路上这和早年 ProxyCommand 的写法完全等价,ProxyJump 只是它的简写。
想看清客户端最终构造了什么,让配置自己展开是最直观的:
$ ssh -G -J bastion.example.com internal-app | grep -Ei '^(proxyjump|hostname|user|port)'
user root
hostname internal-app
port 22
userknownhostsfile /root/.ssh/known_hosts /root/.ssh/known_hosts2
proxyjump bastion.example.com多出来的 userknownhostsfile 一行是因为它同样以 user 开头——顺便提醒,随手 grep 生成出来的配置,同样会给人意外。
-W 是最容易被和 ProxyJump 混淆的一环。-W host:port 让 SSH 客户端进入一种明确的 netcat 模式:把标准输入输出直接接到对端那个 TCP 端口上。ProxyJump 正是建立在这个思路之上,而当简写无法覆盖你的需求时,也可以手写出来:
$ ssh -G -o ProxyCommand='ssh -W %h:%p bastion.example.com' internal-app | grep -Ei '^(proxycommand|proxyjump|hostname)'
hostname internal-app
proxycommand ssh -W %h:%p bastion.example.com所以两者的区别在于角色,而不是能力:-W 是一个传输原语,适合放进 ProxyCommand 和脚本里;ProxyJump 是主机清单层面的功能,同时还会被 scp 和 sftp 复用。多跳写成逗号链即可——-J hop1,hop2 或 ProxyJump hop1,hop2——但每多一跳就多一次认证、多一处需要存放凭据的地方、也多一个可能超时的环节。用能抵达目标的最短链路,并给每一跳配各自的密钥,别把同一把高权限密钥复制到所有节点。
本地转发 -L
本地转发是最常用的那一种:你在自己机器上监听,让 SSH 服务器去发起那条出站连接。
ssh -N -L 127.0.0.1:8080:localhost:80 deploy@bastion.example.com-N 表示不执行远程命令,只把连接挂住。中间那一段 localhost:80 是从 SSH 服务器的视角解释的。这一句话能解决相当一部分端口转发的困惑:如果那个数据库从应用服务器能连、从跳板机不能连,那么指向跳板机的 -L 就是找不到它,无论你笔记本的路由表长什么样。你本机的路由与"对端能到达什么"无关:
$ ip route
default via 192.168.31.1 dev ens18 onlink
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
172.18.0.0/16 dev br-dddc48ca25c5 proto kernel scope link src 172.18.0.1 linkdown
192.168.31.0/24 dev ens18 proto kernel scope link src 192.168.31.101本机一侧除非有明确理由,否则绑 127.0.0.1。绑到 0.0.0.0 等于邀请所有能触达你笔记本的设备——包括共享 Wi-Fi 上的任何一台——把你的已认证隧道当成通往内网的免费通道。从手机或虚拟机访问隧道确实方便,但那应该是一个附带防火墙规则的主动决定,而不是顺手留下的副作用。
远程转发 -R 与回环陷阱
远程转发把方向翻过来:由 SSH 服务器一侧监听,连接再被带回你的机器。
ssh -N -R 127.0.0.1:9090:localhost:8080 deploy@bastion.example.com第一个要问的问题是:除了服务器自己,还有谁能访问 9090。默认答案是"没有"。OpenSSH 服务端的默认值是 GatewayPorts no,也就是远程转发只绑回流环接口。这个默认值值得去核实而不是假设,服务端配置把它的意图写得很清楚:
$ grep -nEi 'gatewayports|allowtcpforwarding|permitopen' /etc/ssh/sshd_config
87:#AllowAgentForwarding yes
88:#AllowTcpForwarding yes
89:#GatewayPorts no以 # 开头的是注释行,其中的取值就是编译进来的默认值,因此 GatewayPorts no 依然生效。要让别的机器能用上这个远程端口,需要 GatewayPorts clientspecified(或 yes)、一个非回环的监听地址,以及一条与之匹配的防火墙规则。注意:图形界面里表单显示 0.0.0.0,并不能证明服务端一定会照做——服务端可能强制回环,也可能通过 AllowTcpForwarding、PermitOpen 直接拒绝远程转发。-R 连上了却从别处访问不到,通常是策略问题,不是 bug。
远程转发还有一种容易坑到脚本的失败方式。ExitOnForwardFailure 默认为 no,于是会话成功、隧道失败:
$ timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -N -L 18080:127.0.0.1:80 example.com
$ echo "exit=$?"
exit=124实测保存下来的输出只有 exit=124 这个由 timeout 给出的退出状态:命令没有打印任何诊断信息,也始终没能连上。凡是自动化场景都该显式加上 ExitOnForwardFailure yes,让转发失败直接导致命令失败,而不是留下一段半死不活的会话。
动态转发 -D:架在 SSH 上的 SOCKS 代理
动态转发不是把一个端口映射到一个服务,而是在你本机暴露一个 SOCKS 代理,由每个客户端请求自己声明目标:
ssh -N -D 127.0.0.1:1080 deploy@bastion.example.com需要经由一条已认证连接访问很多个目标时,就该用它——浏览器、数据库工具,或任何支持 SOCKS5 的命令行程序。验证时用显式代理去测,别只相信监听存在:
curl --socks5-hostname 127.0.0.1:1080 https://example.com/ -I-hostname 这个变体很关键。--socks5-hostname 把主机名交给代理,于是 DNS 在对端完成;而单纯的 --socks5 会在本地先解析、再把地址转发出去。后一种做法既可能把内网名字泄露给本地解析器,也可能因为 split-horizon DNS 返回了一个在网内含义完全不同的公网地址而失败。无论选哪种,都要明确域名是在哪一侧解析的,这就是"隧道能用"与"隧道让人困惑"的分界线。
Agent Forwarding 是信任取舍,不是便利开关
ssh -A 转发的是你的 ssh-agent socket,而不是复制私钥。听起来更安全,很多时候确实如此——服务器上不会留下任何长期驻留的东西。问题在于转发出去的 socket 在连接打开期间能做什么:对端的一个进程可以请求你的 agent 为认证请求签名,而 agent 会照做。任何在这段窗口内控制了该主机的人,都能借你的凭据去访问所有接受这些凭据的地方,甚至不需要拿到你的密钥文件。
所以这是一个关于中间机器是否可信的决定,而不是便利性开关。如果跳板机是链路里最不值得信任的一台,就不要把 agent socket 放上去。ProxyJump 通常是更好的答案:由最终主机直接完成认证,你不必在一个无法完全控制的中间节点上寄存签名能力。当跳板机确实必须参与认证时——典型情况是在密钥认证之上叠加一次键盘交互式一次性验证码——把凭据限制在该跳,并让每一跳的身份彼此独立;自动 OTP 交互认证文档说明了如何稳定匹配这类提示、而不必手敲验证码。
验证隧道与读懂报错
从"实际在监听什么"出发,而不是从"我记得命令行传了什么"出发:
$ ss -tunlp | head -8
Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
udp UNCONN 0 0 0.0.0.0:39883 0.0.0.0:* users:(("avahi-daemon",pid=398632,fd=14))
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:* users:(("avahi-daemon",pid=398632,fd=12))
udp UNCONN 0 0 [::]:52089 [::]:* users:(("avahi-daemon",pid=398632,fd=15))
udp UNCONN 0 0 [::]:5353 [::]:* users:(("avahi-daemon",pid=398632,fd=13))
tcp LISTEN 0 2048 0.0.0.0:9119 0.0.0.0:* users:(("hermes",pid=1352629,fd=11))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=644,fd=3))
tcp LISTEN 0 10 127.0.0.1:9222 0.0.0.0:* users:(("chromium",pid=6408,fd=55))重点是把 Local Address:Port 那一列和状态一起读。0.0.0.0:22 与 0.0.0.0:9119 接受来自任意网卡的连接,127.0.0.1:9222 只服务回环。如果你以为某条转发会绑回环,它却以 0.0.0.0 出现,那是安全事件而不是外观细节,GatewayPorts 与绑定地址的错误正是从这一列暴露出来的。
如果那一列里什么都没有,先提高日志级别,而不是猜。verbose 模式最前面的几行会告诉你失败发生在加密通道建立之前还是之后:
$ timeout 8 ssh -v -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -N -L 18082:127.0.0.1:22 127.0.0.1 2>&1 | head -8
OpenSSH_9.2p1 Debian-2+deb12u5, OpenSSL 3.0.15 3 Sep 2024
debug1: Reading configuration data /root/.ssh/config
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 19: include /etc/ssh/ssh_config.d/*.conf matched no files
debug1: /etc/ssh/ssh_config line 21: Applying options for *
debug1: Connecting to 127.0.0.1 [127.0.0.1] port 22.
debug1: fd 3 clearing O_NONBLOCK
debug1: Connection established.既然已经到 Connection established,那么此后的任何失败都属于认证或转发策略,与连通性无关。把同一次尝试跑完,就能看到紧随其后的失败:
$ timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -N -L 18081:127.0.0.1:22 127.0.0.1
Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.
root@127.0.0.1: Permission denied (publickey).这就是隧道排查的真实形状:网络这一段成功了,身份这一段没有,于是再怎么查防火墙都没用。值得记住的几条信息是 Permission denied (publickey)(凭据问题,此时根本还没有转发存在)、bind: Address already in use(本地端口被占,先找占用者而不是盲目换端口)、administratively prohibited(服务端策略如 AllowTcpForwarding/PermitOpen,应当和服务端负责人一起解决,而不是绕过去),以及发生在一次正常会话里的 connect failed: Connection refused——最后这条说明转发本身没问题,是对端目标不可达。
连接复用,以及图形化客户端为什么仍要同一套心智模型
每多一条转发,通常就多一次认证往返、一次主机密钥确认、一次密码或硬件密钥触碰。连接复用通过共用一条传输来抹掉这笔开销。解析出来的取值会让陷阱显形——没有 ControlPath 时,ControlMaster 基本不起作用:
$ ssh -G -o ControlMaster=auto -o ControlPersist=10m -L 8080:localhost:80 example.com | grep -Ei '^(controlmaster|controlpersist|controlpath)'
controlmaster auto
controlpersist 600这里 ControlPath 为空,所以在设置 socket 路径之前,复用实际上是关闭的。设置之后,就可以显式查看和拆掉这条共用连接,而不用去杀终端:
$ timeout 5 ssh -o BatchMode=yes -o ControlPath=/tmp/tm-demo-%r@%h:%p -O check example.com
Control socket connect(/tmp/tm-demo-root@example.com:22): No such file or directory这条真实信息只说明该 socket 目前还没有对应的 master——-O check 是查询状态,不会创建连接。复用与转发叠加时还会坑到团队:挂在共享 master 上的转发会比你启动它的那个终端活得更久,所以确认归属并关掉 master 是收尾工作的一部分。
图形化 SSH 客户端不会改变上面任何一条,它改变的只是状态放在哪里。Termark 的端口转发规则对应同一套 -L、-R、-D 模型,并复用已有的主机条目,因此"本机监听地址"、"服务端 GatewayPorts"以及"到底有没有东西在监听"这三个问题一个都不会少——端口转发文档说明了表单字段与命令行参数的对应关系。而让主机定义在不同设备之间保持一致,是故障现场不迷失的另一半,这正是云同步的用途。工具带来的是方便,模型仍然要由你自己掌握。想把这些主机与转发规则放进图形界面里管理,可以从官网下载 Termark。