网站有个后台需要单独占一个端口,或者要在两台机器之间做一次临时的数据搬运,又或者某台内网机器要给外部提供接口但不能暴露更多东西——这些场景都会落到同一件事上:端口转发。多数人第一次接触它,是从一行 SSH 命令开始的,用得顺手之后就不再深究,直到某天转发出问题了,才发现自己根本不清楚流量到底走了哪条路。
一、先把三种方向分清,否则命令永远记不住
SSH 的转发只有三种,名字容易混,但只要抓住「谁在监听、谁主动连接」这两个问题就清楚了:
- 本地转发
-L:在本地机器上开一个监听端口,把连接到这个端口的流量,通过 SSH 隧道送到远端机器能访问的某个地址。关键词是「本地监听」。典型用途:数据库只允许内网访问,你在本地开 13306 端口,连接到它就像连内网数据库。 - 远程转发
-R:在远端机器上开一个监听端口,把连到远端的流量,送回本地机器能访问的地址。关键词是「远端监听」。典型用途:家里有一台 NAS 或测试机,想让公网上的服务器帮你转一下流量进来。 - 动态转发
-D:在本地开一个 SOCKS5 代理端口,所有走这个代理的连接都从远端出口出去。关键词是「本地监听、远端出口、任意目标」。典型用途:需要临时换出口 IP 做调试或者访问地域限制的资源。
本地转发的命令形状是:
ssh -N -L 13306:127.0.0.1:3306 user@jump.example.com中间的 13306:127.0.0.1:3306 要这样读:冒号前是「本地监听端口」,冒号后是「在跳板机上看到的地址和端口」。注意后半段是从远端机器的视角解析的,所以写 127.0.0.1:3306 指的是跳板机自己的 MySQL,而不是你本机的。这是初学者最容易搞反的一处,也是「我明明写了内网 IP 却连不上」的根源。
-N 表示不执行远端命令,只做转发;不加它的话 SSH 会给你一个远端 shell,虽然也能转发,但一旦这个 shell 退出,隧道就断了。做后台运行的时候一定要加。
二、监听地址写 127.0.0.1 还是 0.0.0.0,是个安全问题
本地转发默认只在 127.0.0.1 上监听,也就是只有本机能连。如果你想让它对局域网里的其他设备可用,需要显式指定:
ssh -N -L 0.0.0.0:13306:127.0.0.1:3306 user@jump.example.com但这一步是有代价的:SSH 允许这种转发之后,任何能访问你机器 13306 端口的人,都等于拿到了那条内网数据库的访问通道。数据库的账号密码是唯一的防线,如果那个库的账号还允许从任意主机登录,等于把数据库直接公开了。
反过来,远程转发 -R 也有一道类似的关卡,而且更隐蔽:在 OpenSSH 里,-R 0.0.0.0:8080:127.0.0.1:80 这种写法能不能真正对外监听,取决于服务端的 GatewayPorts 配置。默认是 no,也就是说无论你命令行怎么写,远端都只在 127.0.0.1 上监听。很多人在这上面耗了半小时,以为命令写错了,其实是服务端不批准。要放开就得在服务端的 sshd_config 里设 GatewayPorts yes,然后重启 sshd——而这个改动的安全含义是「任何能访问这台机器的人都能用这条隧道」,所以要慎之又慎。
三、让隧道活得久一点:autossh 与 systemd
手敲的隧道一定会断。网络抖动、机房里那条链路被中断几秒、笔记本合盖休眠,隧道就死了,而且 ssh 进程往往不会自动重连。三个层次的处理:
层次一:让 SSH 自己检测死连接。
ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes -L 13306:127.0.0.1:3306 user@hostServerAliveInterval=30 每 30 秒发一次保活,连续三次没回应就主动断开退出。ExitOnForwardFailure=yes 很重要:如果端口已经被占用、转发建立失败,立刻退出而不是留一个「看着在跑其实不转发」的进程。
层次二:用 autossh 自动重连。 autossh 会监控隧道状态,断了重新拉起来。用法基本是把上面那条 ssh 命令原样交给它,只是要配 -M 0 关掉它自己的监控端口(那个端口在某些云环境会被安全组挡住,反而让 autossh 误判)。
层次三:交给 systemd 管。 这是生产环境该用的方式,因为你能用 systemctl status 看到状态、能开机自启、能查日志。写一个 unit:
[Unit]
Description=SSH tunnel to db
After=network-online.target
Wants=network-online.target
[Service]
User=tunnel
ExecStart=/usr/bin/ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -o BatchMode=yes -i /home/tunnel/.ssh/id_ed25519 -L 13306:127.0.0.1:3306 tunnel@host
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target几个细节值得注意:BatchMode=yes 保证不会卡在密码提示上(systemd 里没人给你输密码,进程会一直挂着);Restart=always 配 RestartSec=10 避免疯狂重启把对端 fail2ban 触发;用户单独建一个,私钥权限 600。
四、端口被占用的排查顺序
隧道起不来,报错里最常见的是 bind: Address already in use。这时候不要急着换端口,先查是谁占着:
ss -lntp | grep 13306三种可能:上一个隧道进程还活着没被杀干净(kill 掉即可);有一个本地数据库服务真的在用这个端口;或者端口处于 TIME_WAIT 状态。第三种情况其实很少导致 bind 失败,因为转发端口带 SO_REUSEADDR,但如果对端是刚崩溃的连接,等几十秒通常就好。真正需要警惕的是第一种:脚本重启隧道时没有检查旧进程,越积越多,最后每个端口上都挂着一个僵尸转发。
还有一种隐形的失败:ss -lntp 显示端口在监听,但连上去没有任何响应。这种情况通常是「转发链路的末端不通」——跳板机到目标那一段断了,SSH 不会主动告诉你,因为隧道本身是健康的。验证办法是在跳板机上直接测目标:nc -zv 127.0.0.1 3306 或者 curl -v http://127.0.0.1:8080/。这一步排除掉之后,问题才可能在你本机这一侧。
五、安全收口:隧道是特权,要给边界
隧道能力本身是一种特权。一个只有普通 shell 权限的用户,只要被允许转发,就能把内网任意可达的端口拉出来,绕过防火墙的所有规则。所以生产环境里通常要收口:
在 sshd_config 里可以针对特定用户或组关闭转发能力:
Match User deploy
AllowTcpForwarding no
PermitTunnel no
X11Forwarding no反过来,如果你就是给某个运维账号开隧道,可以用 PermitOpen 限定它只能转发哪几个目标:
Match User tunnel
AllowTcpForwarding local
PermitOpen 127.0.0.1:3306
PermitListen nonePermitOpen 只允许转发到本机的 3306,这个账号就算私钥泄露,也只能碰数据库,不能把内网其他机器一个个打通。这类配置改动之后必须先用 sshd -t 校验语法,再重启,并且保留一个已登录的会话,万一配置写错还有退路。
还有一条容易忘的:隧道用的私钥不要和日常登录的私钥共用。给它单独生成一份,在 authorized_keys 里配合 from= 限定来源 IP,配合 command= 或 restrict 限制用途。这样一旦某台机器上的私钥泄露,影响范围是被封死的。
六、什么时候该用隧道,什么时候该换方案
SSH 隧道适合临时、低频、调试性质的场景:一次数据迁移、一次远程排障、一台机器上的后台面板临时给自己看。它不适合长期承载业务流量,原因有三个:单条 TCP 连接的所有流量挤在一个加密通道里,吞吐会被窗口和加密开销拖住;连接一断所有正在进行的请求全废;没有负载均衡,出问题没有第二条路。
长期方案应该往 WireGuard 这类 VPN 走,或者干脆把服务放在内网、用反向代理加认证暴露出去。判断标准是:这个转发会连续存在超过一个月吗?如果是,就不该用 SSH 隧道硬扛。
另外还有一个常见误用:用隧道去做「对外提供网站」。远程转发加 GatewayPorts 确实能让外部访问到你的内网服务,但这条链路上没有任何限流、没有 WAF、没有日志审计,一旦被扫描器发现就是一个敞开的入口。真要对外,正规做法是反向代理加证书加访问控制,隧道只作为运维通道。