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,它解决了两个痛点:
- 自动 NAT 穿透(打洞):两端都能拿到公网 IP 的端口映射,直接 P2P 连,不经过服务器
- 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 能访问。但代价是:
- 本机
localhost:8096连不上 - 局域网其它设备
192.168.x.x:8096连不上 - 我写的自动扫描脚本用的是
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)之后,条目并不会被立即清理——数据库里会残留一堆挂在幽灵文件夹下的孤儿记录。它们在界面里仍然可见。这时候需要:
- 查询所有
Path里含目标路径的条目并逐个DELETE /Items/{id}; - 删除父文件夹时子条目会级联消失;
- 最后残留的“库根节点”即使 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+),按优先级尝试:
- 两端都开 IPv6:国内移动联通电信的 4G/5G 均有原生 IPv6,家庭宽带开 IPv6 后,两端直接 IPv6 直连,完全不需要打洞。netcheck 从
IPv6:no变IPv6:yes就是成功信号 - 避免双 NAT:光猫桥接、路由器拨号,或至少让光猫开 UPnP,避免两层 NAT 削弱打洞能力
- 路由器开 Full Cone NAT(多数国产品牌支持):全锥形是最友好的 NAT 类型
- 打洞注定失败的场景(对称 NAT 对对称 NAT),不如自建近点 DERP/Peer Relay 兜底,缩短中继延迟
六、总结
- Tailscale 负责“内”:家庭内网设备、移动设备的加密访问,P2P 直连体验好
- Cloudflare Tunnel 负责“外”:给 HTTP 类服务一个标准域名 + HTTPS 入口
- 两者互补,家庭影音类长连接服务放 Tailscale 后面最舒服
- 三个花费最多时间的坑:Tailscale IP 绑定过死的教训、exFAT+fskit 的 mtime 陷阱、删除媒体库后的孤儿条目——都已经排过雷
最后提醒一句:一切容器化、所有数据挂载宿主机,别把状态留在容器里。重来一次的成本越低,折腾起来越安心。
本文基于真实排障过程整理,环境:macOS + OrbStack(Docker) + linuxserver/jellyfin + Tailscale + cloudflared。文中地址均为示意。