Tailscale + Cloudflare Tunnel 实战:家庭服务内外网穿透完全指南

Tailscale + Cloudflare Tunnel 实战:家庭服务内外网穿透完全指南

一台闲置旧 Mac + Tailscale + Cloudflare Tunnel,把家里的影音服务同时暴露给外网和局域网,兼顾便利性和安全性。本文记录从零搭建的完整过程,包含踩坑与优化。

文中的 IP、主机名、域名、路径均为示意代称,请按自己的环境替换。

背景

家里有一台闲置的 MacBook Pro,外挂一块移动硬盘,存放着几百 G 的影视资源。想把它利用起来当家庭影音服务器(Jellyfin),但有几个需求:

  • 内网:局域网内任何设备直接访问,无需额外配置
  • 外网:人在外面时,手机/笔记本也能通过加密隧道访问
  • 安全:服务不直接暴露公网 IP,靠自建隧道保护

最终方案长这样:

外网设备 ──Tailscale(VPN,自动直连/中继)──→ 旧Mac(Jellyfin :8096)
局域网设备 ──192.168.x.x:8096────────────────→ 旧Mac(Jellyfin :8096)

核心思路:访问入口只有 Tailscale 和局域网,公网入口不开。

一、Tailscale:零配置打通内外网

Tailscale 底层是 WireGuard,但相比手动配置 WireGuard,它解决了两个痛点:

  1. 自动 NAT 穿透(打洞):两端都能拿到公网 IP 的端口映射,直接 P2P 连,不经过服务器
  2. DERP 中继兜底:打洞失败时,自动选择一个就近的中继节点转发,保证永远能连上

1.1 安装与登录

macOS 直接装官方 App,CLI 会随 App 一并提供:

brew install --cask tailscale
# 或到 https://tailscale.com/download 下载 pkg

装完启动 App,点菜单栏图标登录(可用 Google/微软/GitHub 账号,会得到一个白名单的 private tailnet):

tailscale status
# 输出类似:
# 100.x.x.10   home-mac      you@  macos
# 100.x.x.20   phone-android you@  android

登录后被分配一个 100.x.x.x 的私有地址(fd7a:115c:a1e0::/16 也是保留给它的 IPv6 网段)。MagicDNS 域名自动生效,home-mac.tailnet-xxxx.ts.net 就能解析到这台机器。

1.2 验证连接质量

# 看与某台设备的实际链路
tailscale ping phone-android
# pong from phone-android via <peer-public-ip>:<port> in 41ms  ← 直连打洞成功
# pong from phone-android via DERP(hkg) in 288ms               ← 走香港中继兜底

# 查看 NAT 类型,判断打洞难易
tailscale netcheck
# * UDP: true
# * IPv4: yes, <your-public-ip>:<port>
# * IPv6: no, but OS has support
# * MappingVariesByDestIP: false        ← 端无关映射,打洞友好
# * PortMapping: UPnP, NAT-PMP, PCP     ← 路由器支持自动端口映射

MappingVariesByDestIP: false 表示 NAT 是端无关映射(Endpoint-Independent Mapping),这类 NAT 打洞成功率非常高。这也是我后面实际用时,手机切 5G 也能直连的根本原因。

1.3 网络切换自动选路

Tailscale 客户端是事件驱动的:切换 Wi-Fi/蜂窝时自动重新做 netcheck 并重选路径,无需任何手动操作。

  • 手机连家庭 Wi-Fi(同网段)→ 直接走内网 IP 直连,延迟 <5ms
  • 手机切 5G → 先尝试 UDP 打洞,成功就直连;失败自动退到邻近 DERP 中继

注意:不是“切 5G 就一定走中继”。5G 运营商多为对称 NAT/CGNAT,打洞常常失败,这时候才会走中继,这与家庭宽带(通常支持 UPnP)差别很大。

二、Cloudflare Tunnel:免费反向代理(用于 Web 类服务)

如果是 Jellyfin 这类需要长连接的影音服务,Tailscale 的 P2P 直连体验最好。但如果你想给博客、运维后台这些 HTTP 服务一个标准域名入口,Cloudflare Tunnel 是更好选择——免费、WebSocket 支持、自带 TLS 结束。

说明:我把 Jellyfin 放在 Tailscale 后面(影音流量走内网直连更高效),而博客/工具站这类用 Cloudflare Tunnel 暴露。两者互补,不冲突。

