SSH 本地端口转发:通过跳板机访问内网服务

内网中的 Web 服务可能监听在服务器的 127.0.0.1:50052,当前电脑既不能直接访问这台服务器,也不应该把这个服务暴露到公网。

如果当前电脑可以登录跳板机 192.0.2.10:22022,跳板机又可以访问目标服务器 198.51.100.20,就可以用 SSH 本地端口转发,把目标服务器上的服务临时映射到当前电脑。

本文中的账号和地址都是脱敏示例:跳板机使用 jump-user@192.0.2.10:22022,目标服务器使用 target-user@198.51.100.20。请替换为实际环境的值。

建立隧道:

ssh -fN \
  -J jump-user@192.0.2.10:22022 \
  -L 50052:127.0.0.1:50052 \
  -o ExitOnForwardFailure=yes \
  target-user@198.51.100.20

然后在浏览器中打开这个内网页面:

http://127.0.0.1:50052/

对浏览器来说,内网 Web 服务就在本机;对内网 Web 服务来说,请求来自目标服务器上的 SSH 连接。

完整链路

这条命令建立的是下面这条 TCP 链路:

本地 浏览器
    -> 本地 127.0.0.1:50052
        -> SSH 本地端口转发
            -> 跳板机 192.0.2.10:22022
                -> 目标服务器 198.51.100.20
                    -> 目标服务器 127.0.0.1:50052
                        -> 内网 HTTP 服务

这里有两个 127.0.0.1:50052,但它们不是同一个地址:

  • -L 左侧的 50052 是当前电脑上的监听端口
  • -L 右侧的 127.0.0.1:50052 是目标服务器视角下的地址

127.0.0.1 永远表示“执行连接的那台机器自己”。SSH 收到本地连接后,会让目标服务器上的 SSH 服务去连接它自己的 127.0.0.1:50052

逐项解释命令

原始命令是:

ssh -fN \
  -J jump-user@192.0.2.10:22022 \
  -L 50052:127.0.0.1:50052 \
  -o ExitOnForwardFailure=yes \
  target-user@198.51.100.20

-J

-J jump-user@192.0.2.10:22022

指定通过跳板机连接目标服务器。跳板机端口是 22022,目标服务器未指定端口,所以使用默认的 SSH 端口 22

-J 只负责建立到目标服务器的 SSH 连接路径,不负责访问内网 Web 服务。后面的 -L 才是端口转发规则。

-L

-L 50052:127.0.0.1:50052

-L 的通用格式是:

-L [本地监听地址:]本地端口:目标地址:目标端口

所以这条规则表示:

当前电脑 127.0.0.1:50052
    -> 目标服务器 127.0.0.1:50052

没有写本地监听地址时,OpenSSH 默认只在本机 loopback 地址上监听。也就是说,其他电脑不能直接连接这个转发端口。

为了把这个边界写得更明确,也可以写成:

-L 127.0.0.1:50052:127.0.0.1:50052

-N

-N 表示不在目标服务器上执行远程命令。这里 SSH 的任务只有端口转发,不需要打开 shell。

-f

-f 会在 SSH 完成认证、准备建立会话后进入后台。因此这条命令执行成功后,终端会立即返回,可以直接在浏览器中打开页面。

第一次调试时建议先不要加 -f,让 SSH 保持在前台,这样更容易看到错误:

ssh -N \
  -J jump-user@192.0.2.10:22022 \
  -L 50052:127.0.0.1:50052 \
  -o ExitOnForwardFailure=yes \
  target-user@198.51.100.20

确认隧道工作后,再使用 -fN 放到后台。

ExitOnForwardFailure

-o ExitOnForwardFailure=yes

要求 SSH 在无法建立请求的端口转发时直接退出。最常见的情况是当前电脑的 50052 已经被其他程序占用。

没有这个选项时,SSH 连接本身可能成功,但端口转发没有按预期建立,之后浏览器只会看到“连接失败”。加上它后,建立隧道的命令本身就能报告失败。

这个选项只保证转发在启动阶段成功,不保证目标服务一直在线。目标服务后来退出,或者 SSH 网络连接中断,浏览器中的页面会加载失败或断开。

