SSH 反向隧道实践:把 Unraid 服务安全暴露到公网
本文记录一套把家里 Unraid 上的服务安全暴露到公网的方案:单条 SSH 反向隧道 + nginx 反代 + Authelia 双因素 + certbot 证书。全文按公开博客口径书写,涉及的真实 IP、域名、账号、路径等均已脱敏。
导语
家里的宽带没有固定公网 IP,或者说落在大内网(CGNAT)后面,是所有自建服务玩家都会撞上的墙。想在外面用 Emby 看片、用 qBittorrent 挂种、用 Navidrome 听歌,就得想办法把这些内网服务"递"到一个有公网地址的地方。
常见的做法有几种:frp、Cloudflare Tunnel、Tailscale,以及本文要讲的 SSH 反向隧道(ssh -R)。前几种各有优势,但 SSH 反向隧道有一个别人比不了的好处:你的 VPS 上本来就跑着 sshd,不需要装任何额外软件,一条命令就能把内网端口"挂"到公网的 127.0.0.1 上,剩下的交给 nginx。
这套方案已经在生产环境跑了很久,中间踩过的坑也足够多,所以本文不只讲"怎么搭",重点是每个坑是怎么来的、怎么避免。
一句话说清原理
关键点:ssh -R 32002:127.0.0.1:8096 root@VPS 的含义是"在 VPS 上监听 32002,把流量通过这条 SSH 连接送回我本地的 8096"。
于是 VPS 上就有了一个只监听 127.0.0.1 的端口,nginx 把对应的域名反代到它,配合 certbot 证书和可选的 Authelia,一个公网可访问的 HTTPS 站点就成立了。
这个方案的所有优缺点,几乎都从这一句原理推导出来——这一点在最后一节会展开。
主要功能脑图
架构分层
| 层 | 所在机器 | 职责 |
|---|---|---|
| 隧道层 | Unraid | 维持 SSH 长连接,把本地端口映射到 VPS 的 320xx |
| 代理层 | VPS | nginx 按 Host 头把域名转发到对应的 127.0.0.1:320xx |
| 认证层 | VPS | Authelia 提供 2FA,nginx 通过 auth_request 调用 |
| 证书层 | VPS | certbot 签发并续期,一张证书覆盖多个子域名 |
远端端口约定从 32002 开始按顺序分配,一个服务一个端口。这样做的原因见"坑"那一节。当前示例映射如下:
| 远端(VPS) | 本地(Unraid) | 服务 |
|---|---|---|
| 32002 | 8096 | Emby |
| 32003 | 3002 | Sun-Panel 导航 |
| 32004 | 80 | Unraid WebUI |
| 32006 | 5244 | Alist |
| 32007 | 4533 | Navidrome |
| 32008 | 8080 | qBittorrent |
| 32009 | 8090 | RomM |
| 32010 | 3000 | MoviePilot |
| 32011 | 9119 | Hermes |
注意 32005 是空的——分配时留一点空隙,方便临时插入新服务而不必全量重排。
核心实现:单连接 supervisor
整套方案的心脏是一个跑在 Unraid 上的 supervisor 脚本。它的第 3 版做了一个决定性改动:把原来 8 条独立的 ssh -R 进程,合并成一条 SSH 连接承载全部 -R 映射。
# ---- 远端端口|源IP|本地端口|名称 ----
TUNNELS=(
"32002|127.0.0.1|8096|emby"
"32003|127.0.0.1|3002|sun"
"32004|127.0.0.1|80|unraid"
# ...
)
SSH_OPTS=(-i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o ServerAliveInterval=15 -o ServerAliveCountMax=3
-o ExitOnForwardFailure=yes -o ConnectTimeout="$CONN_TIMEOUT"
-o BatchMode=yes -o TCPKeepAlive=yes)
ssh "${SSH_OPTS[@]}" -N "${RARGS[@]}" "$VPS"
为什么要合并?因为多个 ssh 进程在断线重连时会互相抢远端端口。A 断了刚要重连,B 也断了也来重连,两边都尝试 bind 同一个端口,输的那个就在 remote port forwarding failed 上死循环。
合并成一条连接后,抖动时只重连一次,端口竞争从根上消失了。
监督逻辑本身包含几个细节:
- 本地端口预检:只有本地服务活着才把它加进
-R参数,避免把死端口映射出去白占远端端口 - 指数退避:
5 * 2^(fails-1),封顶 120 秒,连续失败超过 120 秒无失败则计数归零 - 远端清场:启动前和检测到 bind 冲突时,主动 kill 掉 VPS 上残留的 320xx 监听进程
保活:两端都要管
只做客户端探测是不够的。客户端 ServerAlive 只能发现"我连不上服务器",而"服务器以为连接还活着、端口还被占着"这种情况必须由服务端来解决。
VPS 侧放一个 drop-in:
# /etc/ssh/sshd_config.d/99-tunnel.conf
ClientAliveInterval 15
ClientAliveCountMax 3
TCPKeepAlive yes
再配一个每分钟跑的 cron 保活,防止 supervisor 进程本身死掉:
* * * * * /root/ssh-tunnel-keepalive.sh
这个脚本用 flock 防止重入,检查 pgrep -f 'ssh-tunnel.sh start',不在就 setsid nohup 拉起来。
nginx 与证书
nginx 的模板长这样,注意两个地方:ACME 验证的 location 必须在 return 之前,以及流媒体需要的长超时与缓冲关闭。
server {
listen 80;
server_name emby.example.com;
location ~ ^/.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
server_name emby.example.com;
ssl_certificate /etc/letsencrypt/live/emby.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/emby.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:32002; # 匹配隧道远端端口
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 0;
proxy_read_timeout 600;
proxy_buffering off;
}
}
证书用 webroot 模式签发,多个域名放一张证书:
certbot certonly --webroot -w /var/www/certbot \
--cert-name emby.example.com \
-d emby.example.com,music.example.com,romm.example.com \
--force-renewal --non-interactive --agree-tos
加 2FA
不是所有服务都适合加 2FA——这一条后面会详细说。需要加的时候,Authelia 侧加规则:
- domain: 'emby.example.com'
policy: two_factor
nginx 侧加认证子请求:
location /authelia {
internal;
set $upstream_authelia http://127.0.0.1:9091/api/authz/auth-request;
proxy_pass_request_body off;
proxy_pass $upstream_authelia;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
}
location / {
auth_request /authelia;
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://auth.example.com/?rd=$target_url;
# ... 反代配置
}
改完记得 authelia validate-config 验证再重启。
优点
1. 零额外依赖
VPS 上只要有 sshd 就能用。不用装 frp 服务端、不用配 Cloudflare 账号、不用部署 Tailscale 控制平面。这对"手里已经有一台小 VPS"的人来说是最大的优势。
2. 出站连接,不开口子
隧道是 Unraid 主动连出去建立的。家里不需要开放任何入站端口,不需要动路由器端口映射,也不需要公网 IP。CGNAT 环境下这是少数可行的方案。
3. 规模化成本极低
加一个服务 = 加一行 TUNNELS + 加一个 nginx conf。没有新的进程要管,没有新的配置格式要学。九个服务和三个服务的管理复杂度几乎一样。
4. 复用现有基建
nginx、certbot、Authelia 都是 VPS 上本来就有的东西。证书统一管理、日志统一查看、认证统一策略,不用为穿透单独维护一套体系。
5. 与三层安全模型天然契合
流量必须穿过 nginx 才能到达内网服务。这意味着 nginx 层的限流、IP 白名单、认证、WAF 规则全部自动生效,不需要在内网服务里重复实现。
缺点
1. 单点故障,而且很深
整条链路是串行的:
任意一环断掉,全部服务一起挂。这不是"某个服务不可用",而是九个服务同时不可用。相比之下每个服务独立隧道的话,故障域会小一些——这是一个明确的取舍。
2. 一个远端端口只能对一个后端
想在一个端口上按路径分发多个服务(/emby 和 /music 都走 32002)?只要后端用了绝对 URL、Cookie 路径或 WebSocket,就会立刻出错。必须一个服务一个端口。
3. 调试链路长
用户报"打不开"时,要依次排查:VPS 上 320xx 在不在监听 → tunnel 的 -R 参数对不对 → 本地服务活着没 → nginx 转发目标对不对 → 证书有效期 → Authelia 策略。任何一环都可能,且现象都是"502"或"超时",没有区分度。
4. ssh -R 的 -R 参数是连接建立时固定的
改了 TUNNELS 数组但没重启 supervisor,活动连接仍然用旧的 -R 集合。这个细节很容易让人以为"配置改了没生效",实际是没重启。
5. 安全配置有两处必须谨慎
StrictHostKeyChecking=no+UserKnownHostsFile=/dev/null是为了无人值守(没有交互终端可以接受主机指纹),代价是永远不验证 VPS 身份。链路若经过不可信网络,应改为固定 known_hosts。pkill -9 -f "ssh -i <key>"是按模式匹配的,会杀掉任何命令行含该 key 路径的进程。这是有意为之,但绝不能放宽成裸的ssh。
6. 2FA 会锁死非浏览器客户端
Authelia 的 two_factor 策略要求浏览器渲染登录页。Emby 桌面端、API 客户端、媒体播放器通常做不到。给这类服务加 2FA 等于把它锁死。加之前先确认客户端形态。
7. 缺少原生并发与负载能力
SSH 隧道没有连接池、没有多路复用优化(SSH 层有多路复用,但吞吐上限受单连接限制)。大流量场景下不如 frp 之类的专用工具。
踩坑记录
这一节是全文最有价值的部分,每一条都是真实踩过的。
--expand 会静默清空其它域名的 SAN
这是最危险的一个坑。给已有证书加域名时,如果用 --expand 却只传一个 -d:
# 危险!其它域名的 SAN 会全部丢失
certbot certonly --expand -d new.example.com
--expand 的语义是"用新的域名列表替换旧的",不是"追加"。结果是其它域名悄悄失去了证书覆盖,直到证书续期或浏览器报错才被发现。
正确做法是永远传完整列表:
certbot certonly --cert-name emby.example.com \
-d a.example.com,b.example.com,c.example.com,new.example.com
签发后务必要核对 SAN:
openssl x509 -in /etc/letsencrypt/live/<cert>/fullchain.pem -noout -ext subjectAltName
ACME location 必须排在 return 之前
# 错误:所有请求都被 301 走了,certbot 拿不到验证文件
server {
listen 80;
location / { return 301 https://$host$request_uri; }
location ~ ^/.well-known/acme-challenge/ { root /var/www/certbot; }
}
nginx 的 location 匹配有优先级,但 return 在 rewrite 阶段执行。ACME 验证请求会先撞上 return 301,于是 certbot 拿到 404,报"验证失败"。顺序必须反过来。
删掉 nginx conf 不等于 404
删除 /etc/nginx/conf.d/foo.conf 后,foo.example.com 的请求不会返回 404,而是会落到其它 server 块——通常是因为别的 server 块用了 default_server 或匹配范围更宽。结果往往是返回了一个完全不相干的站点,HTTP 200。
这很容易被误判为"服务还在正常工作",实际上早就配错了。删配置时要显式确认落到哪里。
remote port forwarding failed 的真实含义
这个报错的字面意思是"远端端口绑定失败",实际原因几乎总是:VPS 上还有一个僵死的 ssh 进程占着那个端口。
典型的产生过程是网络抖动——Unraid 侧的 ssh 认为连接断了退出了,但 VPS 侧的 sshd 还没意识到,端口仍然被占。新的连接尝试 bind 就失败了。
解法是双管齐下:客户端清理(supervisor 检测到该报错后 kill 远端进程)+ 服务端主动踢(ClientAliveInterval)。
本地服务全挂时 supervisor 看起来像死了
supervisor 有个设计:只有当至少一个本地端口活着时才去连 VPS。如果 Unraid 上所有被映射的服务都停了,supervisor 会每 15 秒空转检查一次,不会建立连接。
这时候从 VPS 那边看,什么都没有——很像 supervisor 挂了。实际是设计如此。status 输出里连接数是 0,但进程在跑。
日志只在重连路径上裁剪
trim_log 只在 SSH 退出、准备重连时才执行。如果连接非常稳定,日志文件会一直增长到触发下一次重连才被裁剪。这不是 bug,但会觉得"日志怎么这么大"。
什么情况下该选别的方案
诚实地讲,这套方案不是万能的:
| 场景 | 更合适的选择 |
|---|---|
| 只需要访问少量服务,不想碰 VPS | Tailscale / WireGuard 组网 |
| 有 Cloudflare 且想要 DDoS 防护、CDN | Cloudflare Tunnel |
| 高并发、需要精细流量控制 | frp |
| 需要给外部用户提供稳定 SLAD 的公开服务 | 正经的云主机部署,别穿透 |
SSH 反向隧道的定位很清楚:你已经有一台 VPS,只想把家里几个自用服务安全地挂上去。在这个定位里,它的简单和零依赖是压倒性的优势。
审核要点
本文按公开博客口径书写,以下内容已做脱敏:
- VPS 的真实 IP 地址 →
VPS/<vps-ip> - 所有真实域名 →
*.example.com - 服务器上的绝对路径与用户名 → 通用路径
- SSH 密钥文件名与指纹 → 未提及
- 隧道端口映射表保留(属方案设计,非敏感信息)
文中所有配置与命令均来自真实运行环境,仅替换了标识性信息。