2.1 安装 cloudflared

# macOS
brew install cloudflared

2.2 登录并创建 Tunnel

# 一次登录,拉取证书(token)
cloudflared tunnel login

# 创建一条隧道(命名随意)
cloudflared tunnel create my-tunnel

# 配置文件 default.yml 里声明 ingress 规则:
# tunnel: my-tunnel
# credentials-file: /Users/<you>/.cloudflared/<uuid>.json
# ingress:
#   - hostname: blog.example.com
#     service: http://localhost:8081
#   - hostname: tools.example.com
#     service: http://localhost:8888
#   - service: http_status:404   # 兜底

# 启动(可配合 launchd 做开机自启)
cloudflared tunnel run my-tunnel

2.3 配置 DNS

在 Cloudflare 控制台给域名加一条 CNAME:

blog.example.com  CNAME  <tunnel-id>.cfargotunnel.com  Proxy: ON

至此外网访问 https://blog.example.com 就直通家里的服务了。

2.4 踩坑:Origin Rule / SNI 问题

如果 tunnel 的 service 指向的是别的机器的域名(比如想让 media.example.com 反代到 Tailscale 节点的 xxx.ts.net),会遇到 HTTP 525 SSL握手失败:

SSL received a record that exceeded the maximum permissible length

原因:Cloudflare 到上游握手时带的 SNI/Host 与上游证书域名不匹配。标准解法是在 Cloudflare 控制台建一条 Origin Rule,把 Host Header 改写成 xxx.ts.net:

Rule:
  Hostname: media.example.com
  Action: Override host header -> xxx.ts.net

实测教训:这个规则走 Cloudflare Rulesets API 创建需要相应权限,我当时的 API Token 只能读写 DNS,403 创建失败,最后是手动在面板加的。Token 权限比你想的更值得提前规划。

三、实战:把 Jellyfin 同时暴露给局域网和 Tailscale

3.1 Docker 部署

用 Docker Compose(万恶的 macOS 裸装服务在当前这个时代已经过时了,一切容器化):

services:
  jellyfin:
    image: linuxserver/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    environment:
      - PUID=501
      - PGID=20
      - TZ=Asia/Shanghai
      # 填你的 Tailscale 地址,示意:
      - JELLYFIN_PublishedServerUrl=http://100.x.x.10:8096
    ports:
      - "8096:8096"                        # 3.3 里会改成更严谨的绑定方式
    volumes:
      - ${HOME}/jellyfin/config:/config
      - ${HOME}/jellyfin/cache:/cache
      - /Volumes/ExternalDisk/media:/movies

3.2 第一次踩坑:只绑定 Tailscale IP 的连锁反应

最开始按“只允许 Tailscale 访问”的严要求,我把它绑到了 Tailscale IP 上:

ports:
  - "100.x.x.10:8096:8096"

验证确实在 Docker 里可以绑定 Tailscale IP 且只有 VPN 能访问。但代价是:

  1. 本机 localhost:8096 连不上
  2. 局域网其它设备 192.168.x.x:8096 连不上
  3. 我写的自动扫描脚本用的是 127.0.0.1,全部失效

这对“家庭影音”场景太死板了。后来放开成 8096:8096(等价 0.0.0.0):

  • 本机 localhost ✅
  • 局域网 192.168.x.x:8096 ✅
  • Tailscale 100.x.x.10:8096 ✅

安全性如何保住? 家庭宽带在 NAT 后面本来就没公网 IP 直达,且不开路由器端口转发,公网访问 Jellyfin 根本不可能打进来。真正要防的是“局域网内未经授权的设备”,这层用 Jellyfin 自己的登录密码来处理。

结论:Tailscale IP 专属绑定适合“只允许 VPN”的敏感服务;家庭影音这类,绑 0.0.0.0 + NAT 天然隔离 + 服务自身认证是更平衡的方案。别教条。

3.3 第二个踩坑:exFAT 移动硬盘导致自动扫描失效

我用 launchd 每 60 秒检查一次媒体库目录的 mtime,变了就触发 Jellyfin 的 /Library/Refresh:

MTIME=$(stat -f %m "/Volumes/ExternalDisk/media")
if [ "$MTIME" != "$PREV" ]; then
  curl -X POST http://127.0.0.1:8096/Library/Refresh ...
fi

测试一切正常,但实际用的时候完全不触发。排查过程:

