A separate subnet on OpenWrt, bound to its own SSID, with source-based policy routing that sends the entire subnet to a mihomo TUN gateway sitting on a different subnet. Clients configure nothing: join the SSID and traffic is proxied, switch back to the main WiFi and it is direct again. There is one well-hidden trap at the end — ImmortalWRT’s DNS redirect.
The gateway itself is built in the previous post: Running mihomo as a LAN gateway with Docker macvlan.
The goal
I already have mihomo running as a TUN-mode gateway, and devices that want it point their gateway and DNS at it. Doing that per device is tedious, and family devices are not something I want to reconfigure at all. What I actually want:
One dedicated SSID. Join it and everything goes through mihomo. Leave it and everything is direct. Nothing set on the client.
The topology:
┌──────────────────────────────┐
│ OpenWrt (ImmortalWRT) │
main WiFi ──── │ br-lan 10.0.0.1/24 │ ──── WAN
proxy WiFi ──── │ lan_hkt 192.168.88.1/24 │
└──────────────┬───────────────┘
│ 10.0.0.0/24
┌─────────┴─────────┐
│ N95 mini PC │
│ mihomo (macvlan) │
│ 10.0.0.6 TUN │
└───────────────────┘
mihomo runs in Docker on the N95 and holds 10.0.0.6 via macvlan — an address in the 10 subnet. The new WiFi is bound to 192.168.88.0/24.
Why you can’t just set the gateway to 10.0.0.6
The obvious first attempt is to hand out 10.0.0.6 as the gateway over DHCP on the 88 subnet, at which point clients lose the network entirely. The reason is basic but easy to overlook: a gateway has to be on the client’s own subnet. To use an address as a gateway the client must first ARP for it locally, and 10.0.0.6 is in a different broadcast domain, so the request never leaves the subnet. The gateway is unreachable, DNS points at the same address, and nothing works.
Two ways out:
- Give the container a second leg. Trunk the 88 subnet to the N95 over VLAN, add a second macvlan network, and let mihomo also hold
192.168.88.6. Cleanest result, but it touches the switch config, the host and the container. - Do source-based policy routing on OpenWrt. Clients keep
192.168.88.1as their gateway, and OpenWrt forwards anything sourced from the 88 subnet to10.0.0.6. One place to change, nothing to touch on the client or in mihomo.
Traffic to a gateway like this already crosses the N95’s cable twice anyway; the extra hop in option 2 is irrelevant on a router. I went with option 2.
1. Create the interface and bind an SSID
New interface in /etc/config/network:
config interface 'lan_hkt'
option proto 'static'
option ipaddr '192.168.88.1'
option netmask '255.255.255.0'
Then a wifi-iface in /etc/config/wireless. The SSID is up to you; what matters is that network points at the new interface:
config wifi-iface 'wifinet_hkt'
option device 'radio1'
option mode 'ap'
option ssid 'MyWiFi-HK'
option encryption 'sae-mixed'
option key 'your-password'
option network 'lan_hkt'
2. Policy routing: send the whole subnet to mihomo
Append to /etc/config/network. The idea is a routing table numbered 100, consulted only by packets sourced from the 88 subnet:
config route
option interface 'lan'
option target '10.0.0.0/24'
option table '100'
config route
option interface 'lan_hkt'
option target '192.168.88.0/24'
option table '100'
config route
option interface 'lan'
option target '0.0.0.0/0'
option gateway '10.0.0.6'
option table '100'
config rule
option src '192.168.88.0/24'
option lookup '100'
option priority '100'
The first two are connected routes, so the 88 subnet can still reach the 10 subnet (DNS, the mihomo dashboard) and itself without being pushed into the proxy. The third is the point of the whole exercise: a default route via 10.0.0.6. The config rule keeps the table scoped to that one source subnet, so the main network is untouched.
3. DHCP: DNS at mihomo, IPv6 off
In /etc/config/dhcp:
config dhcp 'lan_hkt'
option interface 'lan_hkt'
option start '100'
option limit '150'
option leasetime '12h'
list dhcp_option '6,10.0.0.6'
option ra 'disabled'
option dhcpv6 'disabled'
dhcp_option '6,...' points clients straight at mihomo for DNS, which is what lets its fake-ip mode see the domain names and route on them.
IPv6 has to go. If your ISP gives out public IPv6, a client that picks up a v6 address will prefer it and go out directly, and the whole setup is decoration. This subnet is IPv4 only. While you’re there, make sure lan_hkt has no option ip6assign.
4. Firewall: same zone, no masquerade
Add the new interface to the lan zone in /etc/config/firewall:
config zone
option name 'lan'
list network 'lan'
list network 'lan_hkt'
...
Forwarding within a zone is allowed by default, which is what lets packets from the 88 subnet reach 10.0.0.6 and the replies come back.
Also check that the lan zone does not have option masq set (LuCI calls it “IP dynamic masquerading”). With it on, OpenWrt rewrites the source address of forwarded traffic to its own, and every device shows up as 10.0.0.1 in the mihomo log. It won’t break connectivity today, but it destroys any chance of routing by source IP or subnet later. This design returns traffic by plain routing and needs no NAT.
Once everything is in place:
/etc/init.d/network reload
/etc/init.d/dnsmasq restart
/etc/init.d/odhcpd restart
/etc/init.d/firewall reload
The trap: ImmortalWRT’s DNSMASQ HIJACK
In theory that’s it. In practice, joining the new WiFi gave me a working connection where every blocked site stayed blocked. The mihomo log was full of this:
[TCP] dial Other (match Match/) 10.0.0.1:58272 --> 185.45.5.35:443 error: i/o timeout
[UDP] 10.0.0.1:57830 --> 157.240.7.20:443 match Match using Other[DIRECT]
Every destination is a real IP rather than a 198.18.x fake-ip, which means the DNS resolution never went through mihomo. All it received was a bare address, no domain rule could match, and everything fell through to DIRECT — straight at a blocked IP, then a timeout.
(About the source addresses in that log: those lines were captured before masquerading was disabled on the lan zone, so the router had rewritten the clients’ source IP to its own 10.0.0.1. With step 4 applied you see the real 192.168.88.x addresses instead.)
The odd part was that nslookup facebook.com on the client clearly showed 10.0.0.6 as the DNS server, yet returned 185.45.5.35 — a textbook poisoned answer. Querying 10.0.0.6 from a device on the 10 subnet, on the same layer 2 as mihomo, returned a normal fake-ip.
The only difference: DNS packets from the 88 subnet have to be forwarded by the router. Looking at nftables:
nft list ruleset | grep -B2 'dport 53'
chain prerouting {
type nat hook prerouting priority dstnat + 5; policy accept;
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :53 comment "DNSMASQ HIJACK"
There it is. This is ImmortalWRT’s “DNS redirect” feature: every port 53 query passing through the router is grabbed and answered by the local dnsmasq. Queries from the 88 subnet to 10.0.0.6 are forwarded by definition, so they were intercepted, resolved by dnsmasq over a plaintext upstream, and poisoned — and conntrack rewrote the reply’s source back to 10.0.0.6, so the client had no way to tell.
Turn it off:
uci set dhcp.@dnsmasq[0].dns_redirect='0'
uci commit dhcp
/etc/init.d/dnsmasq restart
nft list ruleset | grep HIJACK # should print nothing
In LuCI: Network → DHCP/DNS → uncheck “DNS redirect”.
You don’t lose much by disabling it. Its job is to correct devices with hardcoded DNS servers, and anything policy-routed into mihomo hits dns-hijack: any:53 in the TUN config instead, which catches the hardcoded 8.8.8.8 query there and returns a fake-ip — a more sensible place to do it anyway. DNS from main-network devices is inbound traffic addressed to the router itself, so it never touched this forwarding rule to begin with.
This is also why the MAC-tagging approach from the previous post never hits the problem: those clients share a subnet with mihomo, so their queries go over layer 2 and are never forwarded.
Verifying
# on the router
ip rule # expect: from 192.168.88.0/24 lookup 100
ip route show table 100 # expect three routes
# on an 88-subnet client
nslookup facebook.com # server 10.0.0.6, answer 198.18.x.x
curl ip.sb # shows the proxy's exit IP
At this point the mihomo log should show domain-based matches rather than bare IPs landing on Match[DIRECT].
Going further: one SSID per country
The structure extends naturally. For each exit region, add the same three pieces — interface, WiFi, config rule — all pointing at the same mihomo. Since OpenWrt routes without NAT, the client source IP arrives intact, so mihomo can pick a proxy group by source subnet:
rules:
- SRC-IP-CIDR,192.168.88.0/24,HK-Nodes
- SRC-IP-CIDR,192.168.89.0/24,US-Nodes
Changing your exit country becomes switching WiFi networks.