本文记录一套把家里 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 反向隧道
一条长连接

公网用户
https://emby.example.com

VPS
nginx :443
按 Host 分发

VPS
127.0.0.1:32002

Unraid
127.0.0.1:8096
Emby

Authelia :9091
需要 2FA 时

关键点:ssh -R 32002:127.0.0.1:8096 root@VPS 的含义是"在 VPS 上监听 32002,把流量通过这条 SSH 连接送回我本地的 8096"。

于是 VPS 上就有了一个只监听 127.0.0.1 的端口,nginx 把对应的域名反代到它,配合 certbot 证书和可选的 Authelia,一个公网可访问的 HTTPS 站点就成立了。

这个方案的所有优缺点,几乎都从这一句原理推导出来——这一点在最后一节会展开。

主要功能脑图

SSH 反向隧道
LAN → 公网 VPS

隧道层

单连接多路复用

9 个 -R 挤在一条 ssh 上

抖动时只重连一次

根除多进程抢端口

自愈

指数退避 5s 翻倍封顶 120s

连续失败计数与重置

本地端口预检

远端残留端口清理

保活

ServerAlive 客户端探测

ClientAlive 服务端踢死

cron 每分钟拉起

Unraid 开机自启

/boot/config/go

公网层

nginx 反代

按 Host 头分发

80 跳 443

ACME challenge 放行

Authelia 2FA

auth_request 子请求

access_control 规则

error_page 401 跳转

certbot

webroot 模式

多域名 SAN 单证书

WebSocket 与流媒体

关闭 proxy_buffering

Upgrade 头透传

长读超时

架构分层

所在机器 职责
隧道层 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. 单点故障,而且很深

整条链路是串行的:

supervisor 进程

单条 ssh 连接

VPS sshd

320xx 监听

nginx

证书

任意一环断掉,全部服务一起挂。这不是"某个服务不可用",而是九个服务同时不可用。相比之下每个服务独立隧道的话,故障域会小一些——这是一个明确的取舍。

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 密钥文件名与指纹 → 未提及
  • 隧道端口映射表保留(属方案设计,非敏感信息)

文中所有配置与命令均来自真实运行环境,仅替换了标识性信息。