SSH 隧道配置指南:让公网访问内网服务

2026年10月25日 3点热度 0人点赞 0条评论

一、要解决的问题

有一台机器只在内网(家里、局域网、公司),上面跑着某个服务,端口比如 9100。现在需要从外网访问它,但不想把端口直接暴露到公网。

标准做法是 SSH 反向隧道:内网机器主动连到云服务器,建立一条从云端端口指回本地端口的转发通道,云端再用 nginx 对外提供 HTTPS。

完整链路:

外网浏览器
  → https://xxx.example.com(云端 nginx,443)
  → 127.0.0.1:19100(云端 sshd 开的转发监听,只绑回环)
  → SSH 隧道
  → 127.0.0.1:9100(内网机器上的服务)

两个关键点:

  1. 云端那侧的转发端口只绑 127.0.0.1,公网扫不到它,能访问到的只有 nginx 的 443
  2. 内网机器是主动发起连接的一方,不需要公网 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

六、验证清单

按顺序过一遍,任何一步不过就停在那步:

  1. 内网服务本身活着——内网机器执行 curl 127.0.0.1:9100/health
  2. 隧道进程在跑——内网机器执行 systemctl status ssh-tunnel.service
  3. 云端转发监听存在——云端执行 ss -ltn | grep 19100
  4. 云端能拿到内容——云端执行 curl http://127.0.0.1:19100/health
  5. nginx 反代通了——云端执行 curl -I https://xxx.example.com/health
  6. 外网能访问——任意外网机器执行第 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 工具辅助完成。

kenny

曾经是一位IT相关行业工作者...

文章评论