Aside0's AI Blog

AI-generated tinkering journal. No hand typing, guaranteed.
  1. Main page
  2. Network
  3. Main content

Route Cloudflare Tunnel through Xray: cloudflared, nftables, and sniffing

2026年6月17日 34hotness 0likes 0comments
Date: 2026-06-17

Public version note: this post keeps the config shape, debugging path, and main decisions. Internal addresses, Xray outbound tags, group IDs, and other environment-specific identifiers have been replaced with example values.

This document records the `cloudflared` exit change on a Raspberry Pi. It is not a from-scratch Cloudflare Tunnel install guide. It explains how to make an already working Cloudflare Tunnel stop connecting directly from the Raspberry Pi home network to Cloudflare edge, and instead leave through the local Xray proxy exit.

The problem was specific:

```text
cloudflared connects directly from the Raspberry Pi to Cloudflare edge by default
I wanted it to leave through the Xray exit
but cloudflared is sensitive to TLS SNI, the original edge IP, and port 7844
plain sniffing or plain HTTP_PROXY can break the tunnel easily
```

The setup that finally stayed stable was:

```text
cloudflared runs under a dedicated group
nft only catches traffic from that group to Cloudflare Tunnel edge IPs on TCP 7844
traffic is redirected to a dedicated Xray dokodemo-door inbound
that inbound enables sniffing, but routeOnly must be enabled
Xray then sends the traffic to the default proxy outbound
```

## Related posts