先确认目标服务确实存在

在目标服务器上检查 50052 是否监听:

ss -lntp | grep 50052

如果服务只监听在目标服务器的 127.0.0.1:50052,当前的 -L 写法是正确的。

也可以从目标服务器本机测试 HTTP 接口:

curl -v 'http://127.0.0.1:50052/'

如果服务监听在目标服务器的其他地址或另一台内网机器上,右侧的目标地址应该改成“目标服务器能够访问的地址”。例如服务实际在另一台内网机器:

ssh -fN \
  -J jump-user@192.0.2.10:22022 \
  -L 50052:198.51.100.21:50052 \
  -o ExitOnForwardFailure=yes \
  target-user@198.51.100.20

此时 198.51.100.21:50052 是从 198.51.100.20 的网络视角访问的,而不是从当前电脑访问的。

确认本地转发已经建立

在 macOS 上可以查看本地监听进程:

lsof -nP -iTCP:50052 -sTCP:LISTEN

在 Linux 上也可以使用:

ss -lntp | grep 50052

看到 SSH 进程监听 127.0.0.1:50052 后,在浏览器中打开:

http://127.0.0.1:50052/

如果只是想先确认 HTTP 请求能否通过隧道,可以使用:

curl -v 'http://127.0.0.1:50052/'

如果目标是长连接或持续输出的 HTTP 接口,curl 不一定会正常结束;它的作用是确认请求已经到达服务端。查看页面时使用浏览器更合适。

本地端口被占用

如果执行命令时看到类似 bind: Address already in use,先找出占用本地端口的进程:

lsof -nP -iTCP:50052 -sTCP:LISTEN

可以停止确认无用的进程,也可以换一个本地端口。比如左侧改成 50053,右侧仍然是目标服务的 50052

ssh -fN \
  -J jump-user@192.0.2.10:22022 \
  -L 50053:127.0.0.1:50052 \
  -o ExitOnForwardFailure=yes \
  target-user@198.51.100.20

访问地址也要随之修改:

http://127.0.0.1:50053/

-L 左侧是本地端口,右侧是远端服务端口,两者不需要相同。

隧道断开怎么办

-fN 只是把 SSH 进程放到后台,不会自动重连。对于临时访问内网服务,断开后重新执行命令通常就够了。

如果需要让 SSH 更快发现断线,可以增加 keepalive:

ssh -fN \
  -J jump-user@192.0.2.10:22022 \
  -L 50052:127.0.0.1:50052 \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  target-user@198.51.100.20

这只能让连接在断开后退出,不能自动重建隧道。需要长期运行时,可以再使用 autossh 或 systemd 管理重连;短时间的服务访问不必把问题复杂化。

查看后台 SSH 进程:

pgrep -af 'ssh.*198.51.100.20'

停止前先用 lsof 找到监听 50052 的准确 PID,然后执行:

kill <pid>

不要直接结束所有 SSH 进程,因为当前终端或其他业务连接也可能正在使用 SSH。

安全和性能边界

这个转发默认只在当前电脑的 127.0.0.1 上监听。不要为了让其他电脑也能访问,随意改成:

-L 0.0.0.0:50052:127.0.0.1:50052

这样会把内网 HTTP 服务暴露给当前电脑所在网络,可能绕过原本的访问控制。如果确实需要共享,应另外配置防火墙、认证和明确的监听范围。

通过隧道传输的数据会经过当前电脑、跳板机和目标服务器之间的 SSH 连接,实际访问体验取决于这条链路的带宽、延迟和稳定性。SSH 负责加密传输,但不会替内网 Web 服务解决响应速度、连接数或权限问题。

这条命令的关键思路

这条命令把三个能力组合到了一起:

-J   解决如何到达目标服务器
-L   把目标服务器上的 TCP 服务映射到本地
-fN  让只负责转发的 SSH 连接进入后台

最终,浏览器 不需要理解跳板机,也不需要知道目标服务器的内网地址。它只访问本机的 127.0.0.1:50052,剩下的路径由 SSH 完成。