日期:2026-06-17
公开版说明:本文保留配置结构、排查路径和关键判断,内网地址、Xray 出站 tag、group id 等环境标识已替换为示例值。
这份文档记录的是树莓派上 `cloudflared` 的出口调整。它不是从零安装 Cloudflare Tunnel 的教程,而是解释怎么让已经能运行的 Cloudflare Tunnel 不再从树莓派家宽直连 Cloudflare edge,而是通过本机 Xray 的代理出口出去。
这次真正解决的是一个很具体的问题:
```text
cloudflared 默认从树莓派直连 Cloudflare edge
我希望它走 Xray 出口
但 cloudflared 对 TLS SNI、原始 edge IP 和 7844 端口很敏感
普通 sniff 或普通 HTTP_PROXY 很容易把 tunnel 搞坏
```
最后能稳定工作的方案是:
```text
cloudflared 进程使用专用 group
nft 只拦截这个 group 发往 Cloudflare Tunnel edge IP 的 TCP 7844
流量重定向到 Xray 的专用 dokodemo-door 入站
这个入站开启 sniff,但必须启用 routeOnly
Xray 再把流量送到默认代理出站
```
## 相关折腾记录
- [树莓派 Tailscale Exit 与 Xray 透明代理配置整理](https://aside0.me/raspberry-pi-tailscale-xray-2026-06-15/)
- [树莓派 Wi-Fi 热点接入 Xray:DNS 防污染和透明代理排查](https://aside0.me/raspberry-pi-wifi-hotspot-xray-dns-2026-06-19/)
- [树莓派 Xray DNS 防泄露和游戏下载分流](https://aside0.me/raspberry-pi-xray-dns-leak-game-split-2026-06-20/)
## 系统和环境
这台机器是树莓派,不是 OpenWrt。
```text
硬件:Raspberry Pi 5
系统:Debian GNU/Linux 13 (trixie)
内核:6.12.62+rpt-rpi-v8
架构:linux/arm64
服务管理:systemd
防火墙:nftables
Xray:26.3.27
cloudflared:2026.6.0
LAN 地址:192.168.10.20
```
相关服务和端口:
```text
Xray socks:0.0.0.0:10808
Xray HTTP proxy:127.0.0.1:18080
cloudflared 专用 Xray 入站:127.0.0.1:1061
cloudflared 运行组:cloudflared_proxy
Cloudflare Tunnel edge 端口:TCP 7844
```
这台树莓派上本来已经有 Xray 透明代理、Tailscale exit、DNS 分流等配置。本文只写 Cloudflare Tunnel 这条链路,不重复那部分。
## 最终拓扑
最终流量路径是这样:
```mermaid
flowchart LR
cf["cloudflared<br/>Group=cloudflared_proxy"]
out["Linux OUTPUT"]
nft["nftables<br/>cloudflared_output_proxy"]
redir["REDIRECT<br/>127.0.0.1:1061"]
xray_in["Xray<br/>cloudflared-local-redir"]
xray_out["Xray 默认代理出站"]
proxy["远端代理节点"]
edge["Cloudflare Tunnel edge<br/>198.41.192.0/24<br/>198.41.200.0/24"]
cfnet["Cloudflare Tunnel"]
origin["局域网 origin 服务"]
cf --> out
out --> nft
nft --> redir
redir --> xray_in
xray_in --> xray_out
xray_out --> proxy
proxy --> edge
edge --> cfnet
cfnet --> origin
```
nft 规则只拦 `cloudflared_proxy` 这个 group 的流量。这样不会影响树莓派上其他 root 进程,也不会把 Xray 自己的出站绕回 Xray。
```mermaid
flowchart TD
process["本机进程"]
group_check{"skgid == cloudflared_proxy?"}
cf_ip_check{"目标 IP 是<br/>198.41.192.0/24<br/>或 198.41.200.0/24?"}
tcp_check{"TCP?"}
redirect["redirect to :1061"]
normal["按原本路径走"]
process --> group_check
group_check -->|否| normal
group_check -->|是| cf_ip_check
cf_ip_check -->|否| normal
cf_ip_check -->|是| tcp_check
tcp_check -->|否| normal
tcp_check -->|是| redirect
```
## 为什么 sniff 会影响 cloudflared
Cloudflare Tunnel 的连接有一个容易误判的地方:
```text
TCP 目标:Cloudflare edge IP,例如 198.41.192.167:7844
TLS SNI:cftunnel.com
```
普通 HTTPS 访问里,Xray sniff 到 SNI 后把目标从 IP 改成域名,通常问题不大,甚至有利于按域名分流。但 `cloudflared` 这条链路不一样。它连接的是 Cloudflare 分配的 edge IP,TLS 握手里又带 `cftunnel.com`。如果 Xray 把原始目标 IP 改写成 sniff 到的域名,再重新解析或重路由,就容易出现:
```text
TLS handshake with edge error: EOF
```
所以这里的关键不是简单地“开 sniff”或“关 sniff”,而是:
```text
允许 sniff 看到域名
但不要让 sniff 改写 cloudflared 的真实目标地址
```
这就是 `routeOnly: true` 的作用。
```mermaid
flowchart LR
original["原始目标<br/>198.41.192.167:7844"]
sniff["sniff 到<br/>cftunnel.com"]
route["用于路由判断"]
dial["实际连接仍然拨原始 IP"]
original --> sniff
sniff --> route
original --> dial
```
## 普通入站的 sniff 模板
你的普通 Xray 入站可以使用这种 sniff 写法。它适合正常透明代理或普通代理入站:
```json
{
"sniffing": {
"destOverride": [
"fakedns",
"quic",
"tls",
"http"
],
"enabled": true,
"routeOnly": true,
"domainsExcluded": [
"domain:cftunnel.com",
"domain:argotunnel.com",
"full:api.cloudflare.com",
"domain:cloudflareaccess.com",
"domain:cloudflareresearch.com"
]
}
}
```
这段配置的意思是:
- 对 FakeDNS、QUIC、TLS、HTTP 做 sniff。
- sniff 结果可以用于恢复域名,让 `geosite` 规则有机会生效。
- `routeOnly: true` 让 sniff 只参与路由判断,不改写真实目标地址。
- `domainsExcluded` 只排除 Cloudflare Tunnel、API、Zero Trust Access 和相关上报域名,不把所有 Cloudflare CDN IP 都排除掉。
不要在普通入站里直接排除 `geoip:cloudflare`。很多第三方网站也在 Cloudflare CDN 后面,按整个 Cloudflare IP 段排除会让这些网站失去更细的域名分流。
### 为什么普通入站不用 geoip:cloudflare
`geoip:cloudflare` 匹配的是 Cloudflare 边缘 IP,不等于 Cloudflare 自己的服务域名。大量普通网站也把 DNS 指到 Cloudflare CDN,它们的 TLS SNI 仍然是自己的域名,不会变成 `cloudflare.com`。
如果普通透明代理入站把 `geoip:cloudflare` 整段排除,Xray 会把很多完全不同的网站都当成“Cloudflare IP 流量”处理。这样会削弱原来的 `geosite`、广告、国内外和服务类域名分流:访问一个用了 Cloudflare CDN 的第三方站点时,规则看到的重点会变成 Cloudflare edge IP,而不是这个站点自己的域名。
这次要解决的问题更窄:`cloudflared` 连接的是 Cloudflare 分配的 edge IP,但握手里会出现 `cftunnel.com`、`argotunnel.com`、`cloudflareaccess.com` 这类控制面域名。正确做法是保留原始 edge IP,同时只对这些控制面域名禁止 sniff 改写。所以普通入站用 `routeOnly` 加小范围 `domainsExcluded`,`cloudflared` 专用入站再额外保留实测 edge CIDR。
## cloudflared 专用 Xray 入站
当前能工作的入站是:
```json
{
"tag": "cloudflared-local-redir",
"listen": "127.0.0.1",
"port": 1061,
"protocol": "dokodemo-door",
"settings": {
"network": "tcp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"destOverride": [
"fakedns",
"quic",
"tls",
"http"
],
"routeOnly": true,
"domainsExcluded": [
"domain:cftunnel.com",
"domain:argotunnel.com",
"full:api.cloudflare.com",
"domain:cloudflareaccess.com",
"domain:cloudflareresearch.com"
],
"ipsExcluded": [
"geoip:cloudflare",
"198.41.192.0/24",
"198.41.200.0/24"
]
}
}
```
几个点不能省:
- `followRedirect: true`:让 `dokodemo-door` 读取 nft REDIRECT 前的原始目标。
- `network: tcp`:正式方案只走 HTTP/2,也就是 TCP 7844。
- `routeOnly: true`:sniff 到域名,但实际拨号仍然保持原始 edge IP。
- `domainsExcluded`:覆盖 Tunnel、API、Zero Trust Access 和 Cloudflare Research 相关域名,避免这些控制面域名被 sniff 改写。
- `198.41.192.0/24` 和 `198.41.200.0/24`:这是这次 Cloudflare Tunnel edge TCP 7844 实测命中的两个网段。
这里没有把 Xray 全局 `domainStrategy` 改成 `AsIs`。当前全局仍然是:
```text
IPIfNonMatch
```
原因是树莓派还有原来的国内直连、广告拦截、DNS、Tailscale exit 分流。只要 `cloudflared-local-redir` 用 `routeOnly` 后能稳定注册,就没有必要为了这条链路改全局策略。
## nft 转发规则
### 专用 group 和 GID
这里的 `cloudflared_proxy` 不是特殊内置组,而是专门给 `cloudflared` 建的运行组。这样 nft 可以只抓这个进程组的 socket 流量,不需要按 root 用户全局拦截。
先创建系统组:
```sh
sudo groupadd -r cloudflared_proxy 2>/dev/null || true
```
没有显式指定 `-g` 时,系统会自动分配一个可用的 system GID。这个数字每台机器可能不同,所以不要直接照抄别人的 GID。创建后用 `getent` 查:
```sh
getent group cloudflared_proxy
getent group cloudflared_proxy | cut -d: -f3
```
输出类似这样:
```text
cloudflared_proxy:x:<cloudflared_proxy_gid>:
```
中间的第三段就是实际 GID。把这个数字填到 `/etc/cloudflared/output-proxy.nft` 里的 `<cloudflared_proxy_gid>`:
```nft
meta skgid <cloudflared_proxy_gid>
```
`cloudflared.service` 里写的是组名:
```ini
Group=cloudflared_proxy
```
nft 规则里写的是这个组对应的 GID。两边必须指向同一个组。服务启动后可以这样确认进程是否真的跑在这个 group 下:
```sh
ps -o pid,user,group,args -C cloudflared
```
正常情况下,`GROUP` 一列应该是 `cloudflared_proxy`。如果这里还是 `root`,说明 systemd service 没有重新加载或服务没有按新配置重启。
规则文件:
```text
/etc/cloudflared/output-proxy.nft
```
当前内容:
```nft
table inet cloudflared_output_proxy {
chain output_nat {
type nat hook output priority dstnat; policy accept;
meta skgid <cloudflared_proxy_gid> meta nfproto ipv4 ip daddr { 198.41.192.0/24, 198.41.200.0/24 } meta l4proto tcp redirect to :1061
}
}
```
这个规则的意思是:
```text
只匹配本机 OUTPUT
只匹配 cloudflared_proxy 这个 group
只匹配 IPv4
只匹配 Cloudflare Tunnel edge 的两个 /24
只匹配 TCP
命中后重定向到本机 1061
```
这比按端口或按 root 用户全局拦截安全得多。`cloudflared` 是 root 启动的,如果按 uid root 抓,本机很多系统服务都可能被误伤。按 group 抓可以把影响面限制到 `cloudflared`。
注意,这两个 /24 不是完整 Cloudflare CDN 网段,只是 Cloudflare Tunnel edge 在这次环境里使用的网段。以后如果 Cloudflare 调整 edge 地址,需要重新看日志或按官方/社区资料更新。
## systemd 服务
`cloudflared` 服务没有把 token 放进命令行。token 在环境文件里,避免 `ps` 直接看到敏感内容。
```ini
[Unit]
Description=Cloudflare Tunnel
After=network-online.target xray.service cloudflared-output-proxy.service
Wants=network-online.target
Requires=xray.service cloudflared-output-proxy.service
[Service]
Type=simple
User=root
Group=cloudflared_proxy
TimeoutStartSec=30
EnvironmentFile=/etc/cloudflared/tunnel.env
Environment=TUNNEL_NO_PRECHECKS=true
Environment=TUNNEL_TRANSPORT_PROTOCOL=http2
Environment=NO_PROXY=127.0.0.1,localhost,::1,192.168.10.0/24,100.64.0.0/10,fd7a:115c:a1e0::/48,.local
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel --protocol http2 --edge-ip-version 4 run
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
`cloudflared-output-proxy.service` 负责加载 nft 表:
```ini
[Unit]
Description=cloudflared output transparent proxy nft rules
After=xray.service
Requires=xray.service
Before=cloudflared.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/usr/sbin/nft delete table inet cloudflared_output_proxy
ExecStart=/usr/sbin/nft -f /etc/cloudflared/output-proxy.nft
ExecStop=-/usr/sbin/nft delete table inet cloudflared_output_proxy
[Install]
WantedBy=multi-user.target
```
这里故意让 `cloudflared.service` 依赖 `cloudflared-output-proxy.service`。如果 nft 规则没有加载,`cloudflared` 就不应该悄悄直连出去。
## 为什么使用 HTTP/2 而不是 QUIC
Cloudflare Tunnel 默认常见路径会使用 QUIC,也就是 UDP 7844。但这次要做的是本机 OUTPUT 透明代理,而且要经过 Xray 的 TCP 代理出站。
当前稳定配置明确使用:
```text
--protocol http2
TUNNEL_TRANSPORT_PROTOCOL=http2
```
也就是让 `cloudflared` 使用 TCP 7844。这样 nft 的 `nat output redirect` 和 Xray 的 `dokodemo-door followRedirect` 能稳定拿到原始目标。
之前试过 UDP 透明转发,结果不稳定。Xray 日志里能看到 UDP 原始目标被读成了本机重定向端口,类似:
```text
accepted udp:127.0.0.1:1062
```
这说明 UDP REDIRECT 这条链路没有拿到 Cloudflare edge 的原始目标,不能作为这次的正式方案。
## 实际验证
服务状态:
```sh
systemctl is-active cloudflared
systemctl is-enabled cloudflared
systemctl is-active cloudflared-output-proxy
systemctl is-enabled cloudflared-output-proxy
systemctl is-active xray
```
当前结果:
```text
cloudflared: active / enabled
cloudflared-output-proxy: active / enabled
xray: active
```
`cloudflared` 当前进程:
```text
root cloudflared_proxy /usr/local/bin/cloudflared --no-autoupdate tunnel --protocol http2 --edge-ip-version 4 run
```
命令行里没有 token。
Tunnel 注册日志:
```text
Registered tunnel connection connIndex=0 ip=198.41.192.167 location=hkg01 protocol=http2
Registered tunnel connection connIndex=1 ip=198.41.200.63 location=hkg10 protocol=http2
Registered tunnel connection connIndex=2 ip=198.41.192.77 location=hkg01 protocol=http2
Registered tunnel connection connIndex=3 ip=198.41.200.33 location=hkg10 protocol=http2
```
这比之前直连时的 `sjc` 更符合预期。当前接入点变成了香港,说明 `cloudflared` 的 edge 连接确实从 Xray 出口出去。
Xray access log 能看到:
```text
cloudflared-local-redir >> default:proxy-out
```
这说明 nft 重定向命中了,Xray 也把这条流量送到了默认代理出站。
活跃连接也能看到 `cloudflared` 到 TCP 7844:
```sh
sudo ss -tnp | grep cloudflared | grep ':7844'
```
期望看到类似:
```text
ESTAB 192.168.10.20:<port> 198.41.192.167:7844 users:cloudflared
ESTAB 192.168.10.20:<port> 198.41.200.63:7844 users:cloudflared
```
注意:`ss` 看到的仍然是原始 edge IP,这正是 `routeOnly` 想保留的效果。是否走了代理,要看 Xray access log 和 edge location。
## 踩过的坑
### 1. 只设置 HTTP_PROXY 不可靠
试过在 `cloudflared.service` 里加:
```ini
Environment=HTTP_PROXY=http://127.0.0.1:18080
Environment=HTTPS_PROXY=http://127.0.0.1:18080
```
这不能可靠地把 Cloudflare edge 连接送进 Xray。实际看 `ss`,`cloudflared` 仍然可能直接连 `:7844`。所以最终没有用普通 HTTP 代理环境变量,而是用 nft 在 OUTPUT 链做透明重定向。
### 2. 透明转发但不开 CF edge 网段不行
只做本机代理入口,不抓 Cloudflare Tunnel edge IP,`cloudflared` 还是会直连。
这次关键网段是:
```text
198.41.192.0/24
198.41.200.0/24
```
nft 必须明确把这些目标重定向到 `1061`。
### 3. sniff 开了但没有 routeOnly 会坏
这个配置语法合法,但会导致 tunnel 失败;也不适合再作为普通入站模板:
```json
{
"sniffing": {
"enabled": true,
"destOverride": [
"fakedns",
"quic",
"tls",
"http"
],
"ipsExcluded": [
"geoip:cloudflare"
]
}
}
```
实际错误:
```text
TLS handshake with edge error: EOF
```
原因是 sniff 仍然可能影响连接目标或后续解析。`cloudflared` 需要保持原始 edge IP。另一方面,普通入站如果用 `geoip:cloudflare` 做大范围排除,也会把很多 Cloudflare CDN 后面的第三方站点一起粗暴排掉。
### 4. 加显式 CIDR 但没有 routeOnly 也不稳
也试过:
```json
{
"sniffing": {
"enabled": true,
"destOverride": [
"fakedns",
"quic",
"tls",
"http"
],
"ipsExcluded": [
"geoip:cloudflare",
"198.41.192.0/24",
"198.41.200.0/24"
]
}
}
```
它能通过 Xray 配置测试,但 `cloudflared` 仍然出现 EOF。说明 `ipsExcluded` 对这条透明重定向链路不够稳。
### 5. routeOnly 才是这条链路的关键
最终能稳定工作的区别是:
```json
"routeOnly": true
```
这样 Xray 可以 sniff 到 `cftunnel.com`,但不会把实际连接目标从 `198.41.x.x:7844` 改掉。
### 6. 不要按 root 全局转发
`cloudflared` 当前是 root 运行。如果 nft 写成按 uid root 抓,容易把系统服务、Xray 自己、定时任务都抓进去。
正式方案按 group 抓:
```nft
meta skgid <cloudflared_proxy_gid>
```
这个 group 只给 `cloudflared` 用,影响面比较清楚。
### 7. token 不要放进 ExecStart
不要写成:
```ini
ExecStart=/usr/local/bin/cloudflared tunnel run --token <token>
```
这样 `ps` 能看到 token。当前使用:
```ini
EnvironmentFile=/etc/cloudflared/tunnel.env
```
服务进程参数里不会出现 token。
## 操作顺序
实际落地时按这个顺序最稳:
```sh
sudo groupadd -r cloudflared_proxy 2>/dev/null || true
getent group cloudflared_proxy
getent group cloudflared_proxy | cut -d: -f3
```
1. 先给 Xray 加 `cloudflared-local-redir` 入站。
2. 用 `xray run -test -config` 验证配置。
3. 创建 `cloudflared_proxy` 组,并用 `getent` 查出实际 GID。
4. 写 `/etc/cloudflared/output-proxy.nft`,把 `<cloudflared_proxy_gid>` 替换成上一步查到的数字。
5. 写 `cloudflared-output-proxy.service`。
6. 修改 `cloudflared.service`,让它用 `Group=cloudflared_proxy`。
7. `systemctl daemon-reload`。
8. 重启 Xray。
9. 启动 `cloudflared-output-proxy`。
10. 重启 `cloudflared`。
11. 用日志和 `ss` 验证。
验证命令:
```sh
systemctl is-active cloudflared xray cloudflared-output-proxy
systemctl is-enabled cloudflared cloudflared-output-proxy
ps -o pid,user,group,args -C cloudflared
journalctl -u cloudflared -n 120 --no-pager | grep 'Registered tunnel connection'
sudo tail -160 /var/log/xray/access.log | grep cloudflared-local-redir
sudo ss -tnp | grep cloudflared | grep ':7844'
```
## 回滚
如果以后 Cloudflare edge 网段变化,或者这条代理路径不稳定,可以先回滚到直连,保证 tunnel 可用。
```sh
sudo systemctl stop cloudflared
sudo systemctl disable --now cloudflared-output-proxy
sudo nft delete table inet cloudflared_output_proxy 2>/dev/null || true
```
然后把 `cloudflared.service` 改回不依赖 output proxy 的版本:
```ini
[Unit]
Description=Cloudflare Tunnel
After=network-online.target xray.service
Wants=network-online.target
Requires=xray.service
[Service]
Type=simple
TimeoutStartSec=30
EnvironmentFile=/etc/cloudflared/tunnel.env
Environment=TUNNEL_NO_PRECHECKS=true
Environment=TUNNEL_TRANSPORT_PROTOCOL=http2
Environment=NO_PROXY=127.0.0.1,localhost,::1,192.168.10.0/24,100.64.0.0/10,fd7a:115c:a1e0::/48,.local
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel --protocol http2 --edge-ip-version 4 run
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
Xray 里可以删除 `cloudflared-local-redir` 入站,然后:
```sh
sudo systemctl daemon-reload
sudo systemctl restart xray
sudo systemctl enable --now cloudflared
sudo systemctl restart cloudflared
```
回滚后的验证重点是看 `cloudflared` 是否重新注册 4 条 tunnel connection。
## 参考
- Xray inbound sniffing 文档:`https://xtls.github.io/en/config/inbound.html#sniffingobject`
- Xray routing 文档:`https://xtls.github.io/en/config/routing.html`
- Cloudflare Tunnel 透明代理社区讨论:`https://github.com/cloudflare/cloudflared/issues/1025`
- Cloudflare Tunnel 经代理的社区实践:`https://blog.xmgspace.me/archives/cloudflare-tunnel-via-proxy.html`
文章评论