【折腾心得】 Cloudflare Tunnel 实现内网穿透遇到的问题

跑在家里 OpenWrt 路由器上的一个内网 Web 服务,通过 Cloudflare Tunnel 发布到公网。不开端口、不暴露家庭 IP,前面用 Cloudflare Access 做身份验证。

环境:

  • 路由器:OpenWrt,LAN 地址 10.0.0.1,双 WAN + mwan3 负载分担,跑着 PassWall 做透明代理
  • 发布的服务:SillyTavern,Docker 部署,监听 10.0.0.1:8000,自带白名单 + Basic Auth
  • 连接器:cloudflare/cloudflared:latest(2026.9.1),Docker bridge 网络
  • 云端:Cloudflare 免费版,Zero Trust 已配,DNS 里 st.example.com 是代理 CNAME 指向隧道

第一层:域名查不到

浏览器打开 st.example.com,直接 DNS_PROBE_FINISHED_NXDOMAIN——这个报错的意思是”这个域名不存在”,不是”连不上”。

在路由器上手动 dig 了一下,发现一件有意思的事:同一个域名,走国内上游查是 NXDOMAIN,走境外上游查能正常返回 IP。同一时刻,两个截然相反的答案。

原因其实不复杂——这条 DNS 记录是刚刚创建的。创建之前,谁查过这个域名都会拿到”不存在”的答复,而这个”不存在”本身也会被缓存。术语叫 negative caching,缓存多久取决于 SOA 记录的 minimum TTL,Cloudflare 默认 1800 秒。国内那几个公共 DNS(114、阿里、腾讯、校园网)恰好都在记录生效前查过,缓了一份旧的否定答复。境外链路走的是另一套上游,从没查过,所以直接拿到了新记录。

当时的处理很直接:在国内 AdGuardHome 的上游配置里加一条定向转发,单独把这个域名的解析甩到境外链路:

upstream_dns:
  - '[/st.example.com/]tcp://127.0.0.1:5336'

立刻就通了。

几个小时后回来复测,国内各上游全部正常返回——否定缓存早过期了。于是把这条绕行规则撤掉了。留着反而有隐患:它把这个域名的解析绑死在境外链路上,等于主动放弃了一条健康的国内路径。万一代理链路挂了,这个域名反而解析不了。


第二层:连接器手上是空配置

DNS 通了,浏览器也能跳到 Cloudflare Access 的登录页了,看着一切正常。登录之后——503。

去翻连接器的 Docker 日志,每次有请求进来就打一条:

WRN No ingress rules were defined in provided config (if any) nor from the cli,
    cloudflared will return 503 for all incoming HTTP requests

字面意思:连接器手上一条路由规则都没有,所以对所有请求无差别返回 503。

可 dashboard 上明明配好了路由啊,st.example.com → http://10.0.0.1:8000,写得清清楚楚。到底是云端没存上,还是连接器没收到?

这里分享一个不用创建 API token 就能核对云端配置的小技巧:直接在 dashboard 页面的浏览器控制台里用 fetch 调 Cloudflare 自己的 API——它会带着你当前的 session cookie,纯只读:

const acc = '<account-id>';
const id  = '<tunnel-id>';
const r = await fetch(`/api/v4/accounts/${acc}/cfd_tunnel/${id}/configurations`,
                      { credentials: 'include' });
const j = await r.json();
console.log(JSON.stringify(j.result, null, 1));

返回结果里 ingress 规则好好的,source: "cloudflare" 说明是远程托管的隧道。云端配置没问题。

再翻连接器的启动日志,从头到尾找不到任何一行 Updated to new configuration——这行日志是配置成功下发时才打的。

对照时间线就明白了:容器先启动,路由后创建。cloudflared 在启动的瞬间拉取一次远程配置,那时候 ingress 还是空的,它就记下了”没有规则”。等我在 dashboard 上创建好路由,连接器不会主动回头再拉一次。

更坑的是在 dashboard 上把那条路由重新保存一遍也没用——内容没变,版本号还是 1,不触发下发。

