ssh -R 指定 0.0.0.0 却连不上:内网服务暴露的四个开关
这台云主机上跑着几个内部服务:一个管理面板、一个只给内部程序调用的接口、一个数据库。它们全都只监听 127.0.0.1,公网方向只开了 22,80 和 443 虽然在防火墙里放行,但当前并没有服务在用。这套配置平时很省心,直到需要在办公室看一次面板的实时数据——没有跳板机,也不想为了看一眼就把端口开到公网。
SSH 隧道是最省事的答案:不用装组件、不用改安全组、用完即断。但它的三个方向在绑定地址上的行为并不一致,其中有一个会静默降级且完全不报错。下面所有结论来自一次实测,环境为 Ubuntu 22.04 + OpenSSH 8.9p1,客户端是 OpenSSH for Windows;换发行版或换版本请自行复核,尤其低于 7.6 的客户端在 -R 的绑定行为上有差异。
先看清服务在哪个地址上听
判断能不能被隧道接住,第一步永远是看监听地址,不是看端口有没有开。
ss -tlnp | grep -E ':(22|29338|29339|18080)\b'
# LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=482,fd=14))
# LISTEN 0 10 127.0.0.1:29338 0.0.0.0:* users:(("uniagentd",pid=1438,fd=5))
# LISTEN 0 10 127.0.0.1:29339 0.0.0.0:* users:(("uniagentd",pid=1439,fd=4))
# LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1306,fd=3))
同一台机器上,厂商的监控 agent 自己就绑在 127.0.0.1:29338 和 29339 上,只让本机进程访问。这个细节值得记住:Local Address 列写 127.0.0.1 还是 0.0.0.0,是完全不同的两件事;0.0.0.0 意味着任何能路由到这台机器的地址都能连,而 127.0.0.1 只有本机协议栈内可达。
隧道的作用,就是把「本机可达」临时变成「我这边可达」。
-L:把远端服务搬到本地
最常用的方向。在本地执行:
SRV=你的主机公网地址
ssh -i ~/.ssh/id_ed25519 -N -f \
-o ExitOnForwardFailure=yes \
-L 18080:127.0.0.1:18080 root@$SRV
curl -s -o /dev/null -w 'code=%{http_code}\n' --max-time 6 127.0.0.1:18080/
# code=200
-L 本地端口:目标地址:目标端口,目标地址是从服务器侧解析的,所以写 127.0.0.1 就是连服务器自己的回环。这里 -N 表示不执行远程命令,只做转发;-f 转后台;ExitOnForwardFailure 让端口绑定失败时直接退出而不是悄悄跑着——省掉一类「隧道建了但没生效」的排查。
本地这一端默认绑在哪,值得单独确认:
# Linux/macOS
ss -tlnp | grep 18081
# Windows
netstat -ano | findstr 18081
# 默认结果(本机实测):
# TCP 127.0.0.1:18081 0.0.0.0:0 LISTENING
# TCP [::1]:18081 [::]:0 LISTENING
默认只绑回环,同网段的同事连不上你的机器,这是好事。但如果你为了「方便同事访问」写成 -L 0.0.0.0:18082:127.0.0.1:18080,实测确实会变成 0.0.0.0:18082 LISTENING,整个局域网都能进来,而这条隧道背后可能是个没有鉴权的内部接口。想给同事看,正确做法是把命令发给他们,各自建各自的 -L,而不是你一个人把口子开到全网段。
-R:把本地服务搬到远端(这里有个静默降级)
方向反过来:服务在你这台机器上,想让服务器侧访问到。这条路径在「办公室机器上跑了个小工具,想让云端任务回调」的场景里很实用。
ssh -i ~/.ssh/id_ed25519 -N -f -R 18999:127.0.0.1:18080 root@$SRV
# 到服务器上看实际绑定:
ss -tlnp | grep 18999
# LISTEN 0 128 127.0.0.1:18999 0.0.0.0:* users:(("sshd",pid=786862,fd=7))
# LISTEN 0 128 [::1]:18999 [::]:* users:(("sshd",pid=786862,fd=5))
注意监听进程是 sshd 而不是你的程序,-R 的端口是服务器上的 sshd 代开的。也正因为如此,从服务器上访问 18999 拿到的是 000——因为 -R 的目标地址是在客户端侧解析的,sshd 会回连客户端本机的 18080,而客户端当时并没有进程在听。
真正容易踩的是这件事:把绑定地址显式写成 0.0.0.0。
ssh -o ExitOnForwardFailure=yes -f -N -R 0.0.0.0:18998:127.0.0.1:18080 root@$SRV
echo "rc=$?" # rc=0,没有任何告警
ss -tlnp | grep 18998 # 实际仍是 127.0.0.1:18998 和 [::1]:18998
curl -s -o /dev/null -w '%{http_code}\n' --max-time 4 $SRV:18998/ # 000
命令返回 0、隧道「建成功了」、从外部访问却是 000。原因是 sshd 的 GatewayPorts 默认 no:无论客户端要求绑哪个地址,都会把监听强制收回到回环上,而且这条降级在客户端和服务端的日志里都不会留下告警。想让它真的绑到公网地址,要么在服务端设 GatewayPorts clientspecified(等于把「客户端想绑哪就绑哪」的权限交出去),要么别用 -R 直接对公网,改用 -L 从访问方拉。前者在公网机器上等于开了一个可以任意占用端口的能力,我不建议。
-D:一条 SOCKS,整个内网可达
当前面几条 -L 越写越多时,换成动态转发更划算:
ssh -i ~/.ssh/id_ed25519 -N -f -D 10808 root@$SRV
curl -s -o /dev/null -w 'code=%{http_code}\n' --max-time 8 \
--socks5-hostname 127.0.0.1:10808 127.0.0.1:18080/
# code=200
--socks5-hostname 让域名解析发生在远端,这一点在访问内部主机名时是必需的。一条 -D 相当于把服务器能到达的所有 TCP 目标都借给你,比自己数着端口开 -L 灵活,代价是权限范围更大——临时排障可以,长期挂在后台就不合适了。
四类报错:原文、成因、处理
| 现象 | 报错原文 | 成因 | 处理 |
|---|---|---|---|
| 本地端口能连,但立刻断开,curl 返回 56 | channel 2: open failed: connect failed: Connection refused |
隧道通了,但目标端口没有进程在听,或只绑在别的地址上 | 到目标端 ss -tlnp | grep 端口,确认是 127.0.0.1 还是 0.0.0.0;注意目标地址从哪一端解析 |
| 隧道显示建立成功,所有请求都失败,无任何客户端提示 | 客户端 channel 2: open failed: administratively prohibited: open failed;服务端 refused local port forward: originator ... target ... |
服务端 AllowTcpForwarding no,本地监听照样会创建,只在真正建通道时才拒绝 |
sshd -T | grep allowtcpforwarding;需要就改成 yes 或 local,systemctl reload ssh |
-R 指定 0.0.0.0,外部访问不可达,且无报错 |
无报错,rc=0 |
GatewayPorts no 把监听静默收回回环 |
sshd -T | grep gatewayports;改用 -L 由访问方拉,或评估后设 clientspecified |
| 隧道几分钟不动就断,再连要重开 | 客户端 Timeout, server not responding |
服务端 ClientAliveInterval 300 与 ClientAliveCountMax 3,约 10 分钟无流量即回收 |
客户端加 -o ServerAliveInterval=60,或用下面的 systemd 单元托管重连 |
| 启动时直接退出 | bind: Address already in use |
本地端口被上次残留的隧道或其他进程占用 | ss -tlnp | grep 端口 找到 pid 后清理;或换端口 |
第一行和第三行的组合最容易被误判成「防火墙问题」。实际上这两个场景里 ufw 全程没参与:一个是目标端口没监听,一个是 sshd 主动拒绝转发。真正跟防火墙有关的场景只有 -R 加 GatewayPorts clientspecified 之后从公网访问,那时才需要同时看云平台安全组和主机防火墙两层。
四个开关与最小授权
这台机器上显式写进配置的转发相关项只有一行,其余全走默认:
grep -nE '^[^#]*(AllowTcpForwarding|GatewayPorts|PermitOpen|PermitListen|AllowAgentForwarding)' \
/etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf
# /etc/ssh/sshd_config.d/99-hardening.conf:10:AllowAgentForwarding no
sshd -T | grep -E '^(allowtcpforwarding|gatewayports|permitopen|permitlisten)'
# allowtcpforwarding yes
# gatewayports no
# permitopen any
# permitlisten any
sshd -T 输出的是合并后真正生效的值,比读配置文件可靠得多——配置里没写不等于默认值就是你猜的那个。想收紧到「只允许转发到某一个端口」,可以这样写,改完先用 -T -f 验证语法再 reload:
# /etc/ssh/sshd_config.d/99-fwd.conf
AllowTcpForwarding local
GatewayPorts no
PermitOpen 127.0.0.1:18080
PermitListen none
# 先验证再应用
sshd -T -f /etc/ssh/sshd_config.d/99-fwd.conf | grep -Ei 'permitopen|permitlisten|gatewayports'
# gatewayports no
# permitopen 127.0.0.1:18080
# permitlisten none
systemctl reload ssh
还有一个更细的粒度在 authorized_keys 里:给某把公钥单独加 permitopen=,只允许它转发到指定目标,其他公钥不受影响。
# ~/.ssh/authorized_keys 中该行前面加选项
permitopen="127.0.0.1:18080",no-agent-forwarding ssh-ed25519 AAAAC3... ops@office
连接时 ssh -v 会打印服务端回传的这把密钥实际拥有的能力(key options: agent-forwarding port-forwarding pty ...),这行日志是验证限制是否生效最快的方式,比翻配置文件直观。
长跑:别让隧道静默死掉
临时排障用 -f -N 就够,超过一天的场景建议交给 systemd 托管,进程挂了自动重来,日志也进 journald:
# /etc/systemd/system/tunnel-panel.service
[Unit]
Description=SSH tunnel to internal panel
After=network-online.target
Wants=network-online.target
[Service]
User=ops
ExecStart=/usr/bin/ssh -N -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=60 -o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=yes -i /home/ops/.ssh/id_ed25519 \
-L 18080:127.0.0.1:18080 root@你的主机公网地址
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
ServerAliveInterval=60 配 CountMax=3,是在服务端 10 分钟回收之前主动保活;即使网络抖动导致连接被判死,Restart=always 也会在 5 秒后重建。注意单元里用绝对路径写密钥,User=ops 下的 ~ 展开和交互式 shell 不一致,这是这类单元最常见的失败原因。
边界
隧道只覆盖 TCP,UDP 转发需要另想办法;它也不改变服务的鉴权模型,只是把网络可达性借给你,原本没有鉴权的内部接口暴露出去仍然是没有鉴权的。审计上并非无迹可循——服务端 refused local port forward 那类日志记录了谁在什么时候想把请求转发到哪里,出问题时这是第一手材料。长期、多人、多服务的场景应该用 WireGuard 这类正式方案,SSH 隧道适合的是「现在就要看一眼,十分钟后关掉」。
最后一句实操建议:所有临时隧道都用明确的 127.0.0.1:端口 写绑定地址,不要图省事写 0.0.0.0——本地这一端会真的全开,服务端那一端会静默降级,两个方向的后果完全相反,而报错都不会告诉你。
- 点赞
- 收藏
- 关注作者
评论(0)