一、要解决的问题
有一台机器只在内网(家里、局域网、公司),上面跑着某个服务,端口比如 9100。现在需要从外网访问它,但不想把端口直接暴露到公网。
标准做法是 SSH 反向隧道:内网机器主动连到云服务器,建立一条从云端端口指回本地端口的转发通道,云端再用 nginx 对外提供 HTTPS。
完整链路:
外网浏览器
→ https://xxx.example.com(云端 nginx,443)
→ 127.0.0.1:19100(云端 sshd 开的转发监听,只绑回环)
→ SSH 隧道
→ 127.0.0.1:9100(内网机器上的服务)
两个关键点:
- 云端那侧的转发端口只绑 127.0.0.1,公网扫不到它,能访问到的只有 nginx 的 443
- 内网机器是主动发起连接的一方,不需要公网 IP,也不用开放入站端口
下面按配置顺序走一遍。
二、准备:云服务器 sshd
云端 sshd 需要允许 TCP 转发,并确认转发端口只落在回环上。
# /etc/ssh/sshd_config
Port 52022
AllowTcpForwarding yes
GatewayPorts no
Port:换成非默认端口,能挡掉一批自动化扫描GatewayPorts no:安全默认值。客户端即使请求-R 0.0.0.0:19100:...,服务端也只会绑127.0.0.1
改完重载:
systemctl reload sshd
云端还要装好 nginx,后面反代要用。
三、准备:密钥登录
内网机器要免密连云端,用 ed25519 密钥对。
在内网机器上生成:
ssh-keygen -t ed25519 -f ~/.ssh/yang_cloud_key -C "myserver"
把公钥传到云端:
ssh-copy-id -i ~/.ssh/yang_cloud_key.pub -p 52022 root@<云端公网IP>
等价的手动做法是:把 .pub 的内容追加到云端的 ~/.ssh/authorized_keys。
权限要求:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/yang_cloud_key
chmod 600 ~/.ssh/authorized_keys # 云端执行
私钥必须是 600,ssh 才会使用。权限过宽会直接拒绝:
WARNING: UNPROTECTED PRIVATE KEY FILE!
Permissions 0755 for '...' are too open.
写别名,省掉每次敲 IP 和端口:
# 内网机器的 ~/.ssh/config
Host yun
HostName <云端公网IP>
Port 52022
User root
IdentityFile ~/.ssh/yang_cloud_key
IdentitiesOnly yes
IdentitiesOnly yes 建议加上:否则 ssh 会把 agent 里的密钥挨个试一遍,报错信息被无关的认证失败刷屏,很难读。
验证:
ssh yun 'hostname && whoami'
四、配置隧道
先手动跑通
ssh -N -T \
-o BatchMode=yes \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R 127.0.0.1:19100:127.0.0.1:9100 \
yun
各参数作用:
| 参数 | 作用 |
-N |
不执行远程命令,只做端口转发 |
-T |
不分配伪终端 |
BatchMode=yes |
禁用密码交互,认证失败立刻退出(交给 systemd 跑时必需,否则会卡在密码提示) |
ExitOnForwardFailure=yes |
转发端口被占用时直接退出,不会出现"看起来连上了其实没通" |
ServerAliveInterval=30 |
30 秒一次心跳 |
ServerAliveCountMax=3 |
连续 3 次无回应判定失联,约 90 秒 |
-R 127.0.0.1:19100:127.0.0.1:9100 |
云端 19100 → 本地 9100 |
跑通的判据(在云端执行):
ss -ltn | grep 19100
curl -s http://127.0.0.1:19100/health
看到 LISTEN 127.0.0.1:19100 且 curl 返回正常内容,说明隧道已建立。
再交给 systemd 常驻
端口表(内网机器):
# /etc/ssh-tunnel/ports.conf
# 格式: <云端端口>:<本机端口>
# 每行一条,# 开头为注释
19100:9100
启动脚本,读表拼出 -R 参数,一条 ssh 连接开多个转发:
#!/usr/bin/env bash
# /usr/local/bin/ssh-tunnel-run
set -euo pipefail
CONF=/etc/ssh-tunnel/ports.conf
ARGS=()
while IFS= read -r line; do
line="${line%%#*}"
line="$(echo "$line" | tr -d '[:space:]')"
[ -z "$line" ] && continue
cloud_port="${line%%:*}"
local_port="${line##*:}"
ARGS+=(-R "127.0.0.1:${cloud_port}:127.0.0.1:${local_port}")
done < "$CONF"
[ ${#ARGS[@]} -eq 0 ] && { echo "ports.conf 为空,无转发"; exit 0; }
echo "启动隧道 -> yun : ${ARGS[*]}"
exec /usr/bin/ssh -N -T \
-o BatchMode=yes \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o TCPKeepAlive=yes \
"${ARGS[@]}" \
yun
systemd 单元:
# /etc/systemd/system/ssh-tunnel.service
[Unit]
Description=SSH reverse tunnel to cloud server
Requires=sysinit.target system.slice
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart=/usr/local/bin/ssh-tunnel-run
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
三个细节:
Wants=network-online.target:保证网络就绪后才起隧道,否则开机阶段会因为解析不到主机名反复重试Restart=always+RestartSec=10:连接断了自动重连,10 秒间隔避免疯狂重试StartLimitIntervalSec属于[Unit]段。写在[Service]里会被 systemd 忽略并刷日志:Unknown key 'StartLimitIntervalSec' in section [Service], ignoring.
启用:
chmod +x /usr/local/bin/ssh-tunnel-run
systemctl daemon-reload
systemctl enable --now ssh-tunnel.service
systemctl status ssh-tunnel.service
加新端口
只改端口表,不用重写 unit:
echo '19200:9200' >> /etc/ssh-tunnel/ports.conf
systemctl restart ssh-tunnel.service
端口号是 -R 参数在 SSH 协议层传过去的,云端不需要任何配置文件。云端 sshd 只负责"在指定端口起监听、把流量丢回来",不需要知道它对应本机哪个服务。
五、云端 nginx 反代
server {
listen 80;
server_name xxx.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name xxx.example.com;
ssl_certificate /path/fullchain.pem;
ssl_certificate_key /path/privkey.pem;
client_max_body_size 100m;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:19100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
}
proxy_pass 指向 127.0.0.1:19100——也就是隧道在云端开出来的那个监听。nginx 和隧道都在同一台机器上,走回环,不经过公网网卡。
改完验证:
nginx -t && systemctl reload nginx
curl -I https://xxx.example.com/health
六、验证清单
按顺序过一遍,任何一步不过就停在那步:
- 内网服务本身活着——内网机器执行
curl 127.0.0.1:9100/health - 隧道进程在跑——内网机器执行
systemctl status ssh-tunnel.service - 云端转发监听存在——云端执行
ss -ltn | grep 19100 - 云端能拿到内容——云端执行
curl http://127.0.0.1:19100/health - nginx 反代通了——云端执行
curl -I https://xxx.example.com/health - 外网能访问——任意外网机器执行第 5 步
第 3 步是分水岭:云端没有 19100 监听,说明问题在密钥或隧道层;有监听但第 4 步失败,说明隧道通了但目标服务不对;第 4、5 步都过而第 6 步不过,问题就在 nginx 或域名解析。
七、常用运维
# 看隧道日志
journalctl -u ssh-tunnel.service -n 50 -f
# 看隧道真实命令行(确认 -R 参数)
systemctl cat ssh-tunnel.service
pgrep -af 'ssh -N -T'
# 重启
systemctl restart ssh-tunnel.service
# 云端看转发监听
ss -ltnp | grep 19100
一个容易忽略的设计选择:不要让隧道服务依赖业务服务。
如果隧道 unit 里写了 Requires=hindsight-daemon.service 这类依赖,语义是"业务服务停,隧道跟着停;但业务服务起,隧道不保证跟着起"。结果就是业务服务重启后隧道躺着不回来,公网 502,而隧道本身的配置完全正确。
隧道是网络层的东西,用 PartOf= 管理启停传播,或者干脆不绑定任何业务服务,只依赖网络。
八、小结
- 链路:内网服务 →
ssh -R→ 云端回环端口 → nginx → HTTPS - 密钥:私钥在发起端,公钥在被连端,私钥权限 600
- 转发:
-R绑回环,BatchMode+ExitOnForwardFailure保证失败得干脆,心跳保证失联可被发现 - 端口表只放内网机器,云端零配置
- systemd:只依赖网络,
Restart=always自愈,PartOf管启停传播 - 验证:内网服务 → 隧道进程 → 云端监听 → nginx → 外网,五步逐层确认
这套配置跑通之后,内网服务对外就只剩一个 HTTPS 入口,其余端口一个都没暴露。
本文核心观点与文字为本人原创,部分配图/排版由 AI 工具辅助完成。
文章评论