本页目录

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 架构:家里的服务经 TCP 隧道 → VPS → Caddy 暴露到公网 家里(内网) Grafana :3000 文档站 :8090 SSH :22 · 更多… rathole-client network_mode: host TCP 隧道 加密 · 长连接 VPS(公网) rathole-server :2333 隧道口 127.0.0.1:13000+ Caddy TLS 终止 · 反代 HTTPS 公网用户 https://doc.liz6.com rathole 只做 TCP 端口转发,不解析 TLS;TLS 证书由 Caddy 在 VPS 侧全自动管理。

角色分工很清晰:

  • 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: ["--server", "/config/server.toml"]
    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: ["--client", "/config/client.toml"]
    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)时:

  1. 家里跑 acme.sh,用阿里云 DNS-01 签通配符 *.liz6.com(ACME 请求经代理出国,DNS API 走国内直连)。
  2. --install-cert --reloadcmd 自动 scp 证书到 VPS + reload Caddy。
  3. 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_upheader_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。

修法⁠(两个手段,各管各的):

  1. TCP keepalive(保活):client.toml 设 keepalive_secs=10 / keepalive_interval=5——rathole 连接都是 client 主动发起,只改 client 端即可覆盖控制和数据通道。
  2. 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 抓不到任何包,家里这边却说通了。

通往真相的排查路径:

  1. ip route get <VPS_IP>via 198.18.0.2 dev Meta——到 VPS 的流量全部穿 TUN。
  2. curl -v 连 VPS 上一个确定关闭的端口⁠(如 9999),如果"成功" = TUN 假应答;如果被拒绝 = 真的到了 VPS。
  3. 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 换成了本机代理。

TUN 劫持:rathole 到 VPS 的连接被本机 mihomo TUN 截住再掐 修复前(错误路径) rathole mihomo TUN 198.18.0.2 VPS "DIRECT" ≠ 绕过 TUN,连接仍被 mihomo 管 修复后(正确路径) rathole VPS 内核直连(wlp9s0) 路由排除:TUN 不再碰这个 IP

修法⁠:把 VPS IP 写进 mihomo 的 TUN 路由排除列表,让它走内核直连、彻底绕开代理:

# mihomo config.yaml
tun:
  inet4-route-exclude-address:
    - 1.117.75.80/32   # rathole VPS, 绕开本机 mihomo TUN

systemctl restart mihomoip 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

curl -s --connect-timeout 5 --max-time 5 "$ENDPOINT" > /dev/null
if [ $? -ne 0 ]; then
    fails=$(($(cat $FAIL_FILE 2>/dev/null || echo 0) + 1))
    echo $fails > $FAIL_FILE
    if [ $fails -ge $MAX_FAILS ]; then
        docker restart rathole-client
        rm -f $FAIL_FILE
    fi
else
    rm -f $FAIL_FILE
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-authenticator PAM)。
  • Caddy 只反代 rathole 隧道口,不直接暴露家里端口。
  • 敏感链路(如 OTLP metrics)额外套 mTLS——Caddy 验证客户端证书 + 吊销检查,隧道只透传密文。
  • VPS 侧用 ufwiptables 限制 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: ["--server", "/config/server.toml"]
    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: ["--client", "/config/client.toml"]
    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

相关文档