解决方法简单粗暴:

docker restart cloudflared

重启之后日志里立刻出现了 Updated to new configuration,503 消失。

教训:先建路由,再启动连接器。顺序反了就得重启一次。


第三层:白名单拦住了隧道流量

503 没了,Access 过了,隧道也在转发了——然后拿到一个 403 Forbidden。

不过这个 403 的错误页面写得相当好心,直接把关键信息甩脸上了:

Connection from 172.17.0.1 (forwarded from 203.0.113.9) has been blocked.

一句话给了两个 IP:眼前的 TCP 连接来自 172.17.0.1(Docker 网关),但转发头里写的是 203.0.113.9(访客的公网 IP)。

应用的白名单配置是这样的:

whitelistMode: true
enableForwardedWhitelist: true
whitelist:
  - 127.0.0.1
  - 10.0.0.*

enableForwardedWhitelist: true 的作用是优先拿 X-Forwarded-For 里的 IP 去比对白名单。而经过 Cloudflare Tunnel 进来的请求,X-Forwarded-For 永远是访客的公网 IP。白名单里只有内网段——这条路从设计上就不可能被放行。

修复分两步走。第一步,关掉对转发头的信任,让应用用真实连接源 IP 判断:

- enableForwardedWhitelist: true
+ enableForwardedWhitelist: false

重启后还是 403,但报错变了——forwarded from 那半句消失了,说明改动生效了。现在的问题是 172.17.0.1(Docker 网关 IP)也不在白名单里。配置里有一个 whitelistDockerHosts: true,按字面意思应该自动放行 Docker 宿主网关,但在 OpenWrt 这个环境下它压根没起作用。没深究原因,直接显式加上:

  whitelist:
    - 127.0.0.1
    - 10.0.0.*
+   - 172.17.0.1

通了。

顺便说一下,这两处改动没有削弱安全性。LAN 直连时应用看到的仍然是真实 LAN IP,10.0.0.* 限制没动;外部访问照样要先过 Access 的邮箱策略,再过 Basic Auth。变的只是”用哪个 IP 来判断”这一件事。

但我想多聊几句我在这里犯的错

这个白名单问题其实不是我自己发现的——我之前错误地排除了这个方向。

当时我在路由器上用 curl 测应用:

curl -s -o /dev/null -w "%{http_code}\n" http://10.0.0.1:8000/
# 401

curl -s -o /dev/null -w "%{http_code}\n" -H "X-Forwarded-For: 203.0.113.9" http://10.0.0.1:8000/
# 401

不管怎么模拟,全是 401,一个 403 都没有。我据此下了结论:白名单不是问题,可以排除。

这个结论是错的。

这个应用的请求处理链是 Basic Auth 在前、白名单在后。我的 curl 没带凭据,在第一层中间件就被 401 挡回来了,白名单那层压根没执行到。我拿着一个从未触达目标层的测试结果,去否定了目标层。

后来是浏览器用保存的凭据自动通过了 Basic Auth,403 才浮出水面。更尴尬的是,中途有人直接问我”会不会是白名单的问题”,我还拿这个无效证据回了一嘴。方向是对的,被我用错误的依据顶回去了。

这件事给我的教训很深:“排除”比”确认”更危险。 说错”是 A 导致的”,下一步验证就会推翻它。但说错”不是 B”,会把 B 永久移出搜索空间,之后你只会越查越偏。

下次再说”这个可以排除”之前,我会先问自己:我的测试真的走到那一层了吗?


第四层:透明代理把隧道的 UDP 吃了

前三个全部修好,服务终于真正能用了。开开心心用了十几分钟——

Error 1033
Cloudflare Tunnel error

Dashboard 显示隧道离线。

而且这事发生在我刚改完 DNS 配置之后,第一反应自然是”我把 DNS 改坏了”。

