Skip to content

Instantly share code, notes, and snippets.

@NicholasClooney
Created July 14, 2026 06:57
Show Gist options
  • Select an option

  • Save NicholasClooney/fb3fb65955b42fafd1da8e632d75678c to your computer and use it in GitHub Desktop.

Select an option

Save NicholasClooney/fb3fb65955b42fafd1da8e632d75678c to your computer and use it in GitHub Desktop.
Companion snippets: GL.iNet Slate 7, Tailscale exit node, and China split-routing (blog.clooney.io)
# Before: Tailscale's exit-node rule wins before PBR marks are evaluated.
# Any packet -- even ones GL.iNet/PBR meant to send direct -- is grabbed
# by table 52 and sent out tailscale0.
5270: from all lookup 52 # Tailscale exit node
6000: from all fwmark 0x8000/0xf000 lookup main
30000: from all fwmark 0x10000/0xff0000 lookup pbr_wan
30001: from all fwmark 0x20000/0xff0000 lookup pbr_secondwan
30002: from all fwmark 0x30000/0xff0000 lookup pbr_wwan
# After: early PBR rules (5261-5263) run before Tailscale's 5270.
# China-marked packets take pbr_wan/pbr_wwan and go out the physical uplink;
# everything else falls through to 5270 and rides the exit node.
5261: from all fwmark 0x10000/0xff0000 lookup pbr_wan
5262: from all fwmark 0x20000/0xff0000 lookup pbr_secondwan
5263: from all fwmark 0x30000/0xff0000 lookup pbr_wwan
5270: from all lookup 52 # Tailscale exit node
#!/bin/sh
# Router-level Tailscale exit-node control on the Slate 7.
#
# --exit-node-allow-lan-access: LAN clients (192.168.8.0/24) can still
# reach the router and each other while their default route is the
# exit node.
# --accept-dns=false: do NOT let Tailscale rewrite the router's DNS.
# dnsmasq stays authoritative so the China split-DNS keeps working.
# Enable
tailscale set \
--exit-node=debian \
--exit-node-allow-lan-access \
--accept-dns=false
# Disable
tailscale set --exit-node= --accept-dns=false
# Safety net while testing remotely: auto-disable after 3 minutes
# in case the exit node breaks routing and takes SSH with it.
sleep 180 && tailscale set --exit-node= --accept-dns=false &
#!/bin/sh
# Make LAN clients able to egress through the router's Tailscale exit node.
# Two things are needed:
# 1. A forwarding rule allowing lan -> tailscale0.
# 2. MASQUERADE on the tailscale0 zone so return packets come back
# to the router's tailnet IP, not the LAN client's 192.168.8.x.
# 1. Permanent forwarding entry lan -> tailscale0
uci add firewall forwarding
uci set firewall.@forwarding[-1].src='lan'
uci set firewall.@forwarding[-1].dest='tailscale0'
uci set firewall.@forwarding[-1].name='lan_tailscale'
uci commit firewall
# 2. tailscale0 zone with MASQUERADE
# (usually already present from the GL.iNet tailscale package; shown for reference)
#
# firewall.tailscale0.name='tailscale0'
# firewall.tailscale0.device='tailscale0'
# firewall.tailscale0.input='ACCEPT'
# firewall.tailscale0.output='ACCEPT'
# firewall.tailscale0.forward='REJECT'
# firewall.@nat[0].name='Tailscale NAT'
# firewall.@nat[0].src='tailscale0'
# firewall.@nat[0].target='MASQUERADE'
/etc/init.d/firewall reload
# GL.iNet's vpn_table stamps every unmarked LAN packet with 0x8000.
# This is what caused the "LAN clients can't reach Google/YouTube even
# though the router itself works fine" bug.
#
# Router-origin traffic goes through LOCAL_POLICY (not shown), which
# only marks packets from skgid 10000 -- that is why the router itself
# was unaffected and reached the exit node normally.
table inet vpn_table {
chain PREROUTING {
iifname "br-*" jump ROUTE_POLICY
}
chain ROUTE_POLICY {
jump TUNNEL100_ROUTE_POLICY
}
chain TUNNEL100_ROUTE_POLICY {
# If the low nibble of fwmark bits 0x0000f000 is zero, OR in 0x8000.
# Result: every unmarked LAN packet gets 0x8000.
meta mark & 0x0000f000 == 0x00000000 \
meta mark set meta mark & 0xffff8fff | 0x00008000
}
}
# The symptom: LAN client 192.168.8.128 tries to reach a Google IP.
# The reply direction shows dst=192.168.0.109, which is the router's
# sta0 (physical uplink) IP, not 100.106.152.123 (its tailnet IP).
#
# Translation: the LAN packet was MASQUERADEd out sta0 by the physical
# uplink instead of tailscale0. The Chinese ISP then either dropped or
# reset the return, and the TCP handshake never completed.
tcp SYN_SENT src=192.168.8.128 dst=142.251.218.97 sport=62497 dport=443
[UNREPLIED] src=142.251.218.97 dst=192.168.0.109 sport=443 dport=62497
# Root cause:
# - GL.iNet's vpn_table stamps all LAN packets with 0x8000.
# - An early IP rule installed by pbr.user.china (pref 5260) matched
# fwmark 0x8000/0xf000 and pointed at `main`, which is the physical
# uplink's default route.
# - So every LAN packet was hijacked to sta0 *before* Tailscale's
# pref 5270 got a chance to route it via tailscale0.
#
# Fix: stop installing pref 5260. The China marks (0x10000/0x20000/0x30000)
# handled by 5261/5262/5263 are enough; the 0x8000 catch-all did more
# harm than good.
# /etc/dnsmasq.d/accelerated-domains.china.conf (excerpt)
#
# Concept:
# - CN domains (from felixonmars/dnsmasq-china-list, ~111k suffixes)
# resolve via AliDNS 223.5.5.5, which is reached DIRECTLY through
# the physical uplink. Chinese CDNs and geo-fenced services return
# nearby POP IPs this way, so Taobao/WeChat/banking stay fast.
#
# - Everything else resolves via stubby DoT (127.0.0.1#5453) which
# forwards to Cloudflare 1.1.1.1. Stubby's traffic rides the
# Tailscale exit node, bypassing ISP DNS poisoning of foreign names.
#
# 223.5.5.5 itself sits inside the China CIDR set, so the PBR mark
# 0x30000 is applied to it at output and no extra rule is needed.
# CN suffix list (excerpt of ~111k lines)
server=/taobao.com/223.5.5.5
server=/tmall.com/223.5.5.5
server=/qq.com/223.5.5.5
server=/weixin.qq.com/223.5.5.5
server=/bilibili.com/223.5.5.5
server=/alipay.com/223.5.5.5
# ... plus tens of thousands more from dnsmasq-china-list
# Direct-domain allowlist (foreign domains that break through the exit
# node -- Apple/iCloud/game backends). Also resolved via AliDNS so the
# returned IPs match a nearby POP.
server=/apple.com/223.5.5.5
server=/icloud.com/223.5.5.5
server=/mzstatic.com/223.5.5.5
server=/tplinkcloud.com/223.5.5.5
server=/orerve.net/223.5.5.5
server=/zaonce.net/223.5.5.5
# Default resolver for everything else -> stubby DoT -> Cloudflare
# (configured elsewhere in dnsmasq via `server=127.0.0.1#5453`)
#!/bin/sh
# Toggle the router in and out of "in China" split-routing mode.
## Leaving China: disable the split
tailscale set --exit-node= --accept-dns=false
uci set pbr.@include[2].enabled='0' # verify index matches pbr.user.china
uci commit pbr
service pbr reload
## Returning to China: re-enable the split
uci set pbr.@include[2].enabled='1'
uci commit pbr
service pbr reload
tailscale set --exit-node=debian \
--exit-node-allow-lan-access \
--accept-dns=false
## Verify the early rules survived the reload and still land before pref 5270
ip rule show | grep -E "^(52[6-9][0-9]|5270):"
## Quick smoke test
ip route get 1.1.1.1 # expect: dev tailscale0 table 52
ip route get 223.5.5.5 mark 0x30000 # expect: dev sta0 table pbr_wwan
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment