How Tor routing works in pbr
How Tor routing works in pbr
Describes pbr 1.2.3 (ucode). The Tor path is unchanged from 1.1.6 onward — same five rules, same chain, same gating — so a Tor problem is almost never a pbr version problem. Behaviour verified against the mock test suite.
Drafted with LLM assistance and verified against the pbr source, the dnsmasq and Tor manuals, and tests on a live OpenWrt router. Paths, defaults and behaviour change between releases and the ones quoted here come from one system at one point in time — check the commands against your own.
This is the deep-dive companion to the Tor section of the pbr documentation, which covers what most people need for a working setup. Everything below is the mechanism behind it, and how to diagnose it when it does not work.
Tor is the one “interface” in pbr that isn’t an interface at all. Nothing is routed to it — traffic is NAT-redirected into a local daemon. Understanding that distinction explains almost every way a Tor setup fails.
Every other pbr target (wan, a WireGuard tunnel, an mwan4 strategy) gets a
routing table, a firewall mark and an ip rule. Packets are marked and then
routed out of a device.
Tor gets none of that. It has no device, no gateway, no table, no mark. Tor runs on the router itself and listens on two local ports, so pbr’s entire job is to bend selected traffic into those ports using destination NAT. That is the whole mechanism.
| A normal interface | Tor | |
|---|---|---|
| Mechanism | mark + ip rule + routing table |
redirect (destination NAT) |
| nft chain | pbr_prerouting / _output / _forward |
pbr_dstnat |
| Firewall mark | allocated per interface | none — field is empty |
| Routing table | allocated per interface | none |
| Covers | all ports, all protocols | only ports 53, 80 and 443 |
| If it’s down | kill-switch / leak per policy | connection refused on those ports |
The path a request takes
Two separate things have to happen for an onion address to load, and they use
different ports and different destinations. DNS is what makes .onion special:
the name has no public record, so it can only be resolved by Tor itself.
answers with a virtual address from
┌──── the VirtualAddrNetwork range ◄─────────────┐
│ │
▼ │
┌──────────────┐ ┌─────────────┐ ┌────────────────┐
│ LAN client │───────►│ pbr_dstnat │──────────►│ Tor DNSPort │───┐
│ 192.168.1.50 │ │ │ udp :53 │ 127.0.0.1:9053 │ │
└──────────────┘ │ jumped from │ → :9053 └────────────────┘ │
│ fw4 dstnat │ ▼
│ │ ┌────────────────┐ ┌──────────────┐
│ 5 redirect │──────────►│ Tor TransPort │──►│ Tor network │
│ rules │ tcp :80 │ 127.0.0.1:9040 │ │ → .onion svc │
└─────────────┘ :443 └────────────────┘ └──────────────┘
→ :9040
The client never learns the onion service’s real address. Tor invents a private
address for the name (AutomapHostsOnResolve) from whatever range
VirtualAddrNetworkIPv4 specifies, hands it back as the DNS answer, and then
recognises that address again when the connection arrives at its TransPort.
Do not assume the range. Tor’s own default is 127.192.0.0/10 (and
[FE80::]/10 for IPv6), while OpenWrt’s Tor client guide sets
172.16.0.0/12, and much third-party material uses 10.192.0.0/10. Read the
actual value out of the running config before deciding a set looks empty —
checking for the wrong prefix is an easy way to misdiagnose this.
What pbr does, in order
1. Detect that Tor is running
Three conditions, all required: tor is not in ignored_interface,
/etc/tor/torrc exists and is non-empty, and ubus call service list reports a
tor instance with running: true. If any fails, Tor is skipped entirely and
no rules are written.
network.uc—is_tor_running()
2. Read the two ports out of torrc
pbr greps DNSPort and TransPort from /etc/tor/torrc, falling back to
9053 and 9040. These become the redirect targets.
Two limits worth knowing, because both fail silently: pbr reads only that one
file — it does not follow %include directives or read /etc/tor/conf.d, so
ports defined in an included file are never seen. And it recognises only the
address:port form. Either way it falls back to 9053/9040, which is invisible
if those happen to be the ports in use, and a dead redirect if they are not.
This matters more than it looks: OpenWrt’s own Tor client guide writes its
config to /etc/tor/custom and wires it in with
tor.conf.tail_include, not to torrc. Anyone following the official guide
therefore has ports pbr cannot see. It works today only because the guide
happens to use 9053/9040 — the same values as pbr’s fallback.
pbr.uc—_get_tor_dns_port()/_get_tor_traffic_port()
3. Register a synthetic interface
A registry entry named tor is created with empty device, mark, table and
priority. This is what makes tor appear in the status output — the “gateway”
column shows the port mapping 53->9053 and 80,443->9040 rather than an
address, because there is no gateway to show.
pbr.uc—interface_process.tor('create'), runs before policies
4. Turn each Tor policy into five redirect rules
Any policy whose interface is tor is forced onto the dstnat chain, and its
dest_port and proto are cleared — Tor decides those, not the policy. Five
rules are emitted per policy, always the same five.
pbr.uc—policy_routing(), tor branch
5. Hook the chain into fw4
pbr_dstnat is created and inserted at the top of fw4’s own dstnat chain. pbr
also health-checks that fw4’s dstnat chain exists whenever Tor is running, and
errors out if it doesn’t.
nft.uc—insert rule inet fw4 dstnat jump pbr_dstnat
The five rules
udp dport 53 redirect to :9053 # Tor-DNS-UDP
tcp dport 80 redirect to :9040 # Tor-HTTP-TCP
udp dport 80 redirect to :9040 # Tor-HTTP-UDP
tcp dport 443 redirect to :9040 # Tor-HTTPS-TCP
udp dport 443 redirect to :9040 # Tor-HTTPS-UDP
Consequence worth knowing. That list is exhaustive and hard-coded. Anything on another port — an onion service on
:8080, SSH, XMPP, a mail client — is never redirected and leaves through the normal uplink. A Tor policy is not a kill switch; it is a redirect for three ports.
Note the two UDP rules for 80 and 443 do not do what they appear to. Tor’s
TransPort accepts TCP only, so nothing is listening for the UDP that gets
redirected to it. The practical effect is to blackhole UDP on those ports rather
than carry it — which does stop QUIC/HTTP3 on 443 slipping past Tor, but by
refusing it rather than routing it. Only the TCP rules move traffic through Tor.
The same asymmetry applies at the other end: Tor’s DNSPort is UDP-only, which
is why there is no tcp dport 53 rule to match the UDP one. Adding one would
redirect TCP DNS to a port that cannot answer it.
How traffic gets selected — and what each mode depends on
Those five rules are prefixed with whatever the policy matches on. That prefix decides whether a setup works, and there are two very different modes.
If you just want Tor to work reliably, use mode A: set
src_addrand leavedest_addrempty. It matches on the first packet, needs no DNS cooperation, and none of the ten failure modes catalogued further down can touch it. Mode B — matching on a destination domain — is supported and useful for ordinary destinations, but it depends ondnsmasqbehaviour that pbr cannot see or verify, and on stock OpenWrt it does not work for.onionat all until you deal withrfc6761.conf.The one tradeoff: mode A sends all of that client’s DNS through Tor, so local filtering (adblock and friends) stops applying to it.
Mode A — match on source (src_addr, no dest_addr)
client ───► ip saddr { 192.168.1.50 } ───► Tor TransPort
tcp dport 443 redirect to :9040 matches on packet 1
— no destination condition —
Matches on the first packet and needs nothing else.
Mode B — match on domain (dest_addr = onion)
client ───► ip daddr @pbr_tor_4_dst_ip_<uid> ───► Tor TransPort
tcp dport 443 redirect to :9040 only once the set
— gated on the set — contains the address
▲
│ must be filled first
┌───────────┐ ┌────────────┐ ┌──────────┐
│ nftset │◄───────│ dnsmasq │◄───────│ Tor │
│ │ adds │ /onion/ → │ answer │ DNSPort │
└───────────┘ answer │ 127.0.0.1 │ └──────────┘
└────────────┘
Listing a domain such as onion in dest_addr triggers mode B. pbr creates a
set named pbr_tor_4_dst_ip_<uid>, writes a line into its dnsmasq include file,
and prefixes all five rules with ip daddr @<set>:
nftset=/onion/4#inet#fw4#pbr_tor_4_dst_ip_<uid> # <policy name>
<uid> is the UCI section id, not the policy name — typically something like
cfg0c6ff5. Use the name from nft list chain inet fw4 pbr_dstnat, not the
label you gave the policy, when inspecting the set.
A working set looks like this — the addresses are Tor’s virtual ones, inside
whatever VirtualAddrNetworkIPv4 specifies:
nft list set inet fw4 pbr_tor_4_dst_ip_cfg0c6ff5
set pbr_tor_4_dst_ip_cfg0c6ff5 {
type ipv4_addr
flags interval
auto-merge
comment "TOR_ONION"
elements = { 172.17.245.221, 172.18.3.14 }
}
If instead you see public addresses (40.x, 140.x, 185.x …), something other
than Tor answered the .onion lookup and pbr stored what DNS returned. If
there is no elements line at all, nothing has been resolved yet — or the
lookups are not reaching dnsmasq.
Mode B requires resolver_set = dnsmasq.nftset. That setting is what makes
pbr hand the domain to dnsmasq to resolve, rather than resolving it itself.
Three conditions must all hold, and none of them is announced:
pbr.config.resolver_setisdnsmasq.nftset. Unset,none, orunbound.nftsetall fall back to resolving the name directly — which for.onioncan never succeed, and surfaces asERROR: Failed to resolve 'onion'.- dnsmasq is built with nftset support. pbr detects this by grepping
dnsmasq --versionfornftsetin the compile options; without it the fallback applies just the same. - The domain is a destination. The set path is skipped for
src_addrentirely, so a domain there is always resolved directly — meaning an onion name insrc_addrcannot work under any configuration.
Note that all five rules carry the ip daddr @<set> prefix, including the DNS
one. A client’s query is addressed to the router, which is never in the set, so
in mode B the DNS redirect never fires and name resolution always goes through
dnsmasq. That is what makes mode B able to work at all — but it also makes it
fragile in ways mode A is not:
- Nothing matches until dnsmasq has resolved a
*.onionname and written the answer into the set. (dnsmasq writes it while processing the reply, before the client receives it, so this is not a race in practice — but it does mean the set is empty until the first lookup happens.) -
On OpenWrt, dnsmasq will not forward
.onion— and adding a server line for it does not help. RFC 7686 reserves.onionas a special-use name, but dnsmasq itself does not act on that: a bare instance forwards.onionhappily. OpenWrt is what blocks it, by shipping/usr/share/dnsmasq/rfc6761.conf(named for RFC 6761, which coverstest,localhostandinvalid;.onionwas added later by RFC 7686 and lives in the same file):server=/bind/ server=/invalid/ server=/local/ server=/localhost/ server=/onion/ ← addressless: "answer locally, never forward" server=/test/and including it from the init script — gated, oddly, on the
bogusprivoption rather than anything to do with RFC6761:RFC6761FILE="/usr/share/dnsmasq/rfc6761.conf" config_get_bool boguspriv "$cfg" boguspriv 1 [ "$boguspriv" -gt 0 ] && { xappend "--bogus-priv" [ -r "$RFC6761FILE" ] && xappend "--conf-file=$RFC6761FILE" }With that file loaded, a correct
server=/onion/127.0.0.1#9053registers and is then overridden. Both appear in the startup log, and the local declaration wins:using nameserver 127.0.0.1#9053 for domain onion ← your server line, registered using only locally-known addresses for onion ← rfc6761.conf — and this winsOrder does not help: dnsmasq normalises the server list, and the local declaration wins wherever it appears (both orderings verified on dnsmasq 2.93). The file also sits outside the usual places (
/var/etc,/tmp/dnsmasq.d,serversfile), which is why it is easy to miss — and this is how a setup that worked on an older OpenWrt breaks with no configuration change of your own.Three ways out, none of them free:
Fix Survives upgrade Cost Source-matched policy (drop dest_addr)✓ no dnsmasq involvement at all that client’s DNS all goes via Tor, so local filtering stops applying to it uci set dhcp.@dnsmasq[0].boguspriv='0'✓ supported UCI blunt: the same flag gates --bogus-priv, so you also lose that and un-localise all six RFC6761 domainsComment server=/onion/inrfc6761.conf✗ package file surgical, but silently reverts on upgrade boguspriv='0'works by accident, so do not rely on it. It is not a control for RFC6761 — it happens to gate the--conf-fileinclude as well as--bogus-priv, which looks like a packaging slip rather than a decision. If OpenWrt ever decouples the two (the correct fix),boguspriv='0'will keep disablingbogus-privwhile no longer removingserver=/onion/, and.onionwill quietly stop resolving again with no configuration change of your own — the same way this broke in the first place.Re-check after every OpenWrt upgrade. Instantly, from the generated config, with no restart:
grep -c rfc6761 /var/etc/dnsmasq.conf.* # 0 = .onion forwardable, 1 = localisedor behaviourally, reading one clean startup block:
/etc/init.d/dnsmasq restart; sleep 2 logread | grep "locally-known addresses for onion" \ && echo "STILL LOCALISED — .onion will not forward" \ || echo "OK — .onion can be forwarded to Tor"The source-matched policy is the only option here that cannot be undone by someone else’s packaging change.
With
logquerieson, the query log confirms which path ran:config … is NXDOMAINmeans answered locally and never forwarded, as againstforwarded … to. - The set is keyed on the UCI section id, so editing policies can change the set name.
This is a legitimate configuration, not a broken one — but on stock OpenWrt it does not work until you deal with
rfc6761.confabove. Once DNS is wired correctly —server=/onion/127.0.0.1#9053actually reaching Tor, and Tor’s answer coming back intact — the virtual address lands in the set and everything works.pbr does not warn about a
dest_addr, deliberately. Whether one works depends on DNS reaching Tor, which pbr has no way to observe, so a warning would fire exactly as loudly on a correct setup as on a broken one. It is documented here instead. pbr does warn aboutsrc_port,dest_port,protoandchain, which are discarded in every case.
Every way mode B fails quietly
Grouped by where in the chain it breaks. None of these announces itself — the
policy loads without error and the rules look correct in nft list throughout.
Mode A is immune to 2–5 outright, because the query never leaves the packet
path — and needs no set at all, so 1 does not apply either.
| # | Breaks at | Pitfall | How it shows / what to check |
|---|---|---|---|
| 1 | No set is built at all | resolver_set is not dnsmasq.nftset, or dnsmasq lacks nftset support |
pbr resolves the name itself and fails: ERROR: Failed to resolve 'onion'. uci get pbr.config.resolver_set; dnsmasq --version \| grep nftset |
| 2 | DNS not routed to Tor | No server=/onion/127.0.0.1#9053 at all |
upstreams answer NXDOMAIN, or hijack the TLD with a public address |
| 3 | DNS blocked before Tor | OpenWrt’s rfc6761.conf ships server=/onion/ |
both log lines appear, local wins — see above |
| 4 | DNS blocked before Tor | A blocklist writing addressless server=/domain/ through serversfile (adblock and friends) |
grep -n onion /var/run/*/dnsmasq.servers; stop the service, restart dnsmasq, re-test |
| 5 | DNS never reaches dnsmasq | Client resolves for itself — browser DoH/DoT, or hardcoded DNS | the client resolves the name fine, yet the set stays empty; check the browser’s secure-DNS setting |
| 6 | Tor produces no answer | AutomapHostsOnResolve missing from torrc |
nslookup -port=9053 <name>.onion 127.0.0.1 returns nothing |
| 7 | Wrong redirect ports | pbr read only /etc/tor/torrc, only in address:port form |
redirect points at a port nothing is listening on |
| 8 | Set poisoned | Wrong answers land in the set and never expire — pbr sets no timeout on these entries | nft list set … shows public addresses; only service pbr restart clears them, and until then they push unrelated traffic into Tor |
| 9 | IPv6 half of the answer | Tor returns an AAAA virtual address too; TransPort bound only to 0.0.0.0 |
intermittent — depends on whether the client picks A or AAAA |
| 10 | Port coverage | Only 53, 80 and 443 are ever redirected | an onion service on any other port is never torified, whatever dest_addr says |
Items 3 and 4 are the same mechanism — an addressless server=/domain/ meaning
“answer locally, never forward” — from two different sources. Confirming which
one is in play matters, because the fixes are unrelated.
The practical rule: for Tor, match on source and leave dest_addr empty.
Both modes can work for .onion, but they depend on different things. Mode B
needs dnsmasq to forward .onion to Tor with nothing overriding it — which on
stock OpenWrt means removing the rfc6761.conf block first, a dependency pbr
cannot see or verify. Mode A redirects the client’s port 53 traffic to Tor’s
DNSPort at the packet level, so the query never reaches dnsmasq at all and none
of its .onion handling applies: Tor resolves the name natively, hands back a
virtual address, and the same policy’s rules carry the traffic. One step, no set
to populate, and nothing outside pbr that can silently break it.
The tradeoff is that mode A sends all of that client’s DNS to Tor, so any local filtering (adblock and friends) no longer applies to it. A domain-matched policy remains the right tool for ordinary destinations.
When it doesn’t work
Start here. Run both, and compare — this one pair localises almost every
.onion failure to one side or the other:
nslookup <name>.onion 127.0.0.1 # via dnsmasq
nslookup -port=9053 <name>.onion 127.0.0.1 # straight from Tor
If the second returns a virtual address and the first does not, Tor is fine and dnsmasq is losing the answer — go to the first row below. If neither works, the problem is in Tor or torrc.
| Symptom | Likely cause | Check |
|---|---|---|
| Tor answers on :9053, dnsmasq returns NXDOMAIN | dnsmasq is answering .onion locally instead of forwarding — either no server=/onion/… is set, or something overrides it |
logread \| grep -E "locally-known\|for domain" — want for domain onion, not locally-known … onion |
server=/onion/… is set but the log also says locally-known … onion |
OpenWrt’s /usr/share/dnsmasq/rfc6761.conf carries an addressless server=/onion/, which wins |
grep -n onion /usr/share/dnsmasq/rfc6761.conf — comment the line out, or use a source-matched policy to bypass dnsmasq entirely |
Set holds public addresses instead of 172.x/10.x |
Something other than Tor is answering .onion lookups; pbr stores whatever DNS returns, and these entries never expire |
nft list set inet fw4 pbr_tor_4_dst_ip_<uid>; clear with service pbr restart once DNS is fixed |
Client resolves .onion but nothing is torified |
Client is bypassing dnsmasq — browser DoH/DoT or its own resolver — so the set is never populated | disable secure DNS in the browser and re-test; the set only ever fills from dnsmasq |
.onion fails, everything else torified |
Domain-matched policy whose set is empty | nft list set inet fw4 pbr_tor_4_dst_ip_<uid> |
ERROR: Failed to resolve 'onion' |
The resolver/nftset path is off, so pbr tried ordinary DNS — which can never resolve a .onion name |
uci get pbr.config.resolver_set |
| IPv4 works, but some clients still fail | Tor returns an IPv6 virtual address too; TransPort bound only to 0.0.0.0 has nothing listening on IPv6 |
see the IPv6 note below |
| Redirect fires but nothing loads | Stale virtual address — Tor restarted since the answer was cached | logread -e tor, then flush client + dnsmasq caches |
| Nothing is torified at all | Tor not detected — no rules were written | nft list chain inet fw4 pbr_dstnat |
| Connections refused on 80/443 | Redirecting to a port Tor isn’t listening on | netstat -ltnp \| grep tor |
| Works on default ports, breaks on custom | torrc uses the bare form, so pbr fell back to 9053/9040 | grep -E 'DNSPort\|TransPort' /etc/tor/torrc |
| Ports set in an include file are ignored | pbr reads only /etc/tor/torrc, never %included files or conf.d |
grep -rE 'DNSPort\|TransPort' /etc/tor/ — if the hits are outside torrc, pbr never saw them |
| Some apps bypass Tor entirely | Their traffic isn’t on 53, 80 or 443 | expected — see the five rules above |
torrc syntax matters more than it should. pbr only recognises the
address:portform —DNSPort 0.0.0.0:9053. A bareDNSPort 9153does not parse, and pbr silently falls back to9053, redirecting every DNS query to a port nothing is listening on. Written with the default numbers the bare form still appears to work, which is what makes this one hard to spot.
A working reference torrc
AutomapHostsOnResolve 1
VirtualAddrNetworkIPv4 172.16.0.0/12
TransPort 0.0.0.0:9040
DNSPort 0.0.0.0:9053
This matches what OpenWrt’s Tor client guide sets, and what the pbr docs show,
so following both does not produce a mismatch. Tor’s own default is different
(127.192.0.0/10) — see the caution above about not assuming the range.
AutomapHostsOnResolve is not optional. Without it Tor’s DNSPort will not
invent an address for a .onion name, so there is nothing for the traffic rules
to match and onion addresses cannot resolve at all.
Two valid dnsmasq designs
OpenWrt’s Tor client guide routes all DNS through Tor — it replaces the server list outright and turns off upstream resolution:
uci set dhcp.@dnsmasq[0].noresolv="1"
uci set dhcp.@dnsmasq[0].localuse="0"
uci set dhcp.@dnsmasq[0].rebind_protection="0"
uci -q delete dhcp.@dnsmasq[0].server
uci add_list dhcp.@dnsmasq[0].server="127.0.0.1#9053"
uci add_list dhcp.@dnsmasq[0].server="::1#9053"
paired with AutomapHostsSuffixes . in torrc, so Tor maps every name to a
virtual address. Simple, but everything on the router resolves through Tor.
The alternative is to scope Tor to .onion only and leave normal DNS alone:
uci add_list dhcp.@dnsmasq[0].server="/onion/127.0.0.1#9053"
# ... regular upstream servers stay in the list
uci set dhcp.@dnsmasq[0].allservers="1"
This is sound, and all-servers does not leak .onion to the public
resolvers: dnsmasq picks the most specific domain match, and since 2.53 it
distributes a query to all servers available for that domain — for .onion
that set contains only the Tor server. Ordinary lookups keep using the normal
upstreams and any local filtering.
Note the port separator is #, not : — 127.0.0.1#9053.
Both designs hit the rfc6761.conf block described above, and neither guide
mentions it.
If IPv6 is enabled, bind TransPort on IPv6 too
Tor’s DNSPort answers a .onion lookup with both an IPv4 and an IPv6
virtual address. With ipv6_enabled, pbr writes the matching
ip6 daddr @pbr_tor_6_dst_ip_<uid> … redirect to :9040 rule — so a client that
prefers IPv6 gets redirected to TransPort on IPv6. A torrc binding only
0.0.0.0 has nothing listening there, and the connection is refused even
though IPv4 would have worked. Either add the IPv6 listeners:
TransPort [::]:9040
DNSPort [::]:9053
or disable IPv6 in pbr. Note this failure is intermittent by nature — it depends on whether the client picks the A or the AAAA record.
Quick reference
| Question | Answer |
|---|---|
| Does Tor need a routing table? | No — pure NAT redirect |
| Does it get a firewall mark? | No — field stays empty |
| Does it appear in the WebUI interface list? | Yes — when torrc is present |
| Are ports other than 53/80/443 covered? | No |
Should dest_port or proto be set on the policy? |
No — pbr clears them |
Should src_port be set? |
No — produces malformed rules |
Should dest_addr be set? |
Not normally — see mode B |
Does a domain dest_addr need anything else? |
Yes — resolver_set = dnsmasq.nftset, or pbr resolves it itself and fails |
Does dest_addr = onion work on stock OpenWrt? |
No — rfc6761.conf blocks the lookup until you remove it |
| Is a Tor problem ever a pbr version problem? | No — the Tor path is unchanged 1.1.6 → 1.2.3 |
How do I check .onion still resolves after an OpenWrt upgrade? |
grep -c rfc6761 /var/etc/dnsmasq.conf.* — 0 = forwardable, 1 = localised |