本页目录
rathole + Caddy 内网穿透:从 CGNAT 到公网 HTTPS 的完整方案
适用场景:CGNAT 或无公网 IP,有 VPS,只需要 TCP 端口转发,追求轻量零内核依赖,不想装 WireGuard 或配 Cloudflare Tunnel。
本机用 rathole——一个 Rust 写的轻量 TCP 隧道,搭在 Docker 里,配合 Caddy 做 TLS 反代,把 Grafana、文档站、SSH 全部透出去。本文从选型、搭建、证书策略,到踩过的三个大坑(HTTP/3、conntrack 掐连接、本机代理劫持隧道),给出一套可复现的完整配置。
一、rathole 是什么,什么时候用它
内网穿透的选项很多,按场景选:
| 方案 | 适合 | 不适合 |
|---|---|---|
| rathole | 有 VPS,只需 TCP 端口转发,轻量零依赖 | 需要 UDP、P2P 直连 |
| frp | 同上 + 需要 Web 管理面板、更多协议 | 配置更重 |
| Tailscale | 不想管 VPS,只自己用,接受 relay 延迟 | 家人/公网用户需要装 client |
| WireGuard | 已有 VPS,想组网而非端口映射 | 需要内核模块,CGNAT 下需额外打洞 |
| Cloudflare Tunnel | 有域名在 CF,完全不想管 VPS | 流量过 CF(隐私/合规),国内慢 |
本机选的 rathole。理由:已有一台腾讯云 VPS,只需要把家里几个 HTTP 服务 + SSH 透出去,不需要 UDP/P2P/管理面板。rathole 就是一条 TCP 隧道,不解析 TLS、不碰证书——TLS 交给 VPS 上的 Caddy。
如果你的场景和本机不重合:CGNAT + 没有 VPS 选 Cloudflare Tunnel;需要组网选 Tailscale;需要 UDP 选 frp。这几种本机没深度用过,下文只展开 rathole 方案。
二、架构:一条隧道,两个角色
角色分工很清晰:
- rathole-client(家里,Docker + host 网络):连到 VPS 的 server,把家里
127.0.0.1:3000映射为 VPS 上的127.0.0.1:13000。 - rathole-server(VPS,Docker + host 网络):监听
:2333,接收 client 连接,按配置把各服务 bind 到本机端口。 - Caddy(VPS,裸机):对外监听 443,按域名反代到 rathole-server 的对应端口;TLS 证书全自动。
家里服务全部只绑 127.0.0.1,不经 rathole 隧道无法从外部访问——这是纵深防御里的"最小暴露面"。
三、搭起来:从零到公网可访问
3.1 VPS 侧:rathole-server + Caddy
两台机器的 rathole 都跑 Docker,用 host 网络(容器内 127.0.0.1 = 宿主机)。先写 server.toml:
# VPS: /opt/rathole/server.toml
[server]
bind_addr = "0.0.0.0:2333" # client 连这个口
[server.transport]
type = "tcp"
[server.services.grafana]
token = "<生成一个随机 token>" # client 也要配同样 token
bind_addr = "127.0.0.1:13000" # 只给 Caddy 反代,不暴露公网
[server.services.doc]
token = "<随机 token>"
bind_addr = "127.0.0.1:13002"
# SSH 不走 Caddy,直接 bind 0.0.0.0:
[server.services.homessh]
token = "<随机 token>"
bind_addr = "0.0.0.0:30022"
# 其余服务: openwebui→13001, paste→13003, caldav→13004, OTLP→9443(同上格式)
# VPS: /opt/rathole/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-server
restart: unless-stopped
network_mode: host
command:
volumes:
- ./server.toml:/config/server.toml:ro
然后 Caddy 反代(在 VPS 上裸机跑):
# /etc/caddy/Caddyfile
grafana.liz6.com {
reverse_proxy 127.0.0.1:13000
}
doc.liz6.com {
reverse_proxy 127.0.0.1:13002
}
Caddy 会自动去 Let's Encrypt 签 TLS 证书——如果 VPS 有独立公网 IP 且 80/443 端口可达。不行的话,见 3.3 节证书策略。
3.2 家里侧:rathole-client
# 家里: /opt/rathole-home/client.toml
[client]
remote_addr = "<VPS 公网 IP>:2333"
[client.transport]
type = "tcp"
[client.transport.tcp]
keepalive_secs = 10 # 保活心跳,对抗 NAT/conntrack 掐连接
keepalive_interval = 5
nodelay = true
[client.services.grafana]
token = "<和 server.toml 同样的 token>"
local_addr = "127.0.0.1:3000"
[client.services.doc]
token = "<和 server.toml 同样的 token>"
local_addr = "127.0.0.1:8090"
# 家里: /opt/rathole-home/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-client
restart: unless-stopped
network_mode: host
command:
volumes:
- ./client.toml:/config/client.toml:ro
network_mode: host 让容器直接访问宿主机的 127.0.0.1:3000 等端口——所有家里服务绑 localhost 即可,无需额外暴露给 Docker 网桥。
3.3 证书策略:两个场景
场景 A:VPS 有独立公网 IP(推荐)。 Caddy 的 tls 块留空就行,全自动 Let's Encrypt。这是当前本机主力配置。
场景 B:NAT VPS,公网 80/443 不可用。 共享 IP NAT 且境外不通(连不上 Let's Encrypt)时:
- 家里跑
acme.sh,用阿里云 DNS-01 签通配符*.liz6.com(ACME 请求经代理出国,DNS API 走国内直连)。 --install-cert --reloadcmd自动 scp 证书到 VPS + reload Caddy。- VPS 上 Caddy 用静态证书(不走自动 LE),URL 带 NAT 高位端口后缀。
能从场景 B 切到场景 A 时,URL 去掉端口后缀,acme.sh 通配证书仅保留给 DERP 等无法走 LE 的场景。能用标准 443 + Caddy 自动 LE 就尽量用,少一个手动环节少一个故障面。
四、踩坑:这三个坑全踩过一遍
4.1 HTTP/3 的 alt-svc 端口对不上
Caddy 默认开启 HTTP/3(QUIC)。HTTP/3 靠 alt-svc 响应头告诉浏览器"下次走 QUIC,端口是 X"。但 Caddy 只知道自己监听在哪个口,不知道外面有没有 NAT 转发。
如果公网端口 ≠ Caddy 监听端口(场景 B 的 NAT 高位口),alt-svc 里写的端口就错了——浏览器收到错误的 QUIC 端口号,尝试连接失败,页面打不开。
现象:Open WebUI 在场景 B 下莫名其妙打不开;curl 正常(走 HTTP/1.1),浏览器不行(尝试 QUIC)。排查:浏览器 DevTools → Network,看是否卡在 h3 请求上。
修法:关掉 Caddy 的 h3——servers { protocols h1 h2 }。切回标准 443(场景 A)后,确保 Caddy 对外端口 = 监听端口(都是 443),可重新打开 h3。如果 Caddy 前面有反向代理或 NAT 端口映射,需要 header_up 或 header_down 手动修正 alt-svc。
残留坑:浏览器会缓存 alt-svc 最长 30 天(HSTS 也会影响),修完后用无痕窗口或用 DevTools → Application → Clear site data 验证。
4.2 Early EOF:conntrack 掐空闲连接
CGNAT ↔ 共享 IP NAT 跨网链路上,rathole 日志每 ~10 分钟刷 Failed to read cmd: early eof——控制通道(长连接,平时基本没流量)被中间 conntrack 当 idle 清理了。
这个不是 rathole 的问题。 裸 TCP 隧道(frp 同理)在这类链路上都吃这个——连接空闲太久,中间 NAT 网关的 conntrack 表项到期被清,下次通信时 TCP 直接 reset。
修法(两个手段,各管各的):
- TCP keepalive(保活):client.toml 设
keepalive_secs=10 / keepalive_interval=5——rathole 连接都是 client 主动发起,只改 client 端即可覆盖控制和数据通道。 - Watchdog 兜底(见第五节):keepalive 只是降低概率,极端链路仍会断;watchdog 断连后自动重启恢复。
4.3 大坑:本机 TUN 代理劫持了隧道传输
这三个里藏得最深的一个。
现象:VPS 是独立公网(ICMP 0 丢包、延迟稳),keepalive 也开了,但 rathole 照样稳态超时——24h 约 470 次 timeout、控制通道每 ~7min 重建。更诡异的是:用得越少的服务超时越多(上百次),高频的 grafana 才个位数。
排查过程——常规手段在 TUN 代理环境下会骗你:
先用 curl / nc 测 VPS 连通性 → 全通。后来才发现:在开着 TUN 代理(mihomo)的机器上,curl 的 TCP 握手会被 TUN 在本地截住假应答,连一个 VPS 上根本没监听的端口也"成功"。VPS 端 tcpdump 抓不到任何包,家里这边却说通了。
通往真相的排查路径:
ip route get <VPS_IP>→via 198.18.0.2 dev Meta——到 VPS 的流量全部穿 TUN。- 用
curl -v连 VPS 上一个确定关闭的端口(如 9999),如果"成功" = TUN 假应答;如果被拒绝 = 真的到了 VPS。 - VPS 上
tcpdump -i eth0 tcp port 2333抓包——注意不能用-i any,它用 cooked 伪链路头会导致tcp[tcpflags]字节偏移过滤静默零匹配。
根因:家里机器自己跑着 mihomo TUN,做了 auto-route + strict-route,接管了整机出向流量。rathole 到 VPS 的连接被塞进 mihomo 的 TUN 数据面——虽然分流规则判为 DIRECT(不挂代理节点),但 DIRECT 只表示"不走代理出口",连接本身仍然穿 TUN 用户态栈。mihomo 的空闲连接回收/reload 会掐掉 rathole 预建连接池里的 data channel = 超时。
本质和 4.2 节 Early EOF 同一类(idle 连接被回收),只是元凶从运营商 NAT 换成了本机代理。
修法:把 VPS IP 写进 mihomo 的 TUN 路由排除列表,让它走内核直连、彻底绕开代理:
# mihomo config.yaml
tun:
inet4-route-exclude-address:
- 1.117.75.80/32 # rathole VPS, 绕开本机 mihomo TUN
systemctl restart mihomo 后 ip route get <VPS_IP> 应变成 via 192.168.31.1 dev wlp9s0(原生路由),再 docker restart rathole-client 让连接重建走直连。改后连续静默 5 分钟零 timeout、8/8 服务在线——之前是每 7 分钟必断。
通用原则:隧道 / VPN 的承载连接(transport)永远不要再穿另一层本机 TUN 代理——既多一层故障面,又会被代理的连接生命周期管理误伤。承载连接必须走原生路由。这个原则对 rathole、frp、WireGuard、Tailscale、ZeroTier 等所有需要长连接的隧道方案都适用。
五、监控:隧道挂了怎么知道
隧道是基础设施,挂了影响所有服务。两个层次:
① 健康检查 + 自动恢复(watchdog)。 一个简单的 bash 脚本,用 systemd timer 每 5 分钟跑一次:
#!/bin/bash
# /usr/local/bin/rathole-watchdog
ENDPOINT="http://127.0.0.1:13002/health" # 经 rathole 隧道打 VPS 上 doc 服务的健康端点
MAX_FAILS=3 # 连续失败 3 次才判定为挂(防抖动)
FAIL_FILE=/tmp/rathole-watchdog-fails
if [; then
fails=
if [; then
fi
else
fi
② 监控盲区:单个业务通道卡死。 watchdog 只探了一个端口(本机探 doc 的 13002),如果其他隧道通道卡了(比如 grafana 的 13000),watchdog 看不见——rathole 的控制通道还活着,健康端点也通,但某条数据通道在一轮网络抖动后不自愈。
真实案例:doc.liz6.com 挂了数小时——TLS/证书正常,但 rathole 的 doc 数据通道在一次网络抖动后卡死,其余 5 条隧道全好。docker restart rathole-client 强制全通道重连才恢复。
教训:watchdog 应增探至少两个业务端口(如 doc + grafana),不能只探一个。理想状态下,如果 rathole 或后续替代方案支持 per-service 重连,应针对性只重启卡死的通道,而不是重启整个 client(会踢掉所有连接的 SSH 会话)。
六、安全加固
- VPS sshd:
PasswordAuthentication no,仅密钥登录。 - rathole 所有 bind 默认
127.0.0.1;只有 SSH 隧道因不走 Caddy 绑了0.0.0.0:30022,加 TOTP 2FA(google-authenticatorPAM)。 - Caddy 只反代 rathole 隧道口,不直接暴露家里端口。
- 敏感链路(如 OTLP metrics)额外套 mTLS——Caddy 验证客户端证书 + 吊销检查,隧道只透传密文。
- VPS 侧用
ufw或iptables限制 rathole 端口(:2333)仅允许家里 IP 连接;IP 会变的话至少限制 SSH 端口(:30022)的爆破尝试(fail2ban)。 - rathole token 用
openssl rand -hex 24生成强随机值,不要多服务共用同一个 token。 - Docker
network_mode: host意味着容器能直接访问宿主机所有端口——仅限可信网络使用,rathole-server 和 rathole-client 都应在受控机器上跑。
七、完整配置
VPS:server.toml + docker-compose.yml
# /opt/rathole/server.toml
[server]
bind_addr = "0.0.0.0:2333"
[server.transport]
type = "tcp"
[server.services.grafana]
token = "<随机 token>"
bind_addr = "127.0.0.1:13000"
[server.services.doc]
token = "<随机 token>"
bind_addr = "127.0.0.1:13002"
# SSH 不走 Caddy,直接暴露 0.0.0.0
[server.services.homessh]
token = "<随机 token>"
bind_addr = "0.0.0.0:30022"
# /opt/rathole/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-server
restart: unless-stopped
network_mode: host
command:
volumes:
- ./server.toml:/config/server.toml:ro
VPS:Caddyfile(示例,按需加路由)
grafana.liz6.com {
reverse_proxy 127.0.0.1:13000
}
doc.liz6.com {
reverse_proxy 127.0.0.1:13002
}
家里:client.toml + docker-compose.yml + watchdog
# /opt/rathole-home/client.toml
[client]
remote_addr = "<VPS IP>:2333"
[client.transport]
type = "tcp"
[client.transport.tcp]
keepalive_secs = 10
keepalive_interval = 5
nodelay = true
[client.services.grafana]
token = "<和 server.toml 一致>"
local_addr = "127.0.0.1:3000"
[client.services.doc]
token = "<和 server.toml 一致>"
local_addr = "127.0.0.1:8090"
# /opt/rathole-home/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-client
restart: unless-stopped
network_mode: host
command:
volumes:
- ./client.toml:/config/client.toml:ro
# /usr/local/bin/rathole-watchdog(完整版见第五节)
配 systemd timer 每 5 分钟触发:
# ~/.config/systemd/user/rathole-watchdog.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rathole-watchdog
# ~/.config/systemd/user/rathole-watchdog.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
相关文档
- network-architecture.md — 拓扑总览
- network-recovery.md — 故障恢复
- monitoring.md — 监控架构