发现新电影已拷入 /Volumes/ExternalDisk/media/<Some Movie>(2025)/
顶层目录 mtime 却还停在一周前的旧值

原因:移动硬盘是 exFAT 且经 macOS 新 fskit 框架挂载(mount 输出可见 exfat, local, noatime, fskit)。实测这个组合有一个怪癖——往顶层目录新建子目录时,父目录的 mtime 不更新。我最初基于“顶层 mtime”的判断从此发散,彻底失明。

修复:放弃判断目录 mtime,改成递归找“比某个标记文件新的文件”:

# 标记文件
MARKER="$HOME/.local/var/jellyfin/.scan_marker"

# 找任意一个比标记新的普通文件(排除 ._ AppleDouble 垃圾)
NEW=$(find "$MEDIA_DIR" -type f ! -name ".*" -newer "$MARKER" -print -quit 2>/dev/null)
[ -z "$NEW" ] && exit 0

# 触发一次全库扫描
curl -X POST http://127.0.0.1:8096/Library/Refresh \
  -H "Authorization: MediaBrowser Token=$TOKEN"
# 扫完再更新标记
touch "$MARKER"

find -newer 直接比较文件本身的 mtime,绕开了 exFAT 父目录 mtime 的所有坑,且 -print -quit 找到第一个就停,开销极小。拷贝大文件中途也会触发一次,无妨,下一轮还能再发现。

3.4 第三个坑:删除媒体库后的虚影(孤儿条目)

用 API 删掉某个媒体库(DELETE /Library/VirtualFolders?name=xxx)之后,条目并不会被立即清理——数据库里会残留一堆挂在幽灵文件夹下的孤儿记录。它们在界面里仍然可见。这时候需要:

  1. 查询所有 Path 里含目标路径的条目并逐个 DELETE /Items/{id};
  2. 删除父文件夹时子条目会级联消失;
  3. 最后残留的“库根节点”即使 API 报 500 删不掉,重启容器后 Jellyfin 启动自检会把它清掉。
# 找出所有残留(把 target-lib 换成你的库路径片段)
curl ".../Items?Recursive=true&Limit=2000&Fields=Path" -H auth | jq -r '
  .Items[] | select(.Path | contains("target-lib")) | .Id' > /tmp/ghost.txt

# 逐个删除
while read id; do
  curl -X DELETE ".../Items/$id" -H auth
done < /tmp/ghost.txt

四、最终访问矩阵

场景 地址 走什么路径
本机 http://localhost:8096 直连
局域网 http://192.168.x.x:8096 内网直连
外网(手机) http://100.x.x.10:8096 Tailscale P2P 或 DERP 中继
公网 Web https://blog.example.com Cloudflare Tunnel

五、打洞成功率的优化建议

如果 tailscale ping 频繁走 DERP 中继(延迟 200ms+),按优先级尝试:

  1. 两端都开 IPv6:国内移动联通电信的 4G/5G 均有原生 IPv6,家庭宽带开 IPv6 后,两端直接 IPv6 直连,完全不需要打洞。netcheck 从 IPv6:no 变 IPv6:yes 就是成功信号
  2. 避免双 NAT:光猫桥接、路由器拨号,或至少让光猫开 UPnP,避免两层 NAT 削弱打洞能力
  3. 路由器开 Full Cone NAT(多数国产品牌支持):全锥形是最友好的 NAT 类型
  4. 打洞注定失败的场景(对称 NAT 对对称 NAT),不如自建近点 DERP/Peer Relay 兜底,缩短中继延迟

六、总结

  • Tailscale 负责“内”:家庭内网设备、移动设备的加密访问,P2P 直连体验好
  • Cloudflare Tunnel 负责“外”:给 HTTP 类服务一个标准域名 + HTTPS 入口
  • 两者互补,家庭影音类长连接服务放 Tailscale 后面最舒服
  • 三个花费最多时间的坑:Tailscale IP 绑定过死的教训、exFAT+fskit 的 mtime 陷阱、删除媒体库后的孤儿条目——都已经排过雷

最后提醒一句:一切容器化、所有数据挂载宿主机,别把状态留在容器里。重来一次的成本越低,折腾起来越安心。


本文基于真实排障过程整理,环境:macOS + OrbStack(Docker) + linuxserver/jellyfin + Tailscale + cloudflared。文中地址均为示意。