但仔细想想就不对:Error 1033 恰恰说明 DNS 是好的。 这个错误页是 Cloudflare 边缘返回的,意思是”请求已经到我这儿了,但我找不到可用的连接器”。能看到这个页面,说明 DNS 解析成功、TCP 连接成功、TLS 握手成功,请求已经到达 Cloudflare。DNS 真坏了的话你看到的会是浏览器自己的 DNS_PROBE_FINISHED_NXDOMAIN——根本到不了 Cloudflare 的错误页。

这两个页面在用户眼里都叫”打不开”,但指向的是链路上完全相反的两端。

去看连接器日志:

ERR Failed to dial a quic connection error="failed to dial to edge with quic:
    timeout: no recent network activity" connIndex=0 ip=198.41.200.13
ERR Failed to dial a quic connection error="failed to dial to edge with quic:
    timeout: no recent network activity" connIndex=0 ip=198.41.200.193

几分钟后自动恢复了三条连接,我恰好撞在掉线窗口里。但更值得注意的是:connIndex=0 从容器启动起就一次都没成功过。翻遍日志,它一直在重试。正常应该是 4 条连接,这个隧道从头到尾只有 3 条在撑。

这个问题从第一分钟就存在,只是被前三个故障盖住了——服务本来就打不开,谁会去数隧道有几条连接。

去查 conntrack,答案很清楚:

src=172.17.0.3 dst=198.41.200.13  sport=44433 dport=7844 packets=10   [UNREPLIED]  mark=1347639089
src=172.17.0.3 dst=198.41.200.63  sport=44433 dport=7844 packets=10   [UNREPLIED]  mark=1347639089
src=172.17.0.3 dst=198.41.200.233 sport=43266 dport=7844 packets=2092 [ASSURED]    mark=1347639089

前两条发了 10 个包出去,一个回包都没有。第三条 2000 多个包双向正常。关键在那个 mark 值——1347639089 换成十六进制是 0x50535731,按 ASCII 读出来:PSW1——PassWall 的标记。这三条连接全被透明代理抓走了。

Cloudflare 的边缘 IP(198.41.200.x)不在国内直连集合里,而我的 UDP 是全代理模式。于是 cloudflared 到 Cloudflare 边缘的 QUIC(UDP 7844)流量全部被劫持,绕去了境外代理节点再折回来。

绕行链路质量参差不齐:有些边缘 IP 绕过去能通,有些完全不通。所以 connIndex=0 反复被分到那些绕不通的边缘,永远连不上;而代理节点一抖动,剩下三条一起断。

这里面最有价值的发现

这套透明代理对 TCP 和 UDP 的处理完全不对称:

  • TCP:只代理白名单端口(22、80、443 等十几个),其余直连
  • UDP:没有端口白名单,境外目标一律全代理

7844 不在 TCP 白名单里。同一个端口号,走 TCP 直连,走 UDP 被劫持。

修复方法:强制 cloudflared 走 HTTP/2(TCP)而不是 QUIC:

docker run -d --name cloudflared \
  --restart unless-stopped \
  --network bridge \
  --memory 256m \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run --protocol http2 --token <YOUR_TUNNEL_TOKEN>

重建后 4 条连接全部注册成功,包括那条从未活过的 connIndex=0:

INF Registered tunnel connection connIndex=0 connection=a3b4464c-... location=lax01 protocol=http2
INF Registered tunnel connection connIndex=1 connection=6274f094-... location=lax07 protocol=http2
INF Registered tunnel connection connIndex=2 connection=14c7a538-... location=lax01 protocol=http2
INF Registered tunnel connection connIndex=3 connection=3637ad23-... location=lax08 protocol=http2

再查 conntrack 验证——没有一条带 PassWall 标记。四条连接的 mark 值落在 mwan3 的掩码范围内,两条走主 WAN、两条走备 WAN,负载分担正常工作。隧道流量彻底脱离了代理链路。

这个结论可以推广:凡是依赖 UDP 的境外服务(QUIC、WireGuard、游戏对战),在有透明代理的网络里都容易撞上这类问题。能改走 TCP 的优先改 TCP

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