- [Raspberry Pi Tailscale exit node and Xray transparent proxy setup](https://aside0.me/en/raspberry-pi-tailscale-xray-2026-06-15-en/)
- [Raspberry Pi Wi-Fi hotspot through Xray: DNS protection and transparent proxying](https://aside0.me/en/raspberry-pi-wifi-hotspot-xray-dns-2026-06-19-en/)
- [Raspberry Pi Xray DNS leak control and game download split routing](https://aside0.me/en/raspberry-pi-xray-dns-leak-game-split-2026-06-20-en/)

## System and environment

This machine is a Raspberry Pi, not OpenWrt.

```text
Hardware: Raspberry Pi 5
OS: Debian GNU/Linux 13 (trixie)
Kernel: 6.12.62+rpt-rpi-v8
Architecture: linux/arm64
Service manager: systemd
Firewall: nftables
Xray: 26.3.27
cloudflared: 2026.6.0
LAN address: 192.168.10.20
```

Related services and ports:

```text
Xray socks: 0.0.0.0:10808
Xray HTTP proxy: 127.0.0.1:18080
cloudflared dedicated Xray inbound: 127.0.0.1:1061
cloudflared runtime group: cloudflared_proxy
Cloudflare Tunnel edge port: TCP 7844
```

This Raspberry Pi already had Xray transparent proxying, Tailscale exit, DNS routing, and related config. This post only covers the Cloudflare Tunnel path and does not repeat the earlier setup.

## Final topology

The final traffic path looks like this:

```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 default proxy outbound"]
  proxy["remote proxy node"]
  edge["Cloudflare Tunnel edge<br/>198.41.192.0/24<br/>198.41.200.0/24"]
  cfnet["Cloudflare Tunnel"]
  origin["LAN origin service"]

  cf --> out
  out --> nft
  nft --> redir
  redir --> xray_in
  xray_in --> xray_out
  xray_out --> proxy
  proxy --> edge
  edge --> cfnet
  cfnet --> origin
```

nft only catches traffic from the `cloudflared_proxy` group. That avoids touching other root processes on the Raspberry Pi, and it also keeps Xray from routing its own outbound traffic back into itself.

```mermaid
flowchart TD
  process["local process"]
  group_check{"skgid == cloudflared_proxy?"}
  cf_ip_check{"target IP is<br/>198.41.192.0/24<br/>or 198.41.200.0/24?"}
  tcp_check{"TCP?"}
  redirect["redirect to :1061"]
  normal["use the normal path"]

  process --> group_check
  group_check -->|no| normal
  group_check -->|yes| cf_ip_check
  cf_ip_check -->|no| normal
  cf_ip_check -->|yes| tcp_check
  tcp_check -->|no| normal
  tcp_check -->|yes| redirect
```

## Why sniffing affects cloudflared

Cloudflare Tunnel has one detail that is easy to misread:

```text
TCP target: Cloudflare edge IP, for example 198.41.192.167:7844
TLS SNI: cftunnel.com
```

For normal HTTPS traffic, when Xray sniffs the SNI and changes the target from an IP to a domain, that is usually fine. It can even help domain-based routing. `cloudflared` is different. It connects to a Cloudflare-assigned edge IP, while the TLS handshake carries `cftunnel.com`. If Xray rewrites the original target IP to the sniffed domain, then resolves or reroutes it again, this can show up as:

```text
TLS handshake with edge error: EOF
```

So the key is not simply turning sniffing on or off. The rule is:

```text
allow sniffing to see the domain
but do not let sniffing rewrite cloudflared's real target address
```

That is what `routeOnly: true` does.

```mermaid
flowchart LR
  original["original target<br/>198.41.192.167:7844"]
  sniff["sniffed<br/>cftunnel.com"]
  route["used for routing decisions"]
  dial["actual dial still uses the original IP"]

  original --> sniff
  sniff --> route
  original --> dial
```

## Sniffing template for normal inbounds

A normal Xray inbound can use this sniffing shape. It fits regular transparent proxy traffic or regular proxy inbounds:

```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"
    ]
  }
}
```

This config means:

- Sniff FakeDNS, QUIC, TLS, and HTTP.
- Use the sniffed result to recover the domain, giving `geosite` rules a chance to match.
- `routeOnly: true` uses the sniffed result only for routing and does not rewrite the real target address.
- `domainsExcluded` only excludes Cloudflare Tunnel, API, Zero Trust Access, and related reporting domains. It does not exclude the whole Cloudflare CDN IP space.

Do not put `geoip:cloudflare` into normal inbounds as a broad exclusion. Many third-party sites sit behind Cloudflare CDN, and excluding the whole Cloudflare IP space makes domain routing less precise for those sites.

### Why normal inbounds do not use geoip:cloudflare

`geoip:cloudflare` matches Cloudflare edge IPs. It does not mean "Cloudflare-owned service domains." Many ordinary websites point their DNS to Cloudflare CDN, while their TLS SNI remains their own domain. It does not become `cloudflare.com`.

If a normal transparent proxy inbound excludes the entire `geoip:cloudflare` range, Xray starts treating many unrelated sites as the same kind of "Cloudflare IP traffic." That weakens the existing `geosite`, ad blocking, domestic/foreign, and service-specific routing. When a third-party site uses Cloudflare CDN, the useful routing signal is usually the site's own domain, not the shared Cloudflare edge IP.

The problem here is narrower: `cloudflared` connects to Cloudflare-assigned edge IPs, while the handshake can expose control-plane names such as `cftunnel.com`, `argotunnel.com`, and `cloudflareaccess.com`. The right shape is to keep the original edge IP and only prevent sniffing from rewriting these control-plane domains. That is why normal inbounds use `routeOnly` plus a small `domainsExcluded` list, while the dedicated `cloudflared` inbound also keeps the measured edge CIDRs.

## Dedicated Xray inbound for cloudflared

The working inbound is:

```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"
    ]
  }
}
```

A few details matter:

- `followRedirect: true`: lets `dokodemo-door` read the original target before nft REDIRECT.
- `network: tcp`: the final setup uses HTTP/2 only, which means TCP 7844.
- `routeOnly: true`: Xray can sniff the domain, but the actual dial still keeps the original edge IP.
- `domainsExcluded`: covers Tunnel, API, Zero Trust Access, and Cloudflare Research domains, so these control-plane names are not rewritten by sniffing.
- `198.41.192.0/24` and `198.41.200.0/24`: these were the two Cloudflare Tunnel edge TCP 7844 ranges hit in this test environment.

I did not change Xray's global `domainStrategy` to `AsIs`. The global setting is still:

```text
IPIfNonMatch
```

The reason is that this Raspberry Pi still has domestic direct routing, ad blocking, DNS routing, and Tailscale exit routing from the earlier setup. Once `cloudflared-local-redir` is stable with `routeOnly`, there is no reason to change the global strategy just for this path.

## nft forwarding rule

### Dedicated group and GID

`cloudflared_proxy` is not a special built-in group. It is a runtime group created just for `cloudflared`. That lets nft catch only sockets from this process group instead of catching every process running as root.

Create the system group first:

```sh
sudo groupadd -r cloudflared_proxy 2>/dev/null || true
```

When `-g` is not specified, the OS assigns an available system GID automatically. That number can be different on each machine, so do not copy someone else's GID directly. After creating the group, check it with `getent`:

```sh
getent group cloudflared_proxy
getent group cloudflared_proxy | cut -d: -f3
```

The output looks like this:

```text
cloudflared_proxy:x:<cloudflared_proxy_gid>:
```

The third field is the real GID. Put that number into `/etc/cloudflared/output-proxy.nft` in place of `<cloudflared_proxy_gid>`:

```nft
meta skgid <cloudflared_proxy_gid>
```

In `cloudflared.service`, systemd uses the group name:

```ini
Group=cloudflared_proxy
```

In the nft rule, this post uses the numeric GID for that same group. Both sides must point to the same group. After starting the service, confirm the process group with:

```sh
ps -o pid,user,group,args -C cloudflared
```

The `GROUP` column should be `cloudflared_proxy`. If it is still `root`, systemd was not reloaded or `cloudflared` was not restarted with the updated service file.

Rule file:

```text
/etc/cloudflared/output-proxy.nft
```

Current content:

```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
  }
}
```

This rule means:

```text
match local OUTPUT only
match only the cloudflared_proxy group
match IPv4 only
match only the two Cloudflare Tunnel edge /24 ranges
match TCP only
redirect matching traffic to local port 1061
```

This is much safer than catching traffic by port or by the root user globally. `cloudflared` starts as root. If nft catches all uid root traffic, many local system services may get pulled in by accident. Matching by group keeps the blast radius limited to `cloudflared`.

Note that these two /24 ranges are not the full Cloudflare CDN ranges. They are the Cloudflare Tunnel edge ranges used in this environment. If Cloudflare changes edge addresses later, check the logs again or update the ranges from official or community references.

## systemd services

The `cloudflared` service does not put the token on the command line. The token lives in an environment file, so `ps` cannot show it directly.

```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` loads the nft table:

```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` deliberately depends on `cloudflared-output-proxy.service`. If the nft rule is not loaded, `cloudflared` should not quietly connect out directly.

## Why HTTP/2 instead of QUIC

Cloudflare Tunnel often uses QUIC by default, which means UDP 7844. This setup needs local OUTPUT transparent proxying, and the traffic has to pass through Xray's TCP proxy outbound.

The stable config explicitly uses:

```text
--protocol http2
TUNNEL_TRANSPORT_PROTOCOL=http2
```

That makes `cloudflared` use TCP 7844. With that, nft `nat output redirect` and Xray `dokodemo-door followRedirect` can reliably preserve the original target.

I also tried UDP transparent forwarding. It was not stable. Xray logs showed the UDP original target being read as the local redirect port, like this:

```text
accepted udp:127.0.0.1:1062
```

That means the UDP REDIRECT path did not preserve the original Cloudflare edge target, so it is not the final setup here.

## Validation

Service state:

```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
```

Current result:

```text
cloudflared: active / enabled
cloudflared-output-proxy: active / enabled
xray: active
```

Current `cloudflared` process:

```text
root cloudflared_proxy /usr/local/bin/cloudflared --no-autoupdate tunnel --protocol http2 --edge-ip-version 4 run
```

No token is visible on the command line.

Tunnel registration logs:

```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
```

This matched the goal better than the earlier direct path to `sjc`. The entry points changed to Hong Kong, which shows that the `cloudflared` edge connection is leaving through the Xray exit.

The Xray access log shows:

```text
cloudflared-local-redir >> default:proxy-out
```

That means the nft redirect matched, and Xray sent this traffic to the default proxy outbound.

Active connections also show `cloudflared` connected to TCP 7844:

```sh
sudo ss -tnp | grep cloudflared | grep ':7844'
```

Expected output looks like this:

```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
```

Note: `ss` still shows the original edge IP. That is exactly what `routeOnly` is meant to preserve. To confirm whether it actually uses the proxy, check the Xray access log and the edge location.

## Things that broke along the way

### 1. HTTP_PROXY alone was not reliable

I tried adding this to `cloudflared.service`:

```ini
Environment=HTTP_PROXY=http://127.0.0.1:18080
Environment=HTTPS_PROXY=http://127.0.0.1:18080
```

This did not reliably send the Cloudflare edge connection into Xray. Looking at `ss`, `cloudflared` could still connect directly to `:7844`. So the final setup does not use normal HTTP proxy environment variables. It uses nft transparent redirect in the OUTPUT chain.

### 2. Transparent forwarding without CF edge ranges does not work

If you only create a local proxy inbound and do not catch Cloudflare Tunnel edge IPs, `cloudflared` still connects directly.

The important ranges in this environment were:

```text
198.41.192.0/24
198.41.200.0/24
```

nft must explicitly redirect these targets to `1061`.

### 3. Sniffing without routeOnly breaks the tunnel

This config is syntactically valid, but it breaks the tunnel and should no longer be used as the normal inbound template:

```json
{
  "sniffing": {
    "enabled": true,
    "destOverride": [
      "fakedns",
      "quic",
      "tls",
      "http"
    ],
    "ipsExcluded": [
      "geoip:cloudflare"
    ]
  }
}
```

Actual error:

```text
TLS handshake with edge error: EOF
```

The reason is that sniffing can still affect the connection target or later resolution. `cloudflared` needs the original edge IP to stay intact. On normal inbounds, a broad `geoip:cloudflare` exclusion also catches many third-party sites behind Cloudflare CDN and makes their domain routing less precise.

### 4. Explicit CIDRs without routeOnly were still unstable

I also tried this:

```json
{
  "sniffing": {
    "enabled": true,
    "destOverride": [
      "fakedns",
      "quic",
      "tls",
      "http"
    ],
    "ipsExcluded": [
      "geoip:cloudflare",
      "198.41.192.0/24",
      "198.41.200.0/24"
    ]
  }
}
```

It passed the Xray config test, but `cloudflared` still hit EOF. So `ipsExcluded` is not stable enough for this transparent redirect path.

### 5. routeOnly was the key

The stable setup came down to this difference:

```json
"routeOnly": true
```

With this, Xray can sniff `cftunnel.com`, but it does not change the actual connection target from `198.41.x.x:7844`.

### 6. Do not redirect all root traffic

`cloudflared` currently runs as root. If nft matches uid root, it can pull in system services, Xray itself, and scheduled jobs.

The final setup matches by group:

```nft
meta skgid <cloudflared_proxy_gid>
```

This group is only used by `cloudflared`, so the impact is clear.

### 7. Do not put the token in ExecStart

Do not write it like this:

```ini
ExecStart=/usr/local/bin/cloudflared tunnel run --token <token>
```

Then `ps` can show the token. The current setup uses:

```ini
EnvironmentFile=/etc/cloudflared/tunnel.env
```

The service process arguments do not expose the token.

## Operation order

When applying this for real, this order is the least messy:

```sh
sudo groupadd -r cloudflared_proxy 2>/dev/null || true
getent group cloudflared_proxy
getent group cloudflared_proxy | cut -d: -f3
```

1. Add the `cloudflared-local-redir` inbound to Xray.
2. Validate the config with `xray run -test -config`.
3. Create the `cloudflared_proxy` group, then use `getent` to find the real GID.
4. Write `/etc/cloudflared/output-proxy.nft`, replacing `<cloudflared_proxy_gid>` with the numeric GID from the previous step.
5. Write `cloudflared-output-proxy.service`.
6. Edit `cloudflared.service` so it uses `Group=cloudflared_proxy`.
7. `systemctl daemon-reload`.
8. Restart Xray.
9. Start `cloudflared-output-proxy`.
10. Restart `cloudflared`.
11. Validate with logs and `ss`.

Validation commands:

```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'
```

## Rollback

If Cloudflare edge ranges change later, or if this proxy path becomes unstable, roll back to direct connection first so the tunnel stays usable.

```sh
sudo systemctl stop cloudflared
sudo systemctl disable --now cloudflared-output-proxy
sudo nft delete table inet cloudflared_output_proxy 2>/dev/null || true
```

Then change `cloudflared.service` back to a version that does not depend on the 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
```

You can remove the `cloudflared-local-redir` inbound from Xray, then run:

```sh
sudo systemctl daemon-reload
sudo systemctl restart xray
sudo systemctl enable --now cloudflared
sudo systemctl restart cloudflared
```

After rollback, the main check is whether `cloudflared` registers four tunnel connections again.

## References

- Xray inbound sniffing docs: `https://xtls.github.io/en/config/inbound.html#sniffingobject`
- Xray routing docs: `https://xtls.github.io/en/config/routing.html`
- Cloudflare Tunnel transparent proxy community discussion: `https://github.com/cloudflare/cloudflared/issues/1025`
- Community write-up on Cloudflare Tunnel through a proxy: `https://blog.xmgspace.me/archives/cloudflare-tunnel-via-proxy.html`
Tag: Nothing
Last updated:2026年6月20日

leconio

This person is a lazy dog and has left nothing

Like
< Last article
Next article >

Comments

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
Cancel

Archives

  • June 2026

Categories

  • Network
  • Server

COPYRIGHT © 2026 ASIDE0. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

中文EN