Date: 2026-06-15
This document records the network changes made on the Raspberry Pi today. It is not a general tutorial. The paths, ports, and policies are written for how this machine is currently used.
The machine has a simple role: the Raspberry Pi is a one-arm bypass router and a Tailscale exit node. External devices send traffic to it through Tailscale or the LAN, then the Raspberry Pi uses Xray for transparent proxying and routing.
The actual problems were:
- Traffic from the Raspberry Pi's Tailscale node to the phone sometimes went through DERP relay first, and direct connection was slow to establish.
- The Raspberry Pi did not have a public IPv6 address, so Tailscale could only do NAT traversal over IPv4.
- The upstream Xray proxy does not support IPv6 targets, so IPv6 destinations cannot be sent directly to the proxy.
- When the Raspberry Pi acts as an exit node, client IPv6 traffic must not leak out as a direct home-broadband IPv6 path.
- DNS must not keep returning a lot of AAAA records to clients, otherwise clients will try IPv6 first and then fail or hang.
- `geosite` and `geoip` need automatic updates. I do not want to replace them by hand later.
The final goal is not to make everything IPv6. This upstream proxy does not support IPv6, so the practical target is:
```text
Tailscale NAT traversal can use IPv6
The Raspberry Pi itself must not casually use public IPv6 as an internet exit
Tailscale exit traffic should prefer IPv4 for internet access
IPv4 internet traffic still goes through Xray
Public IPv6 exit traffic is rejected directly to avoid leaks
```
## Related posts
- [Route Cloudflare Tunnel through Xray: cloudflared, nftables, and sniffing](https://aside0.me/en/cloudflared-tunnel-xray-sniff-nft-2026-06-17-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 running Debian, not OpenWrt. Everything today was configured for this environment:
```text
Hardware: Raspberry Pi 5 Model B Rev 1.0
OS: Debian GNU/Linux 13 (trixie)
Kernel: 6.12.62+rpt-rpi-v8
Architecture: linux/arm64
Network management: NetworkManager
Firewall: nftables
Service manager: systemd 257
Tailscale: 1.98.4
Xray: 26.3.27
```
On the network it is a one-arm bypass router. It has one Ethernet cable, and all ingress and egress traffic uses `eth0`:
```text
LAN subnet: 192.168.51.0/24
Raspberry Pi IPv4: 192.168.51.72/24
Default IPv4 gateway: 192.168.51.1
Public IPv6: obtained automatically through RA
Tailscale IPv4: 100.76.137.29
Tailscale IPv6: fd7a:115c:a1e0::6039:891d
```
A few assumptions need to be clear first, otherwise the later rules look more complicated than they are:
- The Raspberry Pi has public IPv6, but it should not use that IPv6 directly for internet access.
- Tailscale needs IPv6 for direct NAT traversal, so IPv6 cannot be disabled entirely.
- The upstream Xray proxy does not support IPv6 targets, so public IPv6 exit traffic cannot be handed to Xray.
- The final policy is to make clients get IPv4 where possible, then send IPv4 through Xray. IPv6 is kept only for Tailscale NAT traversal and LAN protocols that need it.
## Current topology
```mermaid
flowchart LR
phone["Tailscale client<br/>phone or computer"]
ts["tailscale0<br/>Raspberry Pi"]
nft["nftables<br/>xray_tproxy"]
xray["Xray<br/>transparent proxy and routing"]
proxy["upstream Xray proxy<br/>supports only IPv4 targets"]
wan4["IPv4 Internet"]
wan6["IPv6 Internet"]
phone -->|IPv4 exit traffic| ts
ts --> nft
nft -->|IPv4 TCP/UDP tproxy| xray
xray --> proxy
proxy --> wan4
phone -->|public IPv6 exit traffic| ts
ts --> nft
nft -->|reject| wan6
```
The Raspberry Pi's own local traffic path is different from exit traffic. This is easy to mix up.
```mermaid
flowchart TD
app["local Raspberry Pi process"]
output["Linux OUTPUT chain"]
block6["public IPv6 output reject"]
ipv4["IPv4 default route"]
xray_socks["explicit Xray socks use"]
proxy["upstream Xray proxy"]
app --> output
output -->|normal public IPv6| block6
output -->|normal IPv4| ipv4
app -->|socks5 127.0.0.1:10808| xray_socks
xray_socks --> proxy
```
Tailscale's own NAT traversal traffic must not be blocked with the rest. It is UDP traffic sent by the local `tailscaled` process. If IPv6 output is dropped globally, the newly fixed IPv6 direct path breaks again.
```mermaid
flowchart LR
tailscaled["tailscaled<br/>local process"]
output["nft output"]
stun["STUN<br/>UDP 3478"]
wg["Tailscale UDP<br/>41642"]
peer["Tailscale peer<br/>phone"]
tailscaled --> output
output -->|allow UDP 3478| stun
output -->|allow UDP 41642| wg
wg --> peer
```
## State after the change
Current Raspberry Pi state:
```text
IPv4 address: 192.168.51.72/24
IPv4 gateway: 192.168.51.1
Tailscale IP: 100.76.137.29
Tailscale UDP port: 41642
Xray socks: 0.0.0.0:10808
Xray TProxy TCP: 127.0.0.1:1041
Xray TProxy UDP: 127.0.0.1:1051
Xray DNS inbound: 192.168.51.72:53
```
Service state should be:
```sh
systemctl is-active xray xray-tproxy tailscaled xray-geo-update.timer
systemctl is-enabled xray xray-tproxy tailscaled xray-geo-update.timer
```
Expected result:
```text
active
active
active
active
enabled
enabled
enabled
enabled
```
## 1. Give the Raspberry Pi IPv6, but do not treat IPv6 as exit capability
At the start, `eth0` looked like this:
```text
ipv6.method: disabled
```
That made `tailscale netcheck` show only:
```text
IPv6: no, but OS has support
```
Change it to obtain IPv6 automatically:
```sh
sudo nmcli connection modify "Wired connection 1" \
ipv6.method auto \
ipv6.may-fail yes \
ipv6.never-default no \
ipv6.ignore-auto-routes no \
ipv6.ignore-auto-dns no
sudo nmcli device reapply eth0
```
Because forwarding is also enabled on the Raspberry Pi, Linux defaults can affect RA reception. `eth0` needs to keep accepting router advertisements even when forwarding is on:
```sh
sudo tee /etc/sysctl.d/98-raspi-eth0-ipv6.conf >/dev/null <<'EOF'
# Keep accepting IPv6 router advertisements on eth0 even when forwarding is enabled.
net.ipv6.conf.eth0.accept_ra=2
net.ipv6.conf.eth0.autoconf=1
net.ipv6.conf.eth0.disable_ipv6=0
net.ipv6.conf.default.disable_ipv6=0
net.ipv6.conf.all.disable_ipv6=0
EOF
sudo sysctl -p /etc/sysctl.d/98-raspi-eth0-ipv6.conf
```
Verify:
```sh
ip -6 addr show dev eth0
ip -6 route show default
tailscale netcheck
```
After this, the Raspberry Pi can get public IPv6 and `tailscale netcheck` shows:
```text
IPv6: yes
```
This IPv6 is mainly for improving Tailscale direct connections, not for letting clients access the IPv6 internet through the Raspberry Pi.
## 2. Avoid the Tailscale port conflict
The Raspberry Pi and Kwrt are behind the same upstream NAT. Both machines used the default UDP `41641` before, and the Raspberry Pi to phone path sometimes stayed on DERP for a long time.
Move the Raspberry Pi to `41642`:
```sh
sudo sed -i 's/^PORT=.*/PORT=41642/' /etc/default/tailscaled
sudo systemctl restart tailscaled
```
The nft input chain allows both ports at the same time, so future testing between the two machines does not get blocked by accident:
```nft
udp dport { 41641, 41642 } accept
```
Verify:
```sh
ss -lunp | grep 41642
tailscale netcheck
tailscale ping 100.89.253.91
```
`tailscale ping` may go through DERP first and then switch to direct. That is normal. Tailscale also notes that the first ping often uses DERP, then switches to direct after NAT traversal finds a path.
## 3. What Xray does here
On this Raspberry Pi, Xray has one job: catch IPv4 traffic that should be proxied, then decide by rule whether to send it direct, proxy it, drop it, or hand it to the DNS outbound.
It does not handle Tailscale's own NAT traversal. `tailscaled` is a local process and sends UDP packets directly from Linux OUTPUT. That path must stay open, otherwise Tailscale's IPv6 direct quality gets worse.
It also does not handle public IPv6 exit traffic. The reason is practical: the upstream Xray proxy does not support IPv6 targets. Forcing an IPv6 target into it usually does not turn the target into IPv4. It just fails. So the policy here is to make clients use IPv4 where possible. If public IPv6 traffic still comes in, reject it directly.
Inside Xray, the path is roughly:
```mermaid
flowchart LR
socks["socks-in<br/>10808"]
tproxy["tproxy-in<br/>1041"]
udp["udp-redir<br/>1051"]
dnsin["dns-in<br/>53"]
route["routing rules"]
direct["direct"]
proxy["default proxy"]
block["blackhole"]
dnsout["dns-out"]
socks --> route
tproxy --> route
udp --> route
dnsin --> dnsout
route --> direct
route --> proxy
route --> block
route --> dnsout
```
Current Xray service and asset locations:
```text
Config file: /etc/xray/config.json
Binary: /usr/local/bin/xray
Asset directory: /usr/local/share/xray
Service: xray.service
Run user: root
Run group: xray_tproxy
```
Before running, make sure the systemd environment has:
```text
XRAY_LOCATION_ASSET=/usr/local/share/xray
```
Otherwise `geosite.dat` and `geoip.dat` may not be found.
### Xray inbounds
There are four inbounds now.
```text
socks-in 0.0.0.0:10808 socks
tproxy-in 127.0.0.1:1041 tunnel
udp-redir 127.0.0.1:1051 dokodemo-door
dns-in 192.168.51.72:53 dokodemo-door
```
`socks-in` is for manual proxy use. If a LAN program supports socks, point it at:
```text
192.168.51.72:10808
```
UDP is enabled, and sniffing is enabled too. Sniffing tries to recover the domain name from HTTP, TLS, and QUIC, so routing can use `geosite` rules instead of seeing only the IP. The current sniffing mode uses `routeOnly`, so the domain is used for routing but does not rewrite the real target address. Cloudflare Tunnel, API, and Zero Trust Access control-plane domains are excluded with `domainsExcluded` instead of using an overbroad `geoip:cloudflare` rule that would affect normal CDN-backed sites.
`tproxy-in` is the main TCP entry point for transparent proxying. nft sends matching TCP traffic to `127.0.0.1:1041`, and Xray reads the original destination through TProxy. This inbound uses `tunnel` and allows `tcp,udp`, although nft currently sends UDP separately to `udp-redir`.
`udp-redir` is the UDP entry point for transparent proxying. It listens on `127.0.0.1:1051`, uses `dokodemo-door`, and has `followRedirect` enabled. Its main purpose is to catch and log UDP traffic outside DNS, QUIC, and STUN clearly.
`dns-in` is the DNS entry point for clients. It listens on the Raspberry Pi's LAN address:
```text
192.168.51.72:53
```
DNS requests received by this inbound go to `dns-out`, not through the normal proxy path.
### Xray outbounds
The current outbounds are easier to understand by purpose:
```text
default:vless-enc-openwrt default proxy outbound
direct direct
blackhole drop
dns-out Xray DNS outbound
```
There is also a backup VLESS outbound. It exists in the config, but the current routes default to `default:vless-enc-openwrt`.
`direct` uses `UseIP` as its `domainStrategy`. That means direct connections resolve the target before connecting. Together with `geoip:cn` and `geosite:cn`, mainland China IPs and domains should avoid the proxy where possible.
`blackhole` handles ad domains. It is not a firewall drop. Xray receives the traffic and then discards it, so the logs can still show which rule matched.
### Xray routing order
Xray matches routing rules from top to bottom. The current order is:
```text
dns-in -> dns-out
port 53 -> dns-out
dns-local -> direct
geosite:category-games@cn -> direct
geosite:category-ads-all -> blackhole
geoip:private -> direct
geoip:cn -> direct
geosite:cn -> direct
UDP 123 -> direct
everything else -> default:vless-enc-openwrt
```
Do not change the order casually. A few points are easy to miss:
- `dns-in` and `port 53` need to be near the top, otherwise DNS can be treated as normal proxy traffic.
- `dns-local -> direct` keeps Xray's own DNS queries from looping back into Xray.
- `category-games@cn` is placed before `category-ads-all` to reduce false hits where domestic game domains get caught by ad rules.
- `geoip:private` must go direct, otherwise LAN addresses, Tailscale addresses, and reserved addresses can be proxied by mistake.
- Both `geosite:cn` and `geoip:cn` are kept because transparent proxy traffic sometimes shows only an IP, and sometimes sniffing can recover the domain.
The current routing `domainStrategy` is:
```text
IPIfNonMatch
```
This means that when no domain rule matches, Xray tries to resolve the IP and then continues matching IP rules. That fits this routing setup because transparent proxy traffic does not always have a complete domain name at the start.
### DNS returns only IPv4
Because the upstream Xray proxy does not support IPv6, DNS should not actively return AAAA records to clients. If clients get IPv6 targets, they either fail or bypass the proxy.
Current Xray DNS:
```json
{
"servers": ["localhost"],
"queryStrategy": "UseIPv4",
"useSystemHosts": true,
"tag": "dns-local"
}
```
This setting is not IPv6 to IPv4 conversion. It only tries to avoid returning AAAA records to clients, so clients prefer IPv4. It is enough for dual-stack sites, but it does not support IPv6-only sites.
Verify:
```sh
dig @192.168.51.72 www.google.com A +short
dig @192.168.51.72 www.google.com AAAA +short
```
Expected: A has a result, AAAA is empty.
### Xray validation
Run the config test with `sudo`. If a normal user runs it directly, it fails because it cannot open `/var/log/xray/access.log`. That does not mean the config is broken.
```sh
sudo env XRAY_LOCATION_ASSET=/usr/local/share/xray \
/usr/local/bin/xray run -test -config /etc/xray/config.json
```
Check listeners:
```sh
sudo ss -lntup | grep -E 'xray|:1041|:1051|:10808|:53'
```
Check routing:
```sh
curl -x socks5h://127.0.0.1:10808 http://www.baidu.com/ -I
curl -x socks5h://127.0.0.1:10808 https://www.google.com/generate_204 -I
curl -x socks5h://127.0.0.1:10808 http://googleads.g.doubleclick.net/ -I
```
Expected log behavior:
```text
www.baidu.com -> direct
www.google.com -> default:vless-enc-openwrt
googleads.g.doubleclick.net -> blackhole
```
## 4. The ruleset source is not a core condition
`geosite.dat` and `geoip.dat` are Xray rule data. What matters here is that these rules can be parsed:
```text
geoip:private
geoip:cn
geosite:cn
geosite:category-ads-all
geosite:category-games@cn
```
Switching to Loyalsoldier is not required for the bypass router setup to work. It is just the ruleset source chosen today. Xray's official ruleset, the v2fly ruleset, or any other compatible dat files can run this design too, as long as the rule names you use exist in that dat file or you change the config to names supported by that source.
The reason for using Loyalsoldier today is simple: its `geosite.dat` and `geoip.dat` releases are stable, include common categories, and can be downloaded with the same file names straight into Xray's asset directory.
Current install location:
```text
/usr/local/share/xray/geosite.dat
/usr/local/share/xray/geoip.dat
```
If you are rebuilding only this network, you do not have to switch the ruleset first. A more useful order is:
```text
get Xray running first
then confirm `geosite` and `geoip` rules match
finally decide whether to change the ruleset source and enable automatic updates
```
Manual replacement can look like this:
```sh
tmpdir=$(mktemp -d)
cd "$tmpdir"
curl -fsSL -o geosite.dat \
https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -fsSL -o geoip.dat \
https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
sudo cp -a /usr/local/share/xray/geosite.dat /usr/local/share/xray/geosite.dat.bak.$(date +%Y%m%d-%H%M%S)
sudo cp -a /usr/local/share/xray/geoip.dat /usr/local/share/xray/geoip.dat.bak.$(date +%Y%m%d-%H%M%S)
sudo install -o root -g root -m 0644 geosite.dat /usr/local/share/xray/geosite.dat
sudo install -o root -g root -m 0644 geoip.dat /usr/local/share/xray/geoip.dat
sudo env XRAY_LOCATION_ASSET=/usr/local/share/xray \
/usr/local/bin/xray run -test -config /etc/xray/config.json
sudo systemctl restart xray
```
## 5. nft transparent proxy and IPv6 policy
The main nft table is:
```text
inet xray_tproxy
```
IPv4 entry points:
```nft
iifname "tailscale0" jump proxy4
iifname "eth0" ip saddr 192.168.51.0/24 jump proxy4
```
This means:
- IPv4 exit traffic coming from Tailscale enters Xray.
- When other LAN devices use the Raspberry Pi as their gateway, their IPv4 traffic enters Xray.
IPv4 transparent proxy rules:
```nft
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 0x100 accept
meta l4proto udp tproxy ip to 127.0.0.1:1051 meta mark set 0x100 accept
```
Public IPv6 exit traffic is not proxied. It is rejected directly:
```nft
chain proxy6 {
ip6 daddr { ::/127, fc00::/7, fe80::/10, ff00::/8 } return
reject with icmpv6 type admin-prohibited
}
```
`fc00::/7` stays in the allow list because the Tailscale overlay IPv6 range is `fd7a:...`. We must not block internal Tailscale IPv6 with the public IPv6 path.
## 6. Block local public IPv6 output too
A later policy was added: the Raspberry Pi itself must not go directly through public IPv6 either.
But it cannot be fully blocked. Tailscale IPv6 direct and netcheck need local UDP traffic, so the output chain keeps these exceptions:
```nft
chain output {
type filter hook output priority filter; policy accept;
meta nfproto ipv4 accept
oifname "lo" accept
ct state established,related accept
ip6 daddr { ::/127, fc00::/7, fe80::/10, ff00::/8 } accept
udp sport 41642 accept
udp dport 41642 accept
udp dport 3478 accept
icmpv6 type {
destination-unreachable,
packet-too-big,
time-exceeded,
parameter-problem,
nd-router-solicit,
nd-router-advert,
nd-neighbor-solicit,
nd-neighbor-advert
} accept
meta nfproto ipv6 reject with icmpv6 type admin-prohibited
}
```
This policy means:
- Normal local IPv6 access fails.
- Local IPv4 is unaffected.
- Explicit Xray socks use can still proxy traffic.
- Tailscale IPv6 NAT traversal still works.
Verify:
```sh
curl -6 https://ifconfig.co/ip
curl -4 https://ifconfig.co/ip
curl -x socks5h://127.0.0.1:10808 https://ifconfig.co/ip
tailscale netcheck
```
Expected:
```text
curl -6 fails
curl -4 returns the home-broadband IPv4
socks returns the proxy exit IP
tailscale netcheck still says IPv6: yes
```
At first this step only allowed `41642`, and `tailscale netcheck` changed to `IPv6: no`. The reason is that netcheck also needs to reach STUN over UDP `3478`. Adding `udp dport 3478 accept` restored it.
## 7. Automatic ruleset updates, optional but enabled
This is not required for transparent proxying. Xray works without automatic updates. It solves a different problem: `geoip.dat` and `geosite.dat` get old, so domestic direct routing, ad blocking, and category rules slowly become less accurate.
The current script downloads Loyalsoldier release files. If you want to switch back to another ruleset source later, change the download URLs in the script. You do not need to rebuild TProxy, DNS, or Tailscale config.
New script:
```text
/usr/local/sbin/xray-update-geo-assets
```
New systemd units:
```text
/etc/systemd/system/xray-geo-update.service
/etc/systemd/system/xray-geo-update.timer
```
Schedule:
```text
Every Sunday at 04:00 CST
```
Check:
```sh
systemctl list-timers xray-geo-update.timer --no-pager
```
Script flow:
```mermaid
flowchart TD
start["timer triggered"]
dl["download geosite.dat and geoip.dat<br/>to a temp directory"]
same{"same as current files?"}
backup["back up current dat files"]
install["install new dat files"]
test["xray run -test"]
restart["restart xray"]
rollback["rollback on failure"]
done["done"]
start --> dl
dl --> same
same -->|yes| done
same -->|no| backup
backup --> install
install --> test
test -->|failed| rollback
test -->|passed| restart
restart -->|failed| rollback
restart -->|success| done
```
Manual validation:
```sh
sudo systemctl start xray-geo-update.service
systemctl status xray-geo-update.service --no-pager -l
```
## 8. Why there is no IPv6 to IPv4 conversion
This is an easy place to take the wrong turn.
NAT64 and DNS64 are ways for IPv6-only networks to access IPv4 services. They cannot magically turn an IPv6-only target into an IPv4 target. If a client directly accesses an IPv6 literal like `[2606:4700:4700::1111]`, DNS cannot rewrite it for you.
The problem here is that the upstream Xray proxy does not support IPv6 targets. The more reliable policy is:
```text
Do not give clients AAAA
Do not let exit IPv6 leak through direct
Let clients fall back to IPv4
Send IPv4 through Xray
```
This is not perfect. IPv6-only sites will not open. But it is more controlled than silently sending traffic out through the Raspberry Pi's home-broadband IPv6.
## 9. Validation checklist
After each change, check things in this order.
Services:
```sh
systemctl is-active xray xray-tproxy tailscaled xray-geo-update.timer
systemctl is-enabled xray xray-tproxy tailscaled xray-geo-update.timer
```
Xray config:
```sh
sudo env XRAY_LOCATION_ASSET=/usr/local/share/xray \
/usr/local/bin/xray run -test -config /etc/xray/config.json
```
Listening ports:
```sh
sudo ss -lntup | grep -E 'xray|tailscaled|:1041|:1051|:10808|:53|:41642'
```
nft rules:
```sh
sudo nft list table inet xray_tproxy
```
DNS:
```sh
dig @192.168.51.72 www.google.com A +short
dig @192.168.51.72 www.google.com AAAA +short
```
Tailscale:
```sh
tailscale netcheck
tailscale ping 100.89.253.91
tailscale status
```
Exit:
```sh
curl -6 https://ifconfig.co/ip
curl -4 https://ifconfig.co/ip
curl -x socks5h://127.0.0.1:10808 https://ifconfig.co/ip
```
Current expected result:
```text
curl -6 fails
curl -4 returns the Raspberry Pi's home-broadband IPv4
socks returns the Xray proxy exit
tailscale netcheck IPv6: yes
```
## 10. Rollback
All key files touched today have timestamped backups.
Xray config:
```text
/etc/xray/config.json.bak.*
```
Transparent proxy script:
```text
/usr/local/sbin/xray-tproxy-setup.bak.*
```
Tailscale port config:
```text
/etc/default/tailscaled.bak.*
```
Ruleset:
```text
/usr/local/share/xray/geosite.dat.bak.*
/usr/local/share/xray/geoip.dat.bak.*
```
Rollback example:
```sh
sudo cp -a /etc/xray/config.json.bak.YYYYMMDD-HHMMSS /etc/xray/config.json
sudo cp -a /usr/local/sbin/xray-tproxy-setup.bak.YYYYMMDD-HHMMSS /usr/local/sbin/xray-tproxy-setup
sudo env XRAY_LOCATION_ASSET=/usr/local/share/xray \
/usr/local/bin/xray run -test -config /etc/xray/config.json
sudo systemctl restart xray
sudo systemctl restart xray-tproxy
```
## 11. Boundaries of the current design
This setup is not meant to make the Raspberry Pi a full dual-stack proxy exit. It is a compromise:
- IPv6 is used to improve Tailscale NAT traversal.
- Internet exit still prefers IPv4.
- The upstream Xray proxy does not support IPv6, so public IPv6 exit traffic is rejected directly.
- DNS tries to return only IPv4, reducing the chance that clients try IPv6 first.
- If a client uses DoH or a hard-coded IPv6 address, it may still fail first.
This is where the setup landed today. It is not elegant, but the direction is clear: traffic that can be proxied goes through the proxy. Traffic that cannot be proxied should not silently go direct.
## Appendix: scripts and config snapshots
Below are sanitized snapshots exported from the Raspberry Pi on 2026-06-15. Their purpose is to make later troubleshooting easier: what files changed, what each script does, and how each service starts.
Two notes:
- Xray outbound proxy details have been sanitized, including server addresses, ports, UUIDs, encryption, flow, streamSettings, and related fields.
- Tailscale private keys, node IDs, user profiles, hostnames, and related fields have been sanitized.
These sanitized blocks are good for reading and review. They are not meant to be copied directly onto a machine and run.
Key file paths:
```text
/usr/local/sbin/xray-tproxy-setup
/usr/local/sbin/xray-tproxy-cleanup
/usr/local/sbin/xray-update-geo-assets
/etc/systemd/system/xray-tproxy.service
/etc/systemd/system/xray.service
/etc/systemd/system/xray-geo-update.service
/etc/systemd/system/xray-geo-update.timer
/etc/xray/config.json
/etc/default/tailscaled
/usr/lib/systemd/system/tailscaled.service
```
### `/usr/local/sbin/xray-tproxy-setup`
```sh
#!/bin/sh
set -eu
NFT=/usr/sbin/nft
TABLE=100
LAN_IFACE=eth0
LAN_CIDR=192.168.51.0/24
MARK_HEX=0x100
TPROXY_TCP_PORT=1041
TPROXY_UDP_PORT=1051
TPROXY_IP=127.0.0.1
NFT_FILE=/run/xray_tproxy.nft
modprobe nf_tables 2>/dev/null || true
modprobe nft_tproxy 2>/dev/null || true
modprobe nft_socket 2>/dev/null || true
modprobe nf_tproxy_ipv4 2>/dev/null || true
modprobe nf_tproxy_ipv6 2>/dev/null || true
modprobe nf_socket_ipv4 2>/dev/null || true
modprobe nf_socket_ipv6 2>/dev/null || true
sysctl -w net.ipv4.ip_forward=1 >/dev/null
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null
sysctl -w net.ipv4.conf.all.rp_filter=0 net.ipv4.conf.default.rp_filter=0 >/dev/null
sysctl -w net.ipv4.conf.all.route_localnet=1 net.ipv4.conf.default.route_localnet=1 >/dev/null
sysctl -w net.ipv4.conf.all.accept_local=1 net.ipv4.conf.default.accept_local=1 >/dev/null
sysctl -w net.ipv4.conf.all.send_redirects=0 net.ipv4.conf.default.send_redirects=0 >/dev/null
for iface in "$LAN_IFACE" tailscale0; do
[ -d "/proc/sys/net/ipv4/conf/$iface" ] || continue
sysctl -w "net.ipv4.conf.$iface.rp_filter=0" >/dev/null || true
sysctl -w "net.ipv4.conf.$iface.route_localnet=1" >/dev/null || true
sysctl -w "net.ipv4.conf.$iface.accept_local=1" >/dev/null || true
sysctl -w "net.ipv4.conf.$iface.send_redirects=0" >/dev/null || true
done
for chain in XRAY_TPROXY XRAY_LAN_TPROXY_TEST TPROXY_PROBE TPROXY_NS_PROBE XRAY_LAN_REDIR_MARK; do
while iptables -t mangle -D PREROUTING -i tailscale0 -j "$chain" 2>/dev/null; do :; done
while iptables -t mangle -D PREROUTING -i "$LAN_IFACE" -s "$LAN_CIDR" -j "$chain" 2>/dev/null; do :; done
while iptables -t mangle -D PREROUTING -i "$LAN_IFACE" -s 192.168.51.170/32 -j "$chain" 2>/dev/null; do :; done
while iptables -t mangle -D PREROUTING -i veth-tpx-host -s 10.200.0.2/32 -j "$chain" 2>/dev/null; do :; done
iptables -t mangle -F "$chain" 2>/dev/null || true
iptables -t mangle -X "$chain" 2>/dev/null || true
done
for chain in XRAY_LAN_REDIR; do
while iptables -t nat -D PREROUTING -i "$LAN_IFACE" -s "$LAN_CIDR" -j "$chain" 2>/dev/null; do :; done
iptables -t nat -F "$chain" 2>/dev/null || true
iptables -t nat -X "$chain" 2>/dev/null || true
done
for chain in XRAY_TPROXY_INPUT_ALLOW XRAY_LAN_FORWARD_ALLOW XRAY_TPROXY_INPUT_GUARD XRAY_TPROXY_OUTPUT_GUARD XRAY_TPROXY_GUARD; do
while iptables -D INPUT -j "$chain" 2>/dev/null; do :; done
while iptables -D OUTPUT -j "$chain" 2>/dev/null; do :; done
while iptables -D FORWARD -j "$chain" 2>/dev/null; do :; done
iptables -F "$chain" 2>/dev/null || true
iptables -X "$chain" 2>/dev/null || true
done
if command -v ip6tables >/dev/null 2>&1; then
for chain in XRAY_TPROXY6; do
while ip6tables -t mangle -D PREROUTING -i tailscale0 -j "$chain" 2>/dev/null; do :; done
ip6tables -t mangle -F "$chain" 2>/dev/null || true
ip6tables -t mangle -X "$chain" 2>/dev/null || true
done
fi
$NFT delete table inet xray_tproxy 2>/dev/null || true
$NFT delete table inet lan_xray_tproxy_test 2>/dev/null || true
$NFT delete table inet tproxy_probe 2>/dev/null || true
ip netns del tpxns 2>/dev/null || true
rm -f /tmp/tproxy_probe.py /tmp/tproxy_probe.log /tmp/tproxy_probe.pid 2>/dev/null || true
while ip rule del fwmark "$MARK_HEX"/"$MARK_HEX" table "$TABLE" 2>/dev/null; do :; done
while ip rule del fwmark 0x1 table "$TABLE" 2>/dev/null; do :; done
while ip rule del fwmark 0x1/0x1 table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark "$MARK_HEX"/"$MARK_HEX" table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark 0x1 table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark 0x1/0x1 table "$TABLE" 2>/dev/null; do :; done
ip rule add pref 5206 fwmark "$MARK_HEX"/"$MARK_HEX" table "$TABLE"
ip -6 rule add pref 5206 fwmark "$MARK_HEX"/"$MARK_HEX" table "$TABLE"
ip route replace local 0.0.0.0/0 dev lo table "$TABLE"
ip -6 route replace local ::/0 dev lo table "$TABLE"
cat > "$NFT_FILE" <<NFT_RULES
table inet xray_tproxy {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
iifname "tailscale0" jump proxy4
iifname "$LAN_IFACE" ip saddr $LAN_CIDR jump proxy4
iifname "tailscale0" meta nfproto ipv6 jump proxy6
}
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 $TPROXY_IP:$TPROXY_TCP_PORT meta mark set $MARK_HEX accept
meta l4proto udp tproxy ip to $TPROXY_IP:$TPROXY_UDP_PORT meta mark set $MARK_HEX accept
}
chain proxy6 {
ip6 daddr { ::/128, ::1/128, fc00::/7, fe80::/10, ff00::/8 } return
reject with icmpv6 type admin-prohibited
}
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
meta mark $MARK_HEX accept
iifname "tailscale0" accept
udp dport { 41641, 41642 } accept
tcp dport 22 accept
ip protocol icmp accept
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
udp sport 67 udp dport 68 accept
ip daddr 224.0.0.251 udp dport 5353 accept
ip daddr 239.255.255.250 udp dport 1900 accept
ip saddr 192.168.0.0/16 accept
ip saddr 100.64.0.0/10 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "$LAN_IFACE" oifname "$LAN_IFACE" ip saddr $LAN_CIDR accept
iifname "tailscale0" oifname "$LAN_IFACE" accept
iifname "$LAN_IFACE" oifname "tailscale0" accept
iifname "docker0" accept
oifname "docker0" ct state established,related accept
}
chain output {
type filter hook output priority filter; policy accept;
meta nfproto ipv4 accept
oifname "lo" accept
ct state established,related accept
ip6 daddr { ::/127, fc00::/7, fe80::/10, ff00::/8 } accept
udp sport 41642 accept
udp dport 41642 accept
udp dport 3478 accept
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
meta nfproto ipv6 reject with icmpv6 type admin-prohibited
}
}
NFT_RULES
$NFT -c -f "$NFT_FILE"
$NFT -f "$NFT_FILE"
```
### `/usr/local/sbin/xray-tproxy-cleanup`
```sh
#!/bin/sh
set -u
NFT=/usr/sbin/nft
TABLE=100
$NFT delete table inet xray_tproxy 2>/dev/null || true
while ip rule del fwmark 0x100/0x100 table "$TABLE" 2>/dev/null; do :; done
while ip rule del fwmark 0x1/0x1 table "$TABLE" 2>/dev/null; do :; done
while ip rule del fwmark 0x1 table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark 0x100/0x100 table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark 0x1/0x1 table "$TABLE" 2>/dev/null; do :; done
while ip -6 rule del fwmark 0x1 table "$TABLE" 2>/dev/null; do :; done
ip route flush table "$TABLE" 2>/dev/null || true
ip -6 route flush table "$TABLE" 2>/dev/null || true
```
### `/usr/local/sbin/xray-update-geo-assets`
```sh
#!/bin/sh
set -eu
ASSET_DIR="${XRAY_LOCATION_ASSET:-/usr/local/share/xray}"
XRAY_BIN="${XRAY_BIN:-/usr/local/bin/xray}"
XRAY_CONFIG="${XRAY_CONFIG:-/etc/xray/config.json}"
BASE_URL="https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download"
LOCK_FILE="/run/xray-update-geo-assets.lock"
log() {
printf "%s %s\n" "$(date '+%Y-%m-%d %H:%M:%S%z')" "$*"
}
if [ "$(id -u)" -ne 0 ]; then
log "must run as root"
exit 1
fi
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "another update is already running"
exit 0
fi
fi
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
log "missing command: $1"
exit 1
}
}
need_cmd curl
need_cmd install
need_cmd sha256sum
need_cmd systemctl
[ -x "$XRAY_BIN" ] || { log "xray binary not executable: $XRAY_BIN"; exit 1; }
[ -f "$XRAY_CONFIG" ] || { log "xray config not found: $XRAY_CONFIG"; exit 1; }
install -d -m 0755 "$ASSET_DIR"
tmpdir=$(mktemp -d)
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT INT TERM
fetch_asset() {
name="$1"
url="$BASE_URL/$name"
log "downloading $url"
curl -fsSL --connect-timeout 20 --retry 4 --retry-delay 5 --speed-limit 1024 --speed-time 60 -o "$tmpdir/$name" "$url"
[ -s "$tmpdir/$name" ] || { log "downloaded file is empty: $name"; exit 1; }
}
fetch_asset geosite.dat
fetch_asset geoip.dat
if [ -f "$ASSET_DIR/geosite.dat" ] && [ -f "$ASSET_DIR/geoip.dat" ] \
&& cmp -s "$tmpdir/geosite.dat" "$ASSET_DIR/geosite.dat" \
&& cmp -s "$tmpdir/geoip.dat" "$ASSET_DIR/geoip.dat"; then
log "geo assets already up to date"
sha256sum "$ASSET_DIR/geosite.dat" "$ASSET_DIR/geoip.dat"
exit 0
fi
ts=$(date +%Y%m%d-%H%M%S)
geosite_bak="$ASSET_DIR/geosite.dat.bak.$ts"
geoip_bak="$ASSET_DIR/geoip.dat.bak.$ts"
restore_assets() {
[ -f "$geosite_bak" ] && cp -a "$geosite_bak" "$ASSET_DIR/geosite.dat"
[ -f "$geoip_bak" ] && cp -a "$geoip_bak" "$ASSET_DIR/geoip.dat"
}
[ -f "$ASSET_DIR/geosite.dat" ] && cp -a "$ASSET_DIR/geosite.dat" "$geosite_bak"
[ -f "$ASSET_DIR/geoip.dat" ] && cp -a "$ASSET_DIR/geoip.dat" "$geoip_bak"
install -o root -g root -m 0644 "$tmpdir/geosite.dat" "$ASSET_DIR/geosite.dat"
install -o root -g root -m 0644 "$tmpdir/geoip.dat" "$ASSET_DIR/geoip.dat"
export XRAY_LOCATION_ASSET="$ASSET_DIR"
if ! "$XRAY_BIN" run -test -config "$XRAY_CONFIG"; then
log "xray config test failed after geo update; restoring backups"
restore_assets
exit 1
fi
if systemctl is-active --quiet xray; then
if ! systemctl restart xray; then
log "xray restart failed; restoring backups"
restore_assets
systemctl restart xray || true
exit 1
fi
fi
find "$ASSET_DIR" -name "geosite.dat.bak.*" -type f -mtime +90 -delete 2>/dev/null || true
find "$ASSET_DIR" -name "geoip.dat.bak.*" -type f -mtime +90 -delete 2>/dev/null || true
log "geo assets updated"
sha256sum "$ASSET_DIR/geosite.dat" "$ASSET_DIR/geoip.dat"
log "backups: $geosite_bak $geoip_bak"
```
### systemd units
`/etc/systemd/system/xray-tproxy.service`:
```ini
[Unit]
Description=Xray iptables TPROXY rules for Tailscale exit traffic
After=network-online.target tailscaled.service xray.service
Wants=network-online.target tailscaled.service
Requires=xray.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/xray-tproxy-setup
ExecStop=/usr/local/sbin/xray-tproxy-cleanup
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/xray.service`:
```ini
[Unit]
Description=Xray proxy service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=xray_tproxy
Environment=XRAY_LOCATION_ASSET=/usr/local/share/xray
ExecStart=/usr/local/bin/xray run -config /etc/xray/config.json
Restart=on-failure
RestartSec=3
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/xray-geo-update.service`:
```ini
[Unit]
Description=Update Xray geosite and geoip data from Loyalsoldier
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
Environment=XRAY_LOCATION_ASSET=/usr/local/share/xray
ExecStart=/usr/local/sbin/xray-update-geo-assets
```
`/etc/systemd/system/xray-geo-update.timer`:
```ini
[Unit]
Description=Weekly Xray geosite and geoip data update
[Timer]
OnCalendar=Sun *-*-* 04:00:00
AccuracySec=1m
Persistent=true
Unit=xray-geo-update.service
[Install]
WantedBy=timers.target
```
### sanitized `/etc/xray/config.json`
Outbound proxy details have been sanitized, so this JSON is only useful for checking structure and rule order.
```json
{
"log": {
"loglevel": "warning",
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log"
},
"policy": {
"levels": {
"0": {
"statsUserUplink": false,
"statsUserDownlink": false
}
}
},
"inbounds": [
{
"port": 10808,
"protocol": "socks",
"sniffing": {
"enabled": true,
"routeOnly": true,
"domainsExcluded": [
"courier.push.apple.com",
"rbsxbxp-mim.vivox.com",
"rbsxbxp.www.vivox.com",
"rbsxbxp-ws.vivox.com",
"rbspsxp.www.vivox.com",
"rbspsxp-mim.vivox.com",
"rbspsxp-ws.vivox.com",
"rbswxp.www.vivox.com",
"rbswxp-mim.vivox.com",
"disp-rbspsp-5-1.vivox.com",
"disp-rbsxbp-5-1.vivox.com",
"proxy.rbsxbp.vivox.com",
"proxy.rbspsp.vivox.com",
"proxy.rbswp.vivox.com",
"rbswp.vivox.com",
"rbsxbp.vivox.com",
"rbspsp.vivox.com",
"rbspsp.www.vivox.com",
"rbswp.www.vivox.com",
"rbsxbp.www.vivox.com",
"rbsxbxp.vivox.com",
"rbspsxp.vivox.com",
"rbswxp.vivox.com",
"Mijia Cloud",
"dlg.io.mi.com",
"marscdn.c2c.wechat.com",
"domain:argotunnel.com",
"domain:cftunnel.com",
"domain:cloudflareaccess.com",
"domain:cloudflareresearch.com",
"full:api.cloudflare.com"
],
"destOverride": [
"http",
"tls",
"quic"
]
},
"settings": {
"udp": true,
"auth": "noauth"
},
"listen": "0.0.0.0",
"tag": "socks-in"
},
{
"tag": "tproxy-in",
"port": 1041,
"protocol": "tunnel",
"settings": {
"allowedNetwork": "tcp,udp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"routeOnly": true,
"domainsExcluded": [
"domain:argotunnel.com",
"domain:cftunnel.com",
"domain:cloudflareaccess.com",
"domain:cloudflareresearch.com",
"full:api.cloudflare.com"
],
"destOverride": [
"http",
"tls",
"quic"
]
},
"streamSettings": {
"sockopt": {
"tproxy": "tproxy"
}
},
"listen": "127.0.0.1"
},
{
"tag": "udp-redir",
"listen": "127.0.0.1",
"port": 1051,
"protocol": "dokodemo-door",
"settings": {
"network": "udp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"routeOnly": true,
"domainsExcluded": [
"domain:argotunnel.com",
"domain:cftunnel.com",
"domain:cloudflareaccess.com",
"domain:cloudflareresearch.com",
"full:api.cloudflare.com"
],
"destOverride": [
"quic"
]
},
"streamSettings": {
"sockopt": {
"tproxy": "tproxy"
}
}
},
{
"tag": "dns-in",
"listen": "192.168.51.72",
"port": 53,
"protocol": "dokodemo-door",
"settings": {
"address": "1.1.1.1",
"port": 53,
"network": "tcp,udp"
}
}
],
"outbounds": [
{
"mux": {
"enabled": false
},
"protocol": "vless",
"settings": {
"vnext": [
{
"port": "REDACTED",
"users": [
{
"encryption": "REDACTED",
"id": "REDACTED",
"level": 0,
"flow": "REDACTED"
}
],
"address": "REDACTED"
}
]
},
"streamSettings": "REDACTED",
"tag": "default:vless-enc-openwrt"
},
{
"streamSettings": {
"sockopt": {}
},
"settings": {
"domainStrategy": "UseIP"
},
"protocol": "freedom",
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "blackhole"
},
{
"mux": {
"enabled": false
},
"protocol": "vless",
"settings": {
"vnext": [
{
"port": "REDACTED",
"users": [
{
"encryption": "REDACTED",
"id": "REDACTED",
"level": 0,
"flow": "REDACTED"
}
],
"address": "REDACTED"
}
]
},
"streamSettings": "REDACTED",
"tag": "pNeL0qJ1:vless-enc-openwrt"
},
{
"tag": "dns-out",
"protocol": "dns",
"settings": {}
}
],
"routing": {
"rules": [
{
"type": "field",
"inboundTag": [
"dns-in"
],
"outboundTag": "dns-out"
},
{
"type": "field",
"port": 53,
"network": "tcp,udp",
"outboundTag": "dns-out"
},
{
"type": "field",
"inboundTag": [
"dns-local"
],
"outboundTag": "direct"
},
{
"type": "field",
"domain": [
"geosite:category-games@cn"
],
"outboundTag": "direct"
},
{
"type": "field",
"domain": [
"geosite:category-ads-all"
],
"outboundTag": "blackhole"
},
{
"type": "field",
"ip": [
"geoip:private"
],
"outboundTag": "direct"
},
{
"type": "field",
"ip": [
"geoip:cn"
],
"outboundTag": "direct"
},
{
"type": "field",
"domain": [
"geosite:cn"
],
"outboundTag": "direct"
},
{
"type": "field",
"port": 123,
"network": "udp",
"outboundTag": "direct"
}
],
"domainStrategy": "IPIfNonMatch",
"domainMatcher": "mph"
},
"dns": {
"servers": [
"localhost"
],
"queryStrategy": "UseIPv4",
"useSystemHosts": true,
"tag": "dns-local"
}
}
```
### current full nft rules snapshot
Export command: `sudo nft list ruleset`. This includes Docker and Tailscale auto-generated tables, plus the manually maintained `inet xray_tproxy` table.
```nft
# Warning: table ip filter is managed by iptables-nft, do not touch!
# Warning: table ip nat is managed by iptables-nft, do not touch!
# Warning: table ip6 filter is managed by iptables-nft, do not touch!
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
# Warning: XT target MASQUERADE not found
# Warning: table ip mangle is managed by iptables-nft, do not touch!
# Warning: table ip6 mangle is managed by iptables-nft, do not touch!
table ip filter {
chain DOCKER {
iifname != "docker0" oifname "docker0" counter packets 0 bytes 0 drop
}
chain DOCKER-FORWARD {
counter packets 9 bytes 528 jump DOCKER-CT
counter packets 9 bytes 528 jump DOCKER-INTERNAL
counter packets 9 bytes 528 jump DOCKER-BRIDGE
iifname "docker0" counter packets 0 bytes 0 accept
}
chain DOCKER-BRIDGE {
oifname "docker0" counter packets 0 bytes 0 jump DOCKER
}
chain DOCKER-CT {
oifname "docker0" ct state related,established counter packets 0 bytes 0 accept
}
chain DOCKER-INTERNAL {
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
counter packets 7439 bytes 4847002 jump ts-forward
counter packets 9 bytes 528 jump DOCKER-USER
counter packets 9 bytes 528 jump DOCKER-FORWARD
}
chain DOCKER-USER {
}
chain INPUT {
type filter hook input priority filter; policy accept;
counter packets 105296 bytes 148263055 jump ts-input
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
chain ts-input {
ip saddr 100.76.137.29 iifname "lo" counter packets 0 bytes 0 accept
iifname "tailscale0" counter packets 3121 bytes 441272 accept
udp dport 41642 counter packets 5252 bytes 1309884 accept
ip saddr 100.115.92.0/23 iifname != "tailscale0" counter packets 0 bytes 0 return
ip saddr 100.64.0.0/10 iifname != "tailscale0" counter packets 0 bytes 0 drop
}
chain ts-forward {
iifname "tailscale0" counter packets 4512 bytes 345211 meta mark set mark and 0xff00ffff xor 0x40000
meta mark & 0x00ff0000 == 0x00040000 counter packets 4512 bytes 345211 accept
ip saddr 100.64.0.0/10 oifname "tailscale0" counter packets 0 bytes 0 drop
oifname "tailscale0" counter packets 2927 bytes 4501791 accept
}
}
table ip nat {
chain DOCKER {
}
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
fib daddr type local counter packets 3263419 bytes 203092044 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip daddr != 127.0.0.0/8 fib daddr type local counter packets 404 bytes 19672 jump DOCKER
}
chain POSTROUTING {
type nat hook postrouting priority srcnat; policy accept;
counter packets 6101 bytes 421744 jump ts-postrouting
ip saddr 172.17.0.0/16 oifname != "docker0" counter packets 0 bytes 0 masquerade
}
chain ts-postrouting {
meta mark & 0x00ff0000 == 0x00040000 counter packets 217 bytes 13020 masquerade
}
}
table ip6 filter {
chain DOCKER {
}
chain DOCKER-FORWARD {
counter packets 0 bytes 0 jump DOCKER-CT
counter packets 0 bytes 0 jump DOCKER-INTERNAL
counter packets 0 bytes 0 jump DOCKER-BRIDGE
}
chain DOCKER-BRIDGE {
}
chain DOCKER-CT {
}
chain DOCKER-INTERNAL {
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
counter packets 0 bytes 0 jump ts-forward
counter packets 0 bytes 0 jump DOCKER-USER
counter packets 0 bytes 0 jump DOCKER-FORWARD
}
chain DOCKER-USER {
}
chain INPUT {
type filter hook input priority filter; policy accept;
counter packets 8448 bytes 1239723 jump ts-input
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
chain ts-input {
ip6 saddr fd7a:115c:a1e0::6039:891d iifname "lo" counter packets 0 bytes 0 accept
iifname "tailscale0" counter packets 0 bytes 0 accept
udp dport 41642 counter packets 2707 bytes 485006 accept
}
chain ts-forward {
iifname "tailscale0" counter packets 0 bytes 0 meta mark set mark and 0xff00ffff xor 0x40000
meta mark & 0x00ff0000 == 0x00040000 counter packets 0 bytes 0 accept
oifname "tailscale0" counter packets 0 bytes 0 accept
}
}
table ip6 nat {
chain DOCKER {
}
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
fib daddr type local counter packets 140 bytes 21210 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip6 daddr != ::1 fib daddr type local counter packets 18 bytes 7146 jump DOCKER
}
chain POSTROUTING {
type nat hook postrouting priority srcnat; policy accept;
counter packets 1781 bytes 167791 jump ts-postrouting
}
chain ts-postrouting {
meta mark & 0x00ff0000 == 0x00040000 counter packets 0 bytes 0 xt target "MASQUERADE"
}
}
table ip mangle {
chain PREROUTING {
type filter hook prerouting priority mangle; policy accept;
ct state related,established counter packets 80604 bytes 141020574 meta mark set ct mark and 0xff0000
}
chain OUTPUT {
type route hook output priority mangle; policy accept;
ct state new meta mark & 0x00ff0000 != 0x00000000 counter packets 19903 bytes 1275821 ct mark set mark and 0xff0000
}
}
table ip6 mangle {
chain PREROUTING {
type filter hook prerouting priority mangle; policy accept;
ct state related,established counter packets 7332 bytes 1120179 meta mark set ct mark and 0xff0000
}
chain OUTPUT {
type route hook output priority mangle; policy accept;
ct state new meta mark & 0x00ff0000 != 0x00000000 counter packets 5669 bytes 475008 ct mark set mark and 0xff0000
}
}
table ip raw {
chain PREROUTING {
type filter hook prerouting priority raw; policy accept;
}
}
table inet xray_tproxy {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
iifname "tailscale0" jump proxy4
iifname "eth0" ip saddr 192.168.51.0/24 jump proxy4
iifname "tailscale0" meta nfproto ipv6 jump proxy6
}
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
}
chain proxy6 {
ip6 daddr { ::/127, fc00::/7, fe80::/10, ff00::/8 } return
reject with icmpv6 admin-prohibited
}
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
meta mark 0x00000100 accept
iifname "tailscale0" accept
udp dport { 41641, 41642 } accept
tcp dport 22 accept
ip protocol icmp accept
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
udp sport 67 udp dport 68 accept
ip daddr 224.0.0.251 udp dport 5353 accept
ip daddr 239.255.255.250 udp dport 1900 accept
ip saddr 192.168.0.0/16 accept
ip saddr 100.64.0.0/10 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "eth0" oifname "eth0" ip saddr 192.168.51.0/24 accept
iifname "tailscale0" oifname "eth0" accept
iifname "eth0" oifname "tailscale0" accept
iifname "docker0" accept
oifname "docker0" ct state established,related accept
}
chain output {
type filter hook output priority filter; policy accept;
meta nfproto ipv4 accept
oifname "lo" accept
ct state established,related accept
ip6 daddr { ::/127, fc00::/7, fe80::/10, ff00::/8 } accept
udp sport 41642 accept
udp dport 41642 accept
udp dport 3478 accept
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
reject with icmpv6 admin-prohibited
}
}
```
### Tailscale config
`/etc/default/tailscaled`:
```sh
# Set the port to listen on for incoming VPN packets.
# Remote nodes will automatically be informed about the new port number,
# but you might want to configure this in order to set external firewall
# settings.
PORT=41642
# Extra flags you might want to pass to tailscaled.
FLAGS=""
```
`/usr/lib/systemd/system/tailscaled.service`:
```ini
[Unit]
Description=Tailscale node agent
Documentation=https://tailscale.com/docs/
Wants=network-pre.target
After=network-pre.target NetworkManager.service systemd-resolved.service
[Service]
EnvironmentFile=/etc/default/tailscaled
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=${PORT} $FLAGS
ExecStopPost=/usr/sbin/tailscaled --cleanup
Restart=on-failure
RuntimeDirectory=tailscale
RuntimeDirectoryMode=0755
StateDirectory=tailscale
StateDirectoryMode=0700
CacheDirectory=tailscale
CacheDirectoryMode=0750
Type=notify
[Install]
WantedBy=multi-user.target
```
Sanitized `tailscale debug prefs`:
```json
{
"ControlURL": "https://controlplane.tailscale.com",
"RouteAll": false,
"ExitNodeID": "",
"ExitNodeIP": "",
"InternalExitNodePrior": "",
"ExitNodeAllowLANAccess": false,
"CorpDNS": true,
"RunSSH": false,
"RunWebClient": false,
"WantRunning": true,
"LoggedOut": false,
"ShieldsUp": false,
"AdvertiseTags": null,
"Hostname": "REDACTED",
"NotepadURLs": false,
"AdvertiseRoutes": [
"0.0.0.0/0",
"::/0",
"192.168.51.0/24"
],
"AdvertiseServices": null,
"Sync": null,
"NoSNAT": false,
"NoStatefulFiltering": true,
"NetfilterMode": 2,
"AutoUpdate": {
"Check": true,
"Apply": true
},
"AppConnector": {
"Advertise": false
},
"PostureChecking": false,
"NetfilterKind": "",
"DriveShares": null,
"AllowSingleHosts": true,
"Config": {
"PrivateNodeKey": "REDACTED",
"OldPrivateNodeKey": "REDACTED",
"UserProfile": "REDACTED",
"NetworkLockKey": "REDACTED",
"NodeID": "REDACTED"
}
}
```
Comments