Date: 2026-06-19
Public version note: this post keeps the config shape, debugging path, and main decisions. Internal addresses, client IDs, Xray outbound tags, Wi-Fi PSKs, and other environment-specific values have been replaced with examples.
This post records a very specific Raspberry Pi network change: using a Raspberry Pi as a Wi-Fi hotspot, then routing clients through the existing Xray setup. Normal sites still follow the original China and non-China routing rules. Sites that need the proxy leave through Xray.
This was not a "turn on hotspot and call it done" setup. The actual problems were:
```text
NetworkManager could start a hotspot, but the negotiated Wi-Fi rate stayed low, and 5GHz still used only 20 MHz
hotspot client TCP and UDP traffic had to enter Xray transparent proxying instead of leaking straight out through eth0
client DNS could not be polluted by the local network, and it also could not get REFUSED responses from the Xray DNS inbound
```
The setup that stayed stable was:
```text
eth0 remains the Raspberry Pi uplink and management interface
wlan0 runs a 5GHz 802.11ac 80 MHz hotspot through hostapd
the hotspot subnet is 10.42.0.0/24
dnsmasq gives hotspot clients DHCP and listens on 10.42.0.1:53
dnsmasq does not use public DNS directly, it forwards to 127.0.0.1:5353
127.0.0.1:5353 reaches Cloudflare DoH through the local Xray SOCKS exit
normal client TCP and UDP traffic goes into Xray through nftables tproxy
UDP/443 is rejected before Xray so clients fall back to TCP quickly
```
When I say "close to 500M" here, I mean the Wi-Fi negotiated link rate, not guaranteed download throughput. The Raspberry Pi onboard Wi-Fi is single-stream 802.11ac. With 80 MHz, the common top negotiated rate is `433.3 MBit/s`. Some phones or system UIs show that as a near-500M connection class. Real TCP download speed still depends on signal quality, the client antenna, CPU, the proxy exit, and upstream bandwidth.
## Related posts
- [Raspberry Pi Tailscale exit node and Xray transparent proxy setup](https://aside0.me/en/raspberry-pi-tailscale-xray-2026-06-15-en/)
- [Route Cloudflare Tunnel through Xray: cloudflared, nftables, and sniffing](https://aside0.me/en/cloudflared-tunnel-xray-sniff-nft-2026-06-17-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)
Network manager: NetworkManager
Wi-Fi hotspot: hostapd
DHCP/DNS: dnsmasq
Firewall: nftables
Proxy: Xray
Uplink interface: eth0
Hotspot interface: wlan0
Raspberry Pi LAN address: 192.168.10.20
Hotspot gateway address: 10.42.0.1
Hotspot subnet: 10.42.0.0/24
```
Xray already had transparent proxy inbounds:
```text
Xray SOCKS: 0.0.0.0:10808
Xray TCP TProxy: 127.0.0.1:1041
Xray UDP TProxy: 127.0.0.1:1051
Xray DNS inbound: 192.168.10.20:53
```
This post only covers the Wi-Fi hotspot path and the hotspot clients. The same Raspberry Pi already had Tailscale exit, cloudflared, and the normal side-router style rules. I am not repeating those here.
## Final topology
The final path has two parts: DNS and normal traffic.
DNS takes this route:
```mermaid
flowchart LR
phone["hotspot client<br/>phone / tablet / laptop"]
ap["wlan0<br/>RasPi-Hotspot"]
dnsmasq["dnsmasq<br/>10.42.0.1:53"]
doh_local["local DoH forwarder<br/>127.0.0.1:5353"]
socks["Xray SOCKS<br/>127.0.0.1:10808"]
proxy["Xray default proxy outbound"]
cf["Cloudflare DoH<br/>cloudflare-dns.com"]
phone --> ap
ap --> dnsmasq
dnsmasq --> doh_local
doh_local --> socks
socks --> proxy
proxy --> cf
```
Normal TCP and UDP traffic takes this route:
```mermaid
flowchart LR
phone["hotspot client<br/>10.42.0.0/24"]
wlan["wlan0"]
nft["nftables<br/>xray_tproxy"]
tcp_in["Xray TCP TProxy<br/>127.0.0.1:1041"]
udp_in["Xray UDP TProxy<br/>127.0.0.1:1051"]
route["Xray routing<br/>direct / proxy / blackhole"]
eth["eth0"]
internet["Internet"]
phone --> wlan
wlan --> nft
nft --> tcp_in
nft --> udp_in
tcp_in --> route
udp_in --> route
route --> eth
eth --> internet
```
UDP/443 is handled before it reaches Xray:
```mermaid
flowchart TD
client["hotspot client sends UDP/443<br/>QUIC / HTTP3"]
reject["nftables fast reject"]
fallback["client falls back to TCP/443"]
tproxy["TCP/443 enters Xray TProxy"]
client --> reject
reject --> fallback
fallback --> tproxy
```
The goal is not to proxy QUIC. The goal is to disable UDP/443 clearly and avoid a silent timeout. For phone apps, a fast unreachable response often gives a faster TCP fallback than dropping the packets.
## Why I stopped using NetworkManager hotspot mode
NetworkManager can start `wlan0` as a hotspot, and it can hand out DHCP. In this setup, though, it left one obvious problem: even on 5GHz, the channel width stayed at 20 MHz and the negotiated rate stayed low.
I saw output like this:
```text
channel 149 (5745 MHz), width: 20 MHz
tx bitrate: 78.0 MBit/s
rx bitrate: 43.3 MBit/s
```
On 2.4GHz it was worse, sometimes down to single-digit Mbit/s:
```text
tx bitrate: 6.5 MBit/s
rx bitrate: 5.5 MBit/s
```
At that point, Xray was not the first thing to debug. The Wi-Fi link itself made the network feel broken. I switched to `hostapd` so I could control the AP parameters directly and force 802.11ac with 80 MHz.
## hostapd for a 5GHz 80 MHz hotspot
Example hotspot config:
```ini
country_code=CN
ieee80211d=1
interface=wlan0
driver=nl80211
ssid=RasPi-Hotspot
hw_mode=a
channel=149
ieee80211n=1
ieee80211ac=1
wmm_enabled=1
ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
vht_capab=[SHORT-GI-80]
vht_oper_chwidth=1
vht_oper_centr_freq_seg0_idx=155
beacon_int=100
dtim_period=2
auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP
wpa_passphrase=<wifi-psk>
```
A few parts matter:
- `hw_mode=a` uses 5GHz.
- `channel=149` was the working 5GHz channel in this test.
- `ieee80211ac=1` enables 802.11ac.
- `vht_oper_chwidth=1` means 80 MHz.
- `vht_oper_centr_freq_seg0_idx=155` is the 80 MHz center segment for channel 149.
- `wmm_enabled=1` matters. Many 802.11n/ac high-rate modes depend on WMM.
After starting the AP, check it with:
```sh
iw dev wlan0 info
iw dev wlan0 station dump
```
The AP should show:
```text
ssid RasPi-Hotspot
type AP
channel 149 (5745 MHz), width: 80 MHz, center1: 5775 MHz
```
After a client connects, the negotiated rate should be close to:
```text
tx bitrate: 433.3 MBit/s
rx bitrate: 260.0 MBit/s
```
`tx bitrate` is the AP-to-client direction, so it is usually closer to the upper bound for client download speed. `rx bitrate` is client-to-AP, which depends more on client signal and upload direction. Both are negotiated link rates, not speed test results.
## dnsmasq only handles the hotspot edge
Hotspot clients still need local DHCP and DNS. I used `dnsmasq` for that:
```ini
interface=wlan0
bind-interfaces
listen-address=10.42.0.1
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,1h
dhcp-option=3,10.42.0.1
dhcp-option=6,10.42.0.1
no-resolv
server=127.0.0.1#5353
filter-rr=65
log-dhcp
```
`dhcp-option=3` tells clients that the default gateway is `10.42.0.1`. `dhcp-option=6` tells them to use `10.42.0.1` for DNS too.
These two lines are the important part:
```ini
server=127.0.0.1#5353
filter-rr=65
```
`server=127.0.0.1#5353` means dnsmasq does not query public DNS directly. It forwards to the local DoH forwarder. `filter-rr=65` filters HTTPS records, which reduces the chance that clients wander into paths involving ECH, HTTP/3, or special HTTPS records. Even if a CNAME exists, the client can still get A records.
## Why I did not give phones the Xray DNS inbound directly
The Raspberry Pi already had an Xray DNS inbound, for example:
```text
192.168.10.20:53
```
The obvious question is: if Xray already has DNS, why not point hotspot clients at it?
It did not behave well as a client-facing resolver. The Xray DNS inbound could answer normal A records, but it was incomplete for some query types that phone operating systems ask for. iOS, for example, repeatedly asks:
```text
SOA? .
HTTPS? example.com
```
When the hotspot DNS reused the Xray DNS inbound directly, the results looked like this:
```text
192.168.10.20 www.youtube.com type=1 rcode=0 answers=1
192.168.10.20 www.youtube.com type=65 rcode=5 answers=0
192.168.10.20 www.youtube.com type=6 rcode=5 answers=0
192.168.10.20 . type=6 timed out
```
`rcode=5` means `REFUSED`. That may be acceptable for Xray's own routing lookups, but it is risky as the full resolver for a phone. When the phone keeps seeing REFUSED responses or timeouts at the DNS layer, it may decide the Wi-Fi network is unreliable before the app even makes its real request.
So I did not give clients the Xray DNS inbound directly. The path became:
```text
the phone only talks to dnsmasq
dnsmasq handles hotspot client compatibility
dnsmasq sends upstream DNS through DoH over the Xray SOCKS exit
```
Xray still owns the proxy exit. I did not force its DNS inbound to behave like a full recursive resolver.
## Why direct public DNS also failed
Another simpler-looking option was:
```text
dnsmasq -> 1.1.1.1 / 8.8.8.8
```
That fixes the `SOA` and `HTTPS/type65` REFUSED problem, but in this network it ran into DNS pollution. When hotspot DNS queried public DNS directly, the answers were clearly wrong:
```text
www.google.com -> 104.244.42.197
www.youtube.com -> 69.171.235.22
youtubei.googleapis.com -> 162.125.32.10
telegram.org -> 162.125.32.2
```
Those are not normal Google, YouTube, or Telegram answers. Once the client has a polluted IP, sending the later TCP connection through Xray does not help much. Xray would be proxying a connection to the wrong target.
After the DNS query itself went through the Xray exit, the answers returned to normal:
```text
www.google.com -> 142.251.x.x
www.youtube.com -> youtube-ui.l.google.com -> 142.250.x.x
youtubei.googleapis.com -> 216.239.32.223 / 216.239.34.223 / ...
telegram.org -> 149.154.167.99
chatgpt.com -> 104.18.32.47 / 172.64.155.209
```
That was the main diagnosis. The Xray node was not broken, and hotspot traffic was not completely bypassing Xray. The client had already received polluted DNS answers before its traffic entered Xray.
## Local DoH forwarder
I used a small local forwarder on `127.0.0.1:5353`. It accepts normal DNS-over-UDP from dnsmasq, then reaches Cloudflare DoH through the Xray SOCKS exit:
```text
127.0.0.1:5353
-> SOCKS 127.0.0.1:10808
-> cloudflare-dns.com:443
```
The systemd service looked like this:
```ini
[Unit]
Description=DNS-over-HTTPS forwarder through local Xray SOCKS
After=network-online.target xray.service
Wants=network-online.target
Requires=xray.service
[Service]
Type=simple
ExecStart=/usr/local/lib/raspi-hotspot/socks-doh-proxy.py --listen 127.0.0.1 --port 5353
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
The exact script is not the point. `dnscrypt-proxy`, `mosdns`, `smartdns`, or another DNS component could do the same job if it satisfies two requirements:
```text
downstream clients see a normal DNS resolver
upstream DoH / DoT / DNS traffic can leave through the local Xray SOCKS exit or proxy path
```
The important part is to stop hotspot clients from resolving foreign domains through the local network directly.
## nftables sends hotspot clients into Xray
Normal hotspot client traffic is caught by `nftables` in `prerouting`:
```nft
table inet xray_tproxy {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
iifname "wlan0" udp dport 443 reject comment "fast-reject-udp443"
iifname "wlan0" ip saddr 10.42.0.0/24 jump proxy4
}
chain proxy4 {
ip daddr {
0.0.0.0/8,
10.0.0.0/8,
100.64.0.0/10,
127.0.0.0/8,
169.254.0.0/16,
172.16.0.0/12,
192.168.0.0/16,
224.0.0.0/3
} return
meta l4proto tcp tproxy ip to 127.0.0.1:1041 meta mark set 0x00000100 accept
meta l4proto udp tproxy ip to 127.0.0.1:1051 meta mark set 0x00000100 accept
}
}
```
The matching policy routing is:
```sh
ip rule add fwmark 0x100/0x100 table 100
ip route add local default dev lo table 100
```
Both commands are required. After `tproxy` marks a packet, policy routing has to send that packet back to local `lo`. Only then can Xray receive it on `127.0.0.1:1041` and `127.0.0.1:1051`.
The UDP/443 reject sits before the jump to `proxy4`:
```nft
iifname "wlan0" udp dport 443 reject comment "fast-reject-udp443"
```
With this in place, clients like YouTube, Google, Apple, or Bilibili get a quick failure when they try QUIC, then fall back to TCP/443. If UDP/443 goes into Xray and then a blackhole, the client can wait for a timeout. From the user's side, that feels like "slow" or "not opening".
## Xray inbound and routing notes
Xray needs at least these inbounds:
```json
[
{
"tag": "socks-in",
"listen": "0.0.0.0",
"port": 10808,
"protocol": "socks",
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
},
{
"tag": "tproxy-in",
"listen": "127.0.0.1",
"port": 1041,
"protocol": "tunnel",
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
},
{
"tag": "udp-redir",
"listen": "127.0.0.1",
"port": 1051,
"protocol": "dokodemo-door",
"sniffing": {
"enabled": true,
"destOverride": ["quic"],
"routeOnly": true
}
}
]
```
`routeOnly: true` matters. It lets Xray sniff a domain from TLS, HTTP, or QUIC and use that for `geosite` routing, but it does not rewrite the original target. In transparent proxying, that avoids a whole class of "Xray sniffed the domain and then dialed the wrong thing" problems.
The routing rules can be much more complex in a real setup. The basic order here is:
```text
DNS traffic uses the DNS outbound first
UDP/443 is disabled or rejected early
private addresses go direct
China IPs and China domains go direct
ad rules can go to blackhole
everything else uses the default proxy outbound
```
Example:
```json
{
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
{
"type": "field",
"network": "tcp,udp",
"port": 53,
"outboundTag": "dns-out"
},
{
"type": "field",
"ip": ["geoip:private"],
"outboundTag": "direct"
},
{
"type": "field",
"ip": ["geoip:cn"],
"outboundTag": "direct"
},
{
"type": "field",
"domain": ["geosite:cn"],
"outboundTag": "direct"
}
]
}
}
```
The default outbound tag, node parameters, UUIDs, Reality keys, and VLESS settings should not be placed in a public post.
## Checking Wi-Fi speed
First check whether the AP really runs at 80 MHz:
```sh
iw dev wlan0 info
```
Expected:
```text
channel 149 (5745 MHz), width: 80 MHz, center1: 5775 MHz
```
Then check the connected client:
```sh
iw dev wlan0 station dump
```
Expected:
```text
Station <client-mac> (on wlan0)
tx bitrate: 433.3 MBit/s
rx bitrate: 260.0 MBit/s
```
If it is still only tens of Mbit/s, debug Wi-Fi first, not Xray. Check:
- Whether the client is really on 5GHz.
- Whether hostapd is really using 80 MHz.
- Whether the country code or driver limits the channel.
- Whether the phone is too far from the Raspberry Pi.
- Whether `wlan0` is still being managed by NetworkManager at the same time.
## Checking DNS pollution
Hotspot DNS should be queried through `10.42.0.1`:
```sh
dig @10.42.0.1 www.youtube.com A
dig @10.42.0.1 youtubei.googleapis.com A
dig @10.42.0.1 www.google.com A
dig @10.42.0.1 . SOA
```
Expected healthy answers:
```text
www.youtube.com CNAME youtube-ui.l.google.com -> 142.250.x.x
youtubei.googleapis.com 216.239.32.223 / 216.239.34.223 / ...
www.google.com 142.251.x.x
. SOA a.root-servers.net
```
You should not see obviously polluted answers like these:
```text
www.google.com -> 104.244.42.197
www.youtube.com -> 69.171.235.22
youtubei.googleapis.com -> 162.125.32.10
```
If the phone already received polluted answers, toggle Wi-Fi or airplane mode for a few seconds so the OS drops its old DNS cache.
## Checking that traffic enters Xray
First confirm from the Raspberry Pi itself that the proxy exit works:
```sh
curl --socks5-hostname 127.0.0.1:10808 \
-4 -L -m 12 -o /dev/null -sS \
-w '%{http_code} %{time_starttransfer}\n' \
https://www.youtube.com/generate_204
```
Expected:
```text
204 0.xxx
```
Then watch the Xray access log. When a hotspot client opens a proxied site, logs should look like this:
```text
from 10.42.0.20:53500 accepted tcp:142.250.197.238:443 [tproxy-in >> <proxy-outbound>]
from 10.42.0.20:53501 accepted tcp:216.239.34.223:443 [tproxy-in >> <proxy-outbound>]
```
If you see this instead:
```text
from 10.42.0.20 accepted tcp:<China IP>:443 [tproxy-in -> direct]
```
then the traffic did enter Xray. Xray simply routed it direct because it matched the China rules. Do not misread that as "the proxy was bypassed".
Packet capture makes this easier to see:
```sh
tcpdump -i wlan0 -nn 'net 10.42.0.0/24 and (udp port 53 or port 443)'
```
After DNS is clean, opening YouTube or another Google service should show `googlevideo.com`, `youtube-ui.l.google.com`, or the matching Google IPs. The Xray access log should also show `tproxy-in >> <proxy-outbound>`.
## Mistakes from this run
The first mistake was treating "the hotspot is up" as "the hotspot is fast enough". NetworkManager made hotspot setup convenient, but it did not push `wlan0` to 80 MHz in this test. For a 5GHz 802.11ac hotspot, check `width: 80 MHz` in `iw dev wlan0 info`. Do not stop at the SSID and channel.
The second mistake was treating "DNS returns an answer" as "DNS is fine". Direct `1.1.1.1` or `8.8.8.8` fixed the `SOA` REFUSED issue, but the answers were polluted in this network. Once the client has the wrong IP, transparent proxying later in the path cannot fix the target.
The third mistake was giving phones the Xray DNS inbound directly. Xray DNS is fine for Xray's own routing decisions, but it is not always a good full resolver for phones. iOS asks `SOA .` and `HTTPS/type65`. If those queries get REFUSED or time out, the phone's network stack can behave strangely.
The fourth mistake was blackholing UDP/443. Disabling QUIC is fine. Blackholing makes clients wait. A fast nft reject at the entrance makes TCP/443 fallback happen sooner.
The fifth mistake was debugging with more than one hotspot client attached. First identify which `10.42.0.x` belongs to the phone you are testing. Otherwise it is easy to read another device's Xray log entries as your own.
## Rollback
To temporarily stop the hotspot:
```sh
sudo kill "$(cat /run/raspi-hostapd-test.pid)"
sudo kill "$(cat /run/raspi-dnsmasq-test.pid)"
sudo nmcli dev set wlan0 managed yes
sudo systemctl restart NetworkManager
```
To stop the local DoH forwarder:
```sh
sudo systemctl disable --now socks-doh-dns.service
```
To remove the temporary UDP/443 fast-reject rule, first find its handle:
```sh
sudo nft -a list chain inet xray_tproxy prerouting
```
Then delete it by handle:
```sh
sudo nft delete rule inet xray_tproxy prerouting handle <handle>
```
To send hotspot DNS back to direct public DNS, dnsmasq could be changed to:
```ini
server=1.1.1.1
server=8.8.8.8
```
I would not recommend that in this environment, because direct public DNS was already polluted in testing. A safer rollback is to stop the hotspot instead of sending clients back to polluted DNS.
## Final read
The final problem was not the Xray node itself. From the Raspberry Pi, Xray SOCKS could reach Google, YouTube, X, Facebook, and Telegram, so the proxy exit worked.
The failures were on the hotspot client path:
```text
Wi-Fi layer: the NetworkManager hotspot did not reach 80 MHz, so the link was slow
DNS layer: direct public DNS was polluted, and the Xray DNS inbound was not suitable as the phone's resolver
Transport layer: blackholed UDP/443 delayed client fallback
```
Separating those layers made the path clear:
```text
hostapd pushes wlan0 to 5GHz 80 MHz
dnsmasq handles DHCP/DNS compatibility for hotspot clients
the local DoH forwarder sends DNS queries through the Xray exit
nftables sends normal hotspot client traffic into Xray
Xray handles China/non-China routing and the default proxy exit
```
At that point, the Raspberry Pi was no longer just "a Wi-Fi hotspot". It became a hotspot with cleaner DNS, Xray transparent proxying, and a negotiated Wi-Fi rate close to the 500M class.
Comments