--- /dev/null
+# shunt - policy based routing via passive DNS observation
+# Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
+# This is free software, licensed under the GNU General Public License v3.
+
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=shunt
+PKG_VERSION:=0.1.5
+PKG_RELEASE:=1
+PKG_LICENSE:=GPL-3.0-or-later
+PKG_LICENSE_FILES:=
+PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/shunt
+ SECTION:=net
+ CATEGORY:=Network
+ TITLE:=Policy based routing via passive DNS observation
+ DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-socket +ucode-mod-uci \
+ +ucode-mod-uloop +ucode-mod-resolv +ucode-mod-ubus \
+ +ucode-mod-rtnl +ucode-mod-log +rpcd-mod-ucode +nftables-json +ip
+ PKGARCH:=all
+endef
+
+define Package/shunt/description
+ Routes traffic by source, destination and domain into policy interfaces.
+ Domain addresses are learned resolver-independently: a poll loop resolves the
+ configured names and a passive AF_PACKET observer picks every DNS answer off
+ the wire, so shunt works unchanged with dnsmasq, unbound or any other backend.
+ Marks are applied via an own nftables table with per-element
+ counters, routing via fwmark rules and per-policy tables, with gateway
+ discovery and interface events from netifd.
+endef
+
+define Package/shunt/conffiles
+/etc/config/shunt
+endef
+
+define Build/Prepare
+endef
+
+define Build/Configure
+endef
+
+define Build/Compile
+endef
+
+define Package/shunt/install
+ $(INSTALL_DIR) $(1)/usr/share/ucode/shunt
+ $(INSTALL_DATA) ./src/*.uc $(1)/usr/share/ucode/shunt/
+
+ $(INSTALL_DIR) $(1)/usr/sbin
+ $(INSTALL_BIN) ./files/shunt.uc $(1)/usr/sbin/shunt
+
+ $(INSTALL_DIR) $(1)/etc/init.d
+ $(INSTALL_BIN) ./files/shunt.init $(1)/etc/init.d/shunt
+
+ $(INSTALL_DIR) $(1)/etc/config
+ $(INSTALL_CONF) ./files/shunt.config $(1)/etc/config/shunt
+
+ $(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
+ $(INSTALL_DATA) ./files/shunt.rpcd $(1)/usr/share/rpcd/ucode/shunt
+
+endef
+
+$(eval $(call BuildPackage,shunt))
--- /dev/null
+<!-- markdownlint-disable -->
+
+# shunt - policy based routing by mac, source, destination and domain
+
+## Table of Contents
+* [Description](#description)
+* [Quick Start](#quick-start)
+* [Main Features](#main-features)
+* [Prerequisites](#prerequisites)
+ * [Which tunnels work](#which-tunnels-work)
+* [Installation and Usage](#installation-and-usage)
+* [shunt CLI interface](#shunt-cli-interface)
+* [shunt config options](#shunt-config-options)
+* [How addresses are learned](#how-addresses-are-learned)
+ * [What polling costs](#what-polling-costs)
+* [Examples](#examples)
+* [What it shells out to](#what-it-shells-out-to)
+* [What shunt creates on the system](#what-shunt-creates-on-the-system)
+* [Coexistence with pbr and mwan3](#coexistence-with-pbr-and-mwan3)
+* [Troubleshooting & debug options](#troubleshooting-and-debug-options)
+* [Known limitations](#known-limitations)
+* [Support](#support)
+* [Removal](#removal)
+* [Donations](#donations)
+
+<a id="description"></a>
+## Description
+shunt routes selected traffic into a policy interface - a VPN tunnel, a second uplink, a mobile connection - chosen by client address, client MAC, destination or domain. It keeps its own nftables table and one routing table per policy, so it coexists with fw4 and with other routing tools instead of competing with them.
+
+The one thing that defines the project: **shunt is not bound to any DNS backend.** It works unchanged with dnsmasq, unbound, smartdns, AdGuard Home or anything else, because it never asks the resolver for anything and never sits in the DNS path. Domain to address mapping comes from two sources shunt owns itself, described under [How addresses are learned](#how-addresses-are-learned).
+
+<a id="quick-start"></a>
+## Quick Start
+For a typical setup these few steps are enough - see the sections below for details:
+1. Install the LuCI companion package: `apk update && apk add luci-app-shunt` (this pulls in the `shunt` backend as a dependency).
+2. Make reverse path filtering loose and give the policy interface a masquerading firewall zone - both once, both shown under [Prerequisites](#prerequisites). Without the first, marked traffic is dropped; without the second it is marked and routed and then goes nowhere, which looks exactly like shunt not working.
+4. Open LuCI under `Services -> shunt`, add a policy on the `Policies` tab: pick the `Interface`, name the clients under `Source addresses` or `Source MAC addresses`, and list the `Domains` you want routed.
+5. Start and verify:
+
+```sh
+/etc/init.d/shunt enable
+/etc/init.d/shunt start
+shunt check
+```
+
+**Please note:** a domain policy only takes effect for a client's *next* connection to an address that has just been learned - see [Known limitations](#known-limitations).
+
+<a id="main-features"></a>
+## Main Features
+* Routes by client address, client MAC, destination CIDR and domain, in any combination
+* Resolver independent: works with any DNS backend, and with an encrypted upstream, because it reads the plaintext leg between client and resolver
+* Wildcard domains (`*.example.com`), learned passively as clients use them
+* Per-policy killswitch: hold the traffic when the interface drops, instead of leaking it out of the normal uplink
+* Own nftables table and routing tables, disjoint mark range - runs beside `pbr` and `mwan3`
+* IPv4 and IPv6 throughout, with a MAC selecting a host in both at once
+* Per-element counters on every set, a ubus status object and a LuCI frontend
+* No dependency on a specific DNS backend, no resolver configuration, no include files, no hooks into fw4's ruleset
+
+<a id="prerequisites"></a>
+## Prerequisites
+* OpenWrt with fw4/nftables
+* `ucode` plus `ucode-mod-fs`, `ucode-mod-socket`, `ucode-mod-uci`, `ucode-mod-uloop`, `ucode-mod-resolv`, `ucode-mod-ubus`, `ucode-mod-rtnl`, `ucode-mod-log` and `rpcd-mod-ucode` - all pulled in by the package
+
+`ucode-mod-resolv` and `ucode-mod-ubus` are soft at runtime: without resolv, poll is skipped and the observer carries the service alone; without ubus, gateway discovery and interface events are skipped and the config's own values are used. Both cost one warning in the log, not a failed start.
+
+<a id="which-tunnels-work"></a>
+### Which tunnels work
+
+Any of them, and there is no supported-protocols list to check against, because shunt never asks what protocol an interface speaks. It consumes two things: the device to route into, and a gateway if one is needed. Both come from netifd, and a device netifd does not manage is taken as given.
+
+* **Point to point tunnels** - wireguard, OpenVPN `tun*`, L2TP, PPTP, Tailscale, NetBird and the like - need no gateway at all. The route is `default dev <device> table <n>`.
+* **Ethernet style interfaces** - OpenVPN `tap*`, a second wired uplink, a mobile connection - use the gateway discovered from netifd, or `gw4`/`gw6` if you set them.
+* **Interfaces netifd does not manage** work by name too. On OpenWrt this is the exception rather than the rule - wireguard, OpenVPN, L2TP and the rest all have netifd protocols and are managed like any other interface. It applies to a tunnel brought up outside netifd, by `wg-quick` or a script of your own. If such an interface does need a gateway, discovery cannot find one and you have to set `gw4`/`gw6` yourself.
+
+The one thing that does not work is anything that is not a routable interface. Tor is the usual example: it normally offers a SOCKS port, and sending traffic there is a redirect, not a route. shunt marks a packet and looks up a routing table; without a device to put a default route on, there is nothing for it to do. Transparent proxying is out of scope by design, not for want of a special case.
+
+Two kernel-side prerequisites. shunt never changes either one behind your back - the first can be handed to shunt explicitly (`rp_filter_manage`, below), the second stays with fw4:
+
+**`rp_filter` must be loose on the policy interface.** Marked traffic takes an asymmetric path, so strict reverse path filtering drops it. The kernel decides per incoming packet using `max(net.ipv4.conf.all.rp_filter, net.ipv4.conf.<dev>.rp_filter)`, where `2` is loose - so setting the policy interface alone to `2` suffices even while `all` stays strict. Prefer this: it leaves reverse path filtering intact on every other interface.
+
+```sh
+cat > /etc/sysctl.d/99-shunt.conf <<'EOF'
+net.ipv4.conf.phy0-sta0.rp_filter=2
+EOF
+sysctl -p /etc/sysctl.d/99-shunt.conf
+```
+
+Replace `phy0-sta0` with your policy interface's device - the `Interface` column on the overview shows it - one line per policy device.
+
+There is a boot-order catch. A device that does not exist yet - a tunnel, or a wifi client interface brought up late - has no `conf/<dev>` entry at boot, so `sysctl -p` cannot set it and skips the line. When the device finally appears it inherits `net.ipv4.conf.default.rp_filter`, and if that is strict the device comes up strict and stays that way until the next `sysctl -p` - which for most setups means until the next reboot, i.e. never in practice. Two ways around it: set `net.ipv4.conf.default.rp_filter=2` as well, which makes every later-appearing interface inherit loose (a little broader, but far short of `all`), or let shunt handle it with the option below.
+
+**`rp_filter_manage` (optional, off by default).** With it set, shunt itself sets `rp_filter=2` on its own policy devices - at start and again whenever one comes up, which is exactly the boot-order moment a static file misses. It only ever touches the devices shunt routes into, never `all` or `default`, and only while the service runs. It is off by default because changing a security setting should be a deliberate choice:
+
+```sh
+uci set shunt.@global[0].rp_filter_manage='1'
+uci commit shunt
+/etc/init.d/shunt restart
+```
+
+Whichever way you choose, the daemon checks the live per-device values - at start and again whenever a policy interface comes up - and warns, naming the device, only when a policy device exists and is still strict. A device that is not there yet carries no traffic and triggers no warning; it is checked the moment it appears. With `rp_filter_manage` on the warning therefore simply does not appear - not because the switch is set, but because the values read back are actually loose. With `all` at `2` (or `0`) nothing is ever reported. The package deliberately ships no box-wide sysctl file: `rp_filter` on `all`/`default` is a distribution default OpenWrt sets strict in `/etc/sysctl.d/10-default.conf`, and loosening it there weakens anti-spoofing on every interface, well beyond shunt's own traffic.
+
+**Masquerading stays fw4's job.** shunt marks and routes; it does not touch the firewall's NAT. The policy interface needs a zone with `masq` enabled and forwarding from `lan`, exactly as any other uplink. If the interface is a netifd one - say a wireguard interface named `vpn`:
+
+```sh
+uci add firewall zone
+uci set firewall.@zone[-1].name='vpn'
+uci set firewall.@zone[-1].input='REJECT'
+uci set firewall.@zone[-1].output='ACCEPT'
+uci set firewall.@zone[-1].forward='REJECT'
+uci set firewall.@zone[-1].masq='1'
+uci set firewall.@zone[-1].mtu_fix='1'
+uci add_list firewall.@zone[-1].network='vpn'
+
+uci add firewall forwarding
+uci set firewall.@forwarding[-1].src='lan'
+uci set firewall.@forwarding[-1].dest='vpn'
+
+uci commit firewall
+/etc/init.d/firewall reload
+```
+
+`network` names a **logical interface**, not a device. For a device netifd does not manage - a tunnel brought up outside netifd - use `uci add_list firewall.@zone[-1].device='wg0'` instead. And if the policy interface already has a zone, because it is an ordinary second uplink, there is nothing to do here.
+
+Symptoms of getting this wrong are worth knowing, because they do not look like a firewall problem: the prerouting counters rise, `nft list set` shows the learned address being hit, and the client's connection simply times out.
+
+<a id="installation-and-usage"></a>
+## Installation and Usage
+* Update your router's apk repository (`apk update`)
+* Install the LuCI companion package `luci-app-shunt`, which also installs the main `shunt` package as a dependency
+* Make `rp_filter` loose and give the policy interface a masquerading firewall zone - both are one-time steps with copy-paste commands under [Prerequisites](#prerequisites)
+* Configure at least one policy, either in LuCI under `Services -> shunt` or by editing `/etc/config/shunt`
+* Enable and start the service, then run `shunt check` - it prints the mark, routing table and rule priority of every accepted policy, and every rejected value with its reason
+* Check the `Set Reporting` tab to see which addresses were learned, and the `Processing Log` tab for the service's own messages
+
+<a id="shunt-cli-interface"></a>
+## shunt CLI interface
+All functions are available from the command line, and the config file can be edited directly if you prefer that to LuCI.
+
+```sh
+shunt check # render everything, print marks and issues, change nothing
+shunt run # foreground, the procd service entry point
+shunt flush # tear down table, rules, routes and the mapping file
+shunt -v <cmd> # echo every message to the terminal as well
+```
+
+`shunt check` is safe at any time, including while the service runs, because it only renders - it never touches the kernel. Run it after every config change. Note that it says nothing about whether the service is *running*; that is what `/etc/init.d/shunt status` and the LuCI overview are for.
+
+Exit codes: 0 ok, 1 runtime failure, 2 usage or unusable config.
+
+Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows everything - the daemon's own lines carry its pid, `shunt[1234]:`. Debug lines stay off unless `-v` is given or `option debug '1'` is set - under procd there is no command line, so a bug report needs the config switch. Expect volume: on a router running adblock roughly half of all observed answers are error replies, and debug gives each one a line.
+
+`shunt flush` is the escape hatch if the daemon ever dies without tearing down. It is idempotent and safe on a box that never ran shunt.
+
+<a id="shunt-config-options"></a>
+## shunt config options
+
+### Global section
+
+| Option | Default | Description |
+| :--- | :--- | :--- |
+| enabled | `1` | master switch; `0` means the service starts and exits |
+| debug | `0` | log every observed answer and every set write |
+| rp_filter_manage | `0` | set rp_filter=2 on shunt's own policy devices, at start and on ifup |
+| poll_interval | `300` | seconds between poll cycles, at least 30 |
+| entry_ttl | `1200` | nftables timeout on learned elements, at least 60 |
+| snoop | `1` | enable the passive DNS observer |
+| snoop_device | `br-lan` | LAN devices to observe, a list, one entry per segment |
+
+Values below the minimum are clamped, not rejected, and the clamp is logged. `entry_ttl` should stay well above `poll_interval` - an element is rewritten once its remaining timeout drops below half of `entry_ttl`, so the default pair refreshes comfortably within two poll cycles.
+
+### Policy sections
+
+Each `config policy` section is one routing policy. **The section must be named, and the name must match `[A-Za-z0-9_]{1,24}`** - it becomes an nftables identifier, so a section without a name, or one with a hyphen or a dot in it, is rejected as an issue and never rendered. LuCI enforces the same pattern when a policy is added.
+
+| Option | Description |
+| :--- | :--- |
+| enabled | `0` skips the section entirely |
+| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`) |
+| fallback | `main` (default) or `block`, see below |
+| gw4 / gw6 | gateway override; normally unnecessary |
+| src | client addresses or CIDRs whose traffic this policy owns |
+| src_mac | client MAC addresses, ORed with `src` |
+| proto | `tcp`, `udp`, or both; a port without one covers both |
+| dport | destination ports, single or a range like `8000-8080` |
+| dst | destination addresses or CIDRs |
+| domain | domain patterns, see below |
+
+`src`, `src_mac`, `dst` and `domain` are lists and may repeat.
+
+Interfaces are resolved through netifd: a logical name resolves to its `l3_device`, a raw netdev is adopted if netifd knows it, and a device netifd knows nothing about passes through as given - which on OpenWrt means a tunnel started outside netifd, since wireguard and the other tunnel types have netifd protocols of their own. Gateways are discovered from the same dump, merged across sibling entries, because netifd splits families. `gw4`/`gw6` override discovery and always win; on a point to point interface no gateway is needed at all.
+
+There is deliberately no list of supported tunnel protocols. shunt asks netifd for the device and the gateway and renders a default route into the policy table - `default via <gw> dev <device>` when a gateway is known, `default dev <device>` when none is needed. Wireguard, OpenVPN in both `tun` and `tap` mode, L2TP, PPTP, Tailscale, NetBird, a second physical uplink or a mobile connection all reduce to those two shapes, so none of them needs a case of its own. See [Which tunnels work](#which-tunnels-work) for the one thing that genuinely does not fit.
+
+### Selectors are ANDed, client selectors OR each other
+
+* `src` or `src_mac` alone marks everything from those clients
+* `dst`, `domain`, `dport` or `proto` alone marks that traffic from everyone
+* clients plus destinations marks only those clients' traffic to those destinations
+
+`dport` and `proto` AND with everything else, so a policy with a client, a domain and `dport 443` covers that client's HTTPS traffic to that domain and nothing more. A port without a protocol matches **both** tcp and udp - "port 443" almost always means QUIC too, and requiring the protocol would let it slip through unnoticed. If ports or protocols were configured and none of them is usable, the policy is skipped rather than rendered without the narrowing.
+
+This is the single most common source of "it did not work" reports: with a client and a domain both set, a generic `curl ifconfig.me` from that client correctly takes the normal uplink, because `ifconfig.me` is not in the domain list. That is the policy working, not failing.
+
+A client MAC and a client address OR each other, so a host may be named either way. If client selectors were configured and **none** of them is usable - a typo in the only address, say - the policy is skipped with an issue rather than falling back to "every client", which is what an absent client selector otherwise means.
+
+### Selecting clients by MAC
+
+`src_mac` exists mainly for IPv6. Clients prefer rotating privacy addresses for outgoing traffic, so a single IPv6 address is not a usable selector and the LAN prefix covers every host in the segment. A MAC picks exactly one host, in both address families, and keeps doing so when the addresses change. A policy with only `src_mac` therefore needs no v6 address to route v6.
+
+Three limits, none of them guessable:
+
+* **Same layer 2 segment only.** Anything behind another router arrives with that router's MAC.
+* **Never the router itself.** The `output` chain sees traffic the router generated, which has no ethernet sender, so MAC rules are not installed there. An address based policy does cover the router; a MAC-only one does not.
+* **Phones randomise their MAC**, though usually stable per network. Use the address the client shows in your DHCP leases, not the one on the label.
+
+### Domain patterns
+
+```
+example.com matches the apex only
+*.example.com matches subdomains only, at any depth, NOT the apex
+```
+
+List both to cover both. This is more typing than dnsmasq's implicit subdomain inclusion, and it is deliberate: dnsmasq's behaviour surprises people regularly, this one does not.
+
+Precedence, in order:
+
+1. an exact match always beats any wildcard
+2. among wildcards the longest suffix wins, so `*.cdn.example.com` beats `*.example.com` regardless of which policy declared them
+3. the same pattern in two policies belongs to **both**
+
+Rule 3 is what makes one domain usable by two client groups over two different uplinks: the address is written into each policy's set, and each policy's rule matches only its own clients, so they stay apart. Rules 1 and 2 still decide specificity - a shared `*.example.com` never overrides somebody's exact `www.example.com`.
+
+Matching is label aligned, never string suffix: `evilexample.com` does not match `*.example.com`. A bad pattern is collected as an issue, never fatal.
+
+### Policy precedence
+
+Section order in `/etc/config/shunt`, top to bottom. There is no `priority` option - one less value to set wrong. A packet matching two policies takes the earlier one; rule evaluation ends at the first match.
+
+Note that domain precedence is resolved *before* this, at the matcher: the most specific pattern wins even if it sits in a later section.
+
+### Fallback: main or block
+
+`fallback 'main'` (default) renders no default route into the policy table, so when the policy interface is down the table is empty and marked traffic falls through to `main` - the normal uplink. Traffic keeps flowing, unpolicied.
+
+`fallback 'block'` adds a blackhole default at metric 9999 to the policy table. While the interface is up its own default has the lower metric and wins; when the interface drops, the kernel withdraws that route and the blackhole catches everything. That is the killswitch: traffic belonging to the policy stops rather than leaking out of the wrong interface.
+
+<a id="how-addresses-are-learned"></a>
+## How addresses are learned
+Two sources feed the same nftables sets, union with an element timeout. They are complementary, not alternative modes.
+
+* **poll** resolves the configured names through whatever system resolver exists, on a fixed interval. It warms the sets before the first client packet, so first contact does not race. Wildcards are not names and cannot be polled.
+* **snoop** passively observes DNS responses on the LAN side via AF_PACKET with a BPF filter matching **UDP source port 53** - answers, not questions - including one level of VLAN tagging. It covers CDN variance and wildcards, which poll cannot. It reads; it never writes anything back onto the wire and never sits between a client and its resolver. If it dies, DNS keeps working and only the policy stops applying.
+
+<a id="what-polling-costs"></a>
+### What polling costs
+
+"Polling" invites the assumption of waste, so here is the arithmetic. One cycle is a single call asking for A and AAAA of every listed name: two lookups per name per interval, against the **local** resolver. Ten names at the default 300 seconds is 240 lookups an hour - about what a dozen web page loads cost, on a network whose own DNS traffic runs to hundreds of answers in a few minutes. There is no polling of anything else: no interface scanning, no ruleset re-rendering, no periodic writes. An element is only rewritten when its remaining lifetime has dropped below half.
+
+Two costs worth knowing:
+
+* The query is synchronous. A name that does not resolve blocks the cycle until it times out (2s, one retry), which delays the service start noticeably if several are wrong. This is why an unresolvable name is reported by name.
+* For names with a TTL shorter than the interval the local cache has expired, so poll does refetch upstream rather than answering from cache.
+
+What that means in practice:
+
+* **Pick the device the answers cross on their way to the clients**, normally `br-lan`. It must be an **Ethernet type** device - a bridge, a VLAN device, a physical port, a wireless interface. A tunnel or PPP interface has no ethernet header, so neither the packet filter nor the decoder can read it, and the failure is silent: nothing matches, nothing is logged.
+* **Only the client-to-resolver leg matters, and only whether *it* is encrypted.** What the resolver does upstream is irrelevant: the usual OpenWrt setup - unbound or dnsmasq on the router, forwarding upstream over DoT or DoH
+ - is fully covered, because the client asked in plain text over the LAN and the answer comes back the same way.
+* **A client that speaks DoH or DoT itself is invisible**, because it bypasses the local resolver. That is the one encryption case that costs coverage.
+* **One entry per layer 2 segment.** A guest or IoT VLAN on its own device never carries the answers of the main LAN, so it needs its own `snoop_device` entry. A device that cannot be opened costs one warning; the others keep running.
+* **The router's own lookups are not seen.** poll's queries leave through the uplink, not the LAN device.
+* Not seen either: DNS over TCP, DNS on a port other than 53, and a second stacked VLAN tag.
+
+A name in a `domain` list that never resolves is reported once, by name and policy:
+
+```
+poll: www.example.com (policy vpn) has no address - the policy entry has no effect until it resolves
+```
+
+Once on the way in and once on recovery, never in between. The first cycle runs immediately at start, so a typo shows up within seconds. Wildcards cannot produce this message - nothing can tell whether `*.example.com` was ever meant to match anything.
+
+<a id="examples"></a>
+## Examples
+
+**One client, one domain family, over a wireguard tunnel**
+
+```
+config policy 'vpn'
+ option enabled '1'
+ option interface 'wg0'
+ option fallback 'main'
+ list src '192.168.1.50'
+ list domain 'example.com'
+ list domain '*.example.com'
+```
+
+**A whole IoT VLAN over a mobile uplink, killswitch on**
+
+The client is named by MAC, so it is covered in both address families without listing a rotating IPv6 address:
+
+```
+config global
+ list snoop_device 'br-lan'
+ list snoop_device 'br-iot'
+
+config policy 'iot'
+ option enabled '1'
+ option interface 'trm_wwan'
+ option fallback 'block'
+ list src_mac 'aa:bb:cc:dd:ee:ff'
+ list domain '*.vendor-cloud.com'
+```
+
+**The same domain for two client groups over two uplinks**
+
+Both policies claim `www.example.com`; each routes only its own clients:
+
+```
+config policy 'wwan'
+ option interface 'trm_wwan'
+ list src '10.168.30.70'
+ list domain 'www.example.com'
+
+config policy 'vpn'
+ option interface 'wg0'
+ list src '10.168.1.20'
+ list domain 'www.example.com'
+```
+
+**A destination range without any domain**
+
+```
+config policy 'office'
+ option interface 'wg0'
+ list src '192.168.1.0/24'
+ list dst '10.0.0.0/8'
+```
+
+<a id="what-it-shells-out-to"></a>
+### What it shells out to
+
+Almost nothing. The daemon and the rpcd backend work through ucode's native bindings - `fs`, `socket` for the AF_PACKET observer, `uci`, `uloop`, `ubus`, `resolv`, `rtnl` and `log` - and rpcd carries the LuCI side, so there is no shell glue, no `awk`, no temporary state files. Logging goes to syslog through the binding, not through a `logger` process per line.
+
+Two external commands remain:
+
+| Command | Why |
+| :--- | :--- |
+| `nft` | the ruleset is applied and read as one atomic batch; ucode has no nftables binding |
+| `ip` | routes and rules are written this way, although `rtnl` already reads them - replaceable |
+
+Nothing is ever handed to a shell for parsing: `system()` takes an argument array, and where stderr has to be captured the wrapper is `sh -c 'exec "$0" "$@"'`, which passes arguments through untouched. `popen()` only ever runs fixed command lines - `nft -f -` in the daemon and `nft -j list table` in the rpcd backend - so no configuration value or captured data reaches a command line.
+
+<a id="what-shunt-creates-on-the-system"></a>
+## What shunt creates on the system
+
+```
+table inet shunt own table, survives fw4 reloads
+ chain prerouting filter hook prerouting, priority mangle
+ chain output route hook output, priority mangle
+ set d4_<policy> / d6_<policy> learned, flags timeout, per-element counter
+ set s4_<policy> / s6_<policy> static dst, flags interval, counter
+ set c4_<policy> / c6_<policy> client src selectors, interval, counter
+ set m_<policy> client MACs, no family digit, counter
+
+fwmark <index> << 24, mask 0xff000000
+ip rule pref 31000 + <index>
+routing table 8000 + <index>
+/etc/iproute2/rt_tables.d/shunt.conf the table name mapping
+```
+
+The mark mask is fixed at `0xff000000`, which allows 255 policies. The `output` chain is `type route` so the router's own marked traffic is re-routed after the mark is set.
+
+Every set carries per-element counters, so "is this element ever hit" is one look at `nft list set inet shunt <set>` rather than a tcpdump session. The two kinds count different things: nftables tests a rule left to right, so a **client** set counts every packet that matched the selector, whether or not the destination matched afterwards; a **learned** set is the last lookup in the rule, so a hit there means the packet really was marked. A busy client beside learned addresses at zero is a client that has not visited any of the routed domains, not a fault.
+
+**Writes are batched, and the interval adapts.** `nft -f` reads the entire ruleset from the kernel before it resolves a single name, so on a box that also runs a tool with very large sets - banIP with 238k elements, measured - one `add element` costs seconds of CPU, and `nft --check` alone costs the same. That is a known bug in nftables (netfilter bugzilla #1735, open since 2024), not something shunt can fix, so observed addresses are collected and applied together by a timer.
+
+The interval follows what the last write actually cost, between 2 and 60 seconds: on an ordinary box a write takes milliseconds and the interval stays at its floor, where the batching is invisible. Where it is expensive the interval grows until nftables takes a bounded share of the machine instead of all of it, at the price of a learned address reaching its set later. Both numbers show up under `debug`.
+
+**A reload wipes learned state.** Applying the configuration destroys and re-creates the table atomically, so the learned sets start empty. poll rewarms them within one interval and snoop refills from live traffic; expect a short window after a restart where domain policies do not apply yet.
+
+<a id="coexistence-with-pbr-and-mwan3"></a>
+## Coexistence with pbr and mwan3
+shunt is an independent implementation, not a fork of `pbr` and not a drop-in for it - there is no config migration and no attempt at feature parity. Within its scope it is a full alternative.
+
+Running both at once during a migration is safe by construction:
+
+| | pbr | mwan3 | shunt |
+| :--- | :--- | :--- | :--- |
+| fwmark mask | `0x00ff0000` | `0x00003f00` | `0xff000000` |
+| ip rule pref | 30000 counting down | ~1001-3250 | 31000 counting up |
+| routing tables | dynamic from ~256 | 1-250 | 8000+n |
+| nft | chains in fw4's table | | own `inet shunt` table |
+
+The mark bits are disjoint and all three mask their writes. Where pbr and shunt both match, pbr's lower rule priority wins, deterministically. So move policies over one at a time and retire pbr once its config is empty.
+
+Anything shunt cannot see is worth knowing about: marks set via `SO_MARK` on a daemon socket (OpenVPN's `--mark`) or by an eBPF program are invisible to any inspection. If a box uses those, check the mark ranges by hand.
+
+<a id="troubleshooting-and-debug-options"></a>
+## Troubleshooting & debug options
+
+### Did the policy actually match?
+
+The authoritative check needs no route lookups at all:
+
+```sh
+nft reset counters table inet shunt
+# generate traffic from the client
+nft list chain inet shunt prerouting # rule counters moved?
+tcpdump -ni <policy-interface> host <addr> # the flow leaves where it should
+```
+
+The wire capture is ground truth - but capture a **learned address**, not everything: on a router whose policy interface is also a normal uplink, an unfiltered capture shows traffic that has nothing to do with shunt. An IP echo service is a convenient confirmation, but it only discriminates uplinks that actually have different exits.
+
+The route lookup variant asks the kernel directly:
+
+```sh
+ip route get <addr> mark 0x1000000
+ip route get <addr>
+```
+
+The first answer must name the policy table, the second the normal uplink.
+
+**The first line needs iproute2's `ip`**, because BusyBox's `route get` does not understand `mark` - one build rejects it outright, the OpenWrt one sends an incomplete netlink request that the kernel answers with `EINVAL`. shunt's `ip` dependency (`ip-tiny`) covers it: `route` and `rule` are complete there, the tiny build only strips exotic objects. The second line, without a mark, works with BusyBox too.
+
+Adding `from <client> iif br-lan` makes the lookup more precise, with one further catch worth a confused test session: **`iif` is not optional** there. Without it, `from` a non-local address makes the kernel validate a locally originated lookup and answer `ENETUNREACH` regardless of any table's content, which reads like broken routing and is not.
+
+### What the observer discards, and why
+
+Most DNS answers on a network are of no use to a routing policy, so snoop counts what it discarded and why. `ubus call shunt status` reports those counters, and the LuCI overview shows them with readable labels.
+
+| Verdict | Meaning |
+| :--- | :--- |
+| qtype | the question was not for an address at all |
+| noaddr | an address was asked for, the answer carried none |
+| nomatch | the name belongs to no policy |
+| dns:E_* | the message did not parse, e.g. `E_RCODE` for NXDOMAIN |
+| frame:E_* | the packet did not decode, e.g. `E_FRAG` for a fragment |
+
+`qtype` is usually the largest category and that is expected: current browsers and operating systems ask for **HTTPS records (type 65)** alongside every A and AAAA. `noaddr` is the other half of that distinction: the question *was* A or AAAA, the reply is well formed, and the answer section still holds no address - NODATA.
+
+The checks run in order and the **first** one wins, so these are "first reason to discard" rather than independent counters: a PTR query for a name you route counts as `qtype`, never as `nomatch`.
+
+A high discard count is therefore not a fault. The one number that says whether the observer is doing its job is the matched count next to them.
+
+### When a policy stops applying after a reconnect
+
+The kernel removes routes from a policy table when the interface goes down, after which the fwmark rule falls through to `main` while every counter keeps counting. shunt handles this on two levels: a ubus listener on `network.interface` rebuilds the route half on ifup/ifdown, and every poll tick replays the route commands as a keeper. Learned sets survive both. If ubus is unavailable, only the keeper remains, so recovery takes up to one `poll_interval`.
+
+### Debug logging
+
+```sh
+uci set shunt.@global[0].debug='1'
+uci commit shunt
+/etc/init.d/shunt restart
+logread -e shunt
+```
+
+Set it back to `0` afterwards. Every observed answer and every set write gets a line, which on a busy network is a lot.
+
+<a id="known-limitations"></a>
+## Known limitations
+These are consequences of the design, stated rather than worked around:
+
+* **Clients that speak DoH or DoT themselves are invisible to snoop.** poll still covers the names you list explicitly; wildcards do not work for those clients. A *resolver* forwarding upstream over DoT or DoH changes nothing.
+* **Wildcards require snoop.** poll can only resolve names it was given, and `*.example.com` is not a name.
+* **One CDN address serves many domains.** If a policy routes `example.com` and the address behind it also serves a thousand other sites, those sites follow the same policy. This is unsolvable at layer 3 by anything that routes on addresses.
+* **The first connection to a newly seen address takes the old path.** snoop learns from the response the client is reading at that moment, so the client's SYN is usually out before the element reaches the set. Measured on a live router: the entire first connection stayed on the normal uplink, and the next connection to the same host started on the policy interface. The switch happens at a connection boundary; shunt does not touch conntrack, so no established flow is ever yanked to a different exit mid-stream. Listing the entry point explicitly closes the gap, because poll warms it before any client asks.
+* **DNS over TCP is not observed.** Port 53 over TCP needs reassembly, which is out of scope; answers large enough to force TCP are rare in the traffic shunt cares about.
+* **Route and rule application is best effort.** At boot a tunnel interface may not exist yet. A rule over an empty table falls through to `main`, so the failure mode is "policy not applied yet", never "traffic broken". Each distinct reason is one warning line.
+* **No interface hotplug.** A device that appears later is picked up on the next `ifup` event or within one poll interval, not immediately.
+
+**Out of scope permanently:** resolver-integrated set population (dnsmasq `nftset`, AdGuard Home etc.). Being independent of the DNS backend is the entire point of the project, so adopting a backend-specific mechanism would give up the one property that distinguishes it. Also out: DSCP tagging and user include files.
+
+<a id="support"></a>
+## Support
+Please report issues with as much detail as possible - the output of `shunt check`, the relevant part of `logread -e shunt` with `debug` enabled, your `/etc/config/shunt`, and the OpenWrt version of the device. Please join the shunt discussion in this [forum thread](https://forum.openwrt.org/t/shunt-policy-based-routing-for-any-dns-backend/252748) or contact me by mail <dev@brenken.org>.
+
+<a id="removal"></a>
+## Removal
+Stop the service with `/etc/init.d/shunt stop`, which also tears down the nftables table, the routing tables and the ip rules, then remove the `shunt` and `luci-app-shunt` packages if necessary. `shunt flush` does the teardown alone, should anything be left behind.
+
+<a id="donations"></a>
+## Donations
+You like this project - is there a way to donate? Generally speaking "No" - I have a well-paying full-time job and my OpenWrt projects are just a hobby of mine in my spare time.
+
+If you still insist to donate some bucks ...
+* I would be happy if you put your money in kind into other, social projects in your area, e.g. a children's hospice
+* Let's meet and invite me for a coffee if you are in my area, the “Markgräfler Land” in southern Germany or in Switzerland (Basel)
+* Send your money to my [PayPal account](https://www.paypal.me/DirkBrenken) and I will collect your donations over the year to support various social projects in my area
+
+No matter what you decide - thank you very much for your support!
+
+Have fun!
+Dirk
--- /dev/null
+config global
+ option enabled '1'
+ option poll_interval '300'
+ option entry_ttl '1200'
+ option snoop '1'
+ option debug '0'
+ option rp_filter_manage '0'
+ list snoop_device 'br-lan'
+
+config policy 'vpn'
+ option enabled '0'
+ option interface 'wg0'
+ option fallback 'main'
+ list src '192.168.1.50'
+ list domain 'example.com'
+ list domain '*.example.com'
--- /dev/null
+#!/bin/sh /etc/rc.common
+# shunt - policy based routing
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
+
+START=95
+USE_PROCD=1
+
+shunt_init="/etc/init.d/shunt"
+
+if [ -z "${IPKG_INSTROOT}" ]; then
+ case "${action}" in
+ "stop")
+ "${shunt_init}" running || exit 0
+ ;;
+ esac
+fi
+
+start_service() {
+ procd_open_instance "shunt"
+ procd_set_param command /usr/sbin/shunt run
+ procd_set_param respawn 300 5 3
+ procd_set_param stdout 0
+ procd_set_param stderr 1
+ procd_close_instance
+}
+
+stop_service() {
+ /usr/sbin/shunt flush 2>/dev/null
+}
+
+service_triggers() {
+ procd_add_reload_trigger "shunt"
+}
+
+reload_service() {
+ restart
+}
--- /dev/null
+// shunt - rpcd backend for the LuCI frontend
+//
+// Stateless: renders the configuration through the same modules the daemon
+// uses and reads the rest from the kernel. The daemon is asked only for the
+// facts nothing outside its process can see.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+import { popen, readfile } from 'fs';
+
+const ubus = require('ubus');
+const rtnl = require('rtnl');
+
+// The rtnl constants hang off a `const` sub-object, not off the module - the
+// example in lib/rtnl.c's own header says otherwise and yields null.
+const RT = rtnl.const;
+
+import { load as config_load, parse as config_parse } from 'shunt.config';
+import { resolve as netifd_resolve } from 'shunt.netifd';
+import { compile as match_compile } from 'shunt.match';
+import { compile as nft_compile } from 'shunt.nft';
+import { compile as route_compile } from 'shunt.route';
+import { names as poll_names } from 'shunt.poll';
+
+const TABLE_FAMILY = 'inet';
+const TABLE_NAME = 'shunt';
+
+function daemon_status() {
+ let conn = ubus.connect();
+
+ if (!conn)
+ return null;
+
+ let r = conn.call('shunt', 'status');
+
+ return r ?? null;
+}
+
+// The same sequence as the daemon's build_state(), minus the logging: two
+// readings of one config file are how a status view starts to lie.
+function render() {
+ let sections = config_load();
+
+ if (sections == null)
+ return null;
+
+ let cfg = config_parse(sections);
+
+ if (!cfg)
+ return null;
+
+ let dump = null;
+ let conn = ubus.connect();
+
+ if (conn)
+ dump = conn.call('network.interface', 'dump');
+
+ cfg.policies = netifd_resolve(cfg.policies, dump);
+
+ let m = match_compile(cfg.policies);
+ let n = nft_compile(cfg.policies);
+ let r = route_compile(cfg.policies, n.marks);
+
+ return { cfg, matcher: m, nft: n, route: r };
+}
+
+// Policy devices whose marked traffic the kernel would drop: max(all, <dev>),
+// blocked only when all is strict (1) and the device is not loose itself. Same
+// logic as the daemon. When rp_filter_manage is on the daemon has already set
+// these to 2, so this reads back empty on its own.
+function rp_filter_blocked(policies) {
+ let rp = (k) => trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
+
+ if (rp('all') != '1')
+ return [];
+
+ let seen = {}, blocked = [];
+
+ for (let p in (policies ?? [])) {
+ let dev = p.interface;
+
+ if (!length(dev ?? '') || seen[dev])
+ continue;
+
+ seen[dev] = true;
+
+ let v = rp(dev);
+
+ // Absent device: no traffic, nothing dropped - not blocked. The
+ // daemon re-checks on ifup when it appears.
+ if (v == '')
+ continue;
+
+ if (v != '2')
+ push(blocked, dev);
+ }
+
+ return blocked;
+}
+
+// Same check the daemon logs, surfaced for the UI: which of all/default carry
+// strict reverse path filtering, which drops shunt's asymmetric traffic.
+function rp_filter_strict() {
+ let strict = [];
+
+ for (let key in [ 'all', 'default' ])
+ if (trim(readfile(`/proc/sys/net/ipv4/conf/${key}/rp_filter`) ?? '') == '1')
+ push(strict, key);
+
+ return strict;
+}
+
+function nft_table() {
+ let fh = popen(sprintf('nft -j list table %s %s 2>/dev/null',
+ TABLE_FAMILY, TABLE_NAME), 'r');
+
+ if (!fh)
+ return null;
+
+ let out = fh.read('all');
+
+ fh.close();
+
+ if (!length(out ?? ''))
+ return null;
+
+ let j = json(out);
+
+ return j?.nftables ? j : null;
+}
+
+function nft_sets(table) {
+ let out = {};
+
+ for (let item in (table?.nftables ?? [])) {
+ let s = item?.set;
+
+ if (!s?.name)
+ continue;
+
+ let elems = [];
+
+ for (let e in (s.elem ?? [])) {
+ let v = e?.elem ?? e;
+ let val = v?.val ?? v;
+
+ push(elems, {
+ addr: (type(val) == 'object') ? (val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) : null) : val,
+ expires: v?.expires,
+ packets: v?.counter?.packets,
+ bytes: v?.counter?.bytes
+ });
+ }
+
+ out[s.name] = elems;
+ }
+
+ return out;
+}
+
+function kernel_rules(marks) {
+ let want = {};
+
+ for (let m in (marks ?? []))
+ want[sprintf('%d', m.mark)] = m.name;
+
+ let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP,
+ { family: RT.AF_UNSPEC });
+
+ if (res == null)
+ return null;
+
+ let out = {};
+
+ for (let r in res) {
+ if (r?.fwmark == null)
+ continue;
+
+ let name = want[sprintf('%d', r.fwmark)];
+
+ if (!name)
+ continue;
+
+ if (!out[name])
+ out[name] = [];
+
+ push(out[name], {
+ family: r.family,
+ priority: r.priority,
+ table: r.table,
+ fwmark: r.fwmark,
+ fwmask: r.fwmask
+ });
+ }
+
+ return out;
+}
+
+function kernel_routes(marks) {
+ let out = {};
+
+ for (let m in (marks ?? [])) {
+ let n = 0;
+
+ for (let fam in [ RT.AF_INET, RT.AF_INET6 ]) {
+ let res = rtnl.request(RT.RTM_GETROUTE, RT.NLM_F_DUMP,
+ { family: fam, table: m.rt_table });
+
+ if (res == null) {
+ n = null;
+ break;
+ }
+
+ for (let r in res)
+ if (r?.table == m.rt_table)
+ n++;
+ }
+
+ out[m.name] = n;
+ }
+
+ return out;
+}
+
+return {
+ 'luci.shunt': {
+
+ status: {
+ args: {},
+ call: function(req) {
+ let st = render();
+
+ if (!st)
+ return { error: 'cannot read /etc/config/shunt' };
+
+ let svc = daemon_status();
+ let table = nft_table();
+ let rules = kernel_rules(st.nft.marks);
+ let routes = kernel_routes(st.nft.marks);
+ let policies = [];
+
+ for (let m in st.nft.marks) {
+ let p = null;
+
+ for (let c in st.cfg.policies)
+ if (c.name == m.name)
+ p = c;
+
+ push(policies, {
+ name: m.name,
+ mark: m.mark,
+ rt_table: m.rt_table,
+ rt_prio: m.rt_prio,
+ interface: p?.interface,
+ fallback: p?.fallback,
+ domains: length(p?.domains ?? []),
+ rules: rules ? length(rules[m.name] ?? []) : null,
+ routes: routes[m.name]
+ });
+ }
+
+ return {
+ running: (svc != null),
+ applied: (table != null),
+ service: svc,
+ global: st.cfg.global,
+ policies,
+ poll_names: length(poll_names(st.cfg.policies)),
+ rp_filter_blocked: rp_filter_blocked(st.cfg.policies),
+ rp_filter_strict: rp_filter_strict(),
+ issues: [
+ ...st.cfg.issues,
+ ...st.matcher.issues,
+ ...st.nft.issues,
+ ...st.route.issues
+ ]
+ };
+ }
+ },
+
+ sets: {
+ args: { policy: '' },
+ call: function(req) {
+ let table = nft_table();
+
+ if (!table)
+ return { sets: {} };
+
+ let all = nft_sets(table);
+ let want = req.args?.policy;
+
+ if (!length(want ?? ''))
+ return { sets: all };
+
+ let out = {};
+
+ for (let name in all) {
+ let at = (substr(name, 0, 1) == 'm') ? 2 : 3;
+
+ if (substr(name, at) == want)
+ out[name] = all[name];
+ }
+
+ return { sets: out };
+ }
+ }
+ }
+};
--- /dev/null
+#!/usr/bin/ucode
+// shunt - policy based routing daemon
+//
+// Reads the config, renders the ruleset and the routes, applies them, then
+// keeps the learned sets fed from a poll cycle and a passive DNS observer.
+// All decisions live in the modules; this file is wiring.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+import { popen, writefile, readfile, unlink, mkdir, lstat, error as fs_error } from 'fs';
+import { openlog, syslog, LOG_PID, LOG_DAEMON, LOG_ERR, LOG_WARNING,
+ LOG_NOTICE, LOG_INFO, LOG_DEBUG } from 'log';
+import { load as cfg_load, parse as cfg_parse } from 'shunt.config';
+import { compile as match_compile } from 'shunt.match';
+import { compile as nft_compile, refresh, teardown } from 'shunt.nft';
+import { compile as route_compile } from 'shunt.route';
+import { open as snoop_open, observe, RECV_LEN } from 'shunt.snoop';
+import { names as poll_names, plan as poll_plan,
+ addresses as poll_addresses,
+ index_results as poll_index } from 'shunt.poll';
+import { resolve as netifd_resolve } from 'shunt.netifd';
+import { create as dedupe_create } from 'shunt.dedupe';
+
+const RT_TABLES = '/etc/iproute2/rt_tables.d/shunt.conf';
+const TAG = 'shunt';
+
+let verbose = false;
+
+let dbg = false;
+
+// syslog(3) through the binding, not a `logger` process per line. '%s' as the
+// format because syslog() runs sprintf over its arguments, and an ip error
+// text can contain a percent sign.
+const PRIO = { err: LOG_ERR, warn: LOG_WARNING, notice: LOG_NOTICE,
+ info: LOG_INFO, debug: LOG_DEBUG };
+
+openlog(TAG, LOG_PID, LOG_DAEMON);
+
+function log(prio, msg) {
+ syslog(PRIO[prio] ?? LOG_NOTICE, '%s', msg);
+
+ if (verbose)
+ warn(sprintf('[%s] %s\n', prio, msg));
+}
+
+function debug(msg) {
+ if (dbg)
+ log('debug', msg);
+}
+
+let ubus_conn = null;
+
+function ubus() {
+ if (ubus_conn != null)
+ return ubus_conn;
+
+ try {
+ ubus_conn = require('ubus').connect();
+ }
+ catch (e) {
+ ubus_conn = false;
+ }
+
+ if (!ubus_conn)
+ log('warn', 'ubus unavailable - gateway discovery and interface events disabled');
+
+ return ubus_conn;
+}
+
+function netifd_dump() {
+ let c = ubus();
+ return c ? c.call('network.interface', 'dump') : null;
+}
+
+function load_config() {
+ let sections = cfg_load();
+
+ if (sections == null) {
+ log('err', 'ucode-mod-uci missing');
+ return null;
+ }
+
+ return cfg_parse(sections);
+}
+
+function report(kind, issues) {
+ for (let i in issues)
+ log('warn', sprintf('%s: %J', kind, i));
+}
+
+const RUN_DIR = '/tmp/.shunt';
+const RUN_ERR = RUN_DIR + '/cmd.err';
+
+function capture_ok() {
+ mkdir(RUN_DIR, 0o700);
+
+ let st = lstat(RUN_DIR);
+
+ return st != null && st.type == 'directory' && st.uid == 0 &&
+ !st.perm.group_write && !st.perm.other_write &&
+ !st.perm.group_read && !st.perm.other_read;
+}
+
+function loud(argv) {
+ if (!capture_ok())
+ return { rc: quiet(argv), err: '' };
+
+ let rc = system([ '/bin/sh', '-c',
+ sprintf('exec "$0" "$@" 2>%s', RUN_ERR), ...argv ]);
+ let err = '';
+
+ if (rc != 0)
+ err = replace(trim(readfile(RUN_ERR) ?? ''), /\s*\n\s*/g, '; ');
+
+ unlink(RUN_ERR);
+
+ return { rc, err };
+}
+
+// quiet() drops the child's stderr, loud() keeps it for the warning. Neither
+// may be called `run` - that name is the daemon's own entry point.
+function quiet(argv) {
+ return system([ '/bin/sh', '-c', 'exec "$0" "$@" 2>/dev/null', ...argv ]);
+}
+
+function nft_pipe(batch, what) {
+ let fh = popen('nft -f -', 'w');
+
+ if (!fh) {
+ log('err', sprintf('%s: cannot spawn nft: %s', what, fs_error()));
+ return false;
+ }
+
+ fh.write(batch);
+
+ let rc = fh.close();
+ if (rc != 0) {
+ log('err', sprintf('%s: nft exited %d', what, rc));
+ return false;
+ }
+
+ return true;
+}
+
+function apply(state) {
+ if (!nft_pipe(state.nft.setup, 'setup'))
+ return false;
+
+ if (length(state.route.rt_tables)) {
+ mkdir('/etc/iproute2/rt_tables.d', 0o755);
+ if (!writefile(RT_TABLES, state.route.rt_tables))
+ log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error()));
+ }
+
+ for (let argv in state.route.del)
+ quiet(argv);
+
+ let failed = 0;
+ let reasons = {};
+
+ for (let argv in state.route.add) {
+ let r = loud(argv);
+
+ if (r.rc != 0) {
+ let why = length(r.err) ? r.err : sprintf('exit %d', r.rc);
+
+ failed++;
+ reasons[why] = (reasons[why] ?? 0) + 1;
+ debug(sprintf('not applied: %s - %s', join(' ', argv), why));
+ }
+ }
+
+ for (let why in reasons)
+ log('warn', sprintf('%d of %d route/rule command(s) not applied - %s',
+ reasons[why], length(state.route.add), why));
+
+ if (failed)
+ log('warn', 'policy not applied yet - traffic falls through to main; restart once the interface is up');
+
+ return true;
+}
+
+function flush(state) {
+ if (state)
+ for (let argv in state.route.del)
+ quiet(argv);
+
+ nft_pipe(teardown(), 'teardown');
+
+ if (readfile(RT_TABLES) != null)
+ unlink(RT_TABLES);
+}
+
+function rp_read(k) {
+ return trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
+}
+
+// Distinct, existing policy devices. Deduped so a device shared by several
+// policies is set or reported once.
+function policy_devices(policies) {
+ let seen = {}, out = [];
+
+ for (let p in (policies ?? [])) {
+ let dev = p.interface;
+
+ if (length(dev ?? '') && !seen[dev]) {
+ seen[dev] = true;
+ push(out, dev);
+ }
+ }
+
+ return out;
+}
+
+// Which policy devices the kernel would drop marked traffic on. rp_filter
+// takes max(conf.all, conf.<dev>), so a device is blocked only when all is
+// strict (1 - 0 is off, 2 is loose) AND the device is not loosened itself. A
+// device with no /proc entry does not exist yet and inherits default.
+function rp_filter_blocked(policies) {
+ if (rp_read('all') != '1')
+ return [];
+
+ let blocked = [];
+
+ for (let dev in policy_devices(policies)) {
+ let v = rp_read(dev);
+
+ if (v == '')
+ continue;
+
+ if (v != '2')
+ push(blocked, dev);
+ }
+
+ return blocked;
+}
+
+// With rp_filter_manage set, shunt loosens rp_filter on its own policy devices
+// - the per-interface fix the README documents, done automatically. Bounded to
+// exactly the devices shunt routes into, never all/default, and only on a
+// device that exists. Off by default: changing a security setting is opt-in.
+function rp_filter_apply(policies) {
+ for (let dev in policy_devices(policies))
+ if (rp_read(dev) != '' && rp_read(dev) != '2')
+ loud([ 'sysctl', '-w', sprintf('net.ipv4.conf.%s.rp_filter=2', dev) ]);
+}
+
+// Reads the live /proc value, so when rp_filter_apply has done its job the
+// list is empty on its own - no need to consult the switch a second time.
+function check_rp_filter(policies) {
+ for (let dev in rp_filter_blocked(policies))
+ log('warn', sprintf('rp_filter is strict on %s - shunt\'s marked traffic will be dropped there; set net.ipv4.conf.%s.rp_filter=2 or enable rp_filter_manage, see the README',
+ dev, dev));
+}
+
+// silent: build only what a teardown consumes and say nothing about the
+// configuration - flush() needs route.del and nothing else.
+function build_state(silent) {
+ let cfg = load_config();
+ if (!cfg)
+ return null;
+
+ if (!silent)
+ report('config', cfg.issues);
+
+ cfg.policies = netifd_resolve(cfg.policies, netifd_dump());
+
+ let matcher = null;
+
+ if (!silent) {
+ matcher = match_compile(cfg.policies);
+ report('domain', matcher.issues);
+ }
+
+ let n = nft_compile(cfg.policies);
+ if (!silent)
+ report('nft', n.issues);
+
+ let r = route_compile(cfg.policies, n.marks);
+ if (!silent)
+ report('route', r.issues);
+
+ return { cfg, matcher, nft: n, route: r };
+}
+
+// nft -f reads the entire ruleset before resolving a single name, so on a box
+// with large sets from another tool one add element costs seconds of CPU.
+// Writes are collected and applied by a timer whose interval follows the
+// measured cost: an ordinary box stays at the floor, an expensive one backs
+// off.
+const WRITE_MIN = 2;
+const WRITE_MAX = 60;
+const WRITE_FACTOR = 3;
+
+function queue_writes(st, writes, now) {
+ for (let w in writes)
+ if (st.state.nft.learn[w.set] && st.cache.due(w.set, w.addr, now))
+ st.pending[`${w.set}/${w.addr}`] = w;
+}
+
+function drain_writes(st) {
+ let due = values(st.pending);
+
+ st.pending = {};
+
+ if (!length(due))
+ return;
+
+ let r = refresh(due, st.state.cfg.global.entry_ttl);
+ report('refresh', r.issues);
+
+ if (!length(r.batch))
+ return;
+
+ let t0 = time();
+ let ok = nft_pipe(r.batch, 'refresh');
+ let cost = time() - t0;
+
+ if (ok)
+ debug(sprintf('%d element(s) written in %ds', length(due), cost));
+
+ let want = cost * WRITE_FACTOR;
+
+ if (want < WRITE_MIN)
+ want = WRITE_MIN;
+ if (want > WRITE_MAX)
+ want = WRITE_MAX;
+
+ if (want != st.interval) {
+ debug(sprintf('write interval %ds -> %ds (last write %ds)',
+ st.interval, want, cost));
+ st.interval = want;
+ st.timer.set(want * 1000);
+ }
+}
+
+function run() {
+ let uloop, resolv;
+
+ try {
+ uloop = require('uloop');
+ }
+ catch (e) {
+ log('err', 'ucode-mod-uloop missing');
+ return 1;
+ }
+
+ let state = build_state();
+ if (!state)
+ return 2;
+
+ dbg = verbose || state.cfg.global.debug;
+
+ if (!state.cfg.global.enabled) {
+ log('notice', 'disabled in config');
+ return 0;
+ }
+
+ if (!length(state.nft.marks)) {
+ log('err', 'no usable policy - not starting, run `shunt check` for the reasons');
+ return 2;
+ }
+
+ if (state.cfg.global.rp_filter_manage)
+ rp_filter_apply(state.cfg.policies);
+
+ check_rp_filter(state.cfg.policies);
+
+ if (!apply(state)) {
+ flush(state);
+ return 1;
+ }
+
+ let cache = dedupe_create(state.cfg.global.entry_ttl);
+ let targets = poll_names(state.cfg.policies);
+
+ let stats = { started: time(), resolv: false, snoop: [],
+ matched: 0, drops: {} };
+
+ try {
+ resolv = require('resolv');
+ }
+ catch (e) {
+ resolv = null;
+ if (length(targets))
+ log('warn', 'ucode-mod-resolv missing - poll disabled, snoop only');
+ }
+
+ stats.resolv = (resolv != null);
+
+ let unresolved = {};
+
+ let wq = { state, cache, pending: {}, interval: WRITE_MIN, timer: null };
+
+ function poll_cycle() {
+ if (!resolv || !length(targets))
+ return;
+
+ let res = resolv.query(targets, { type: [ 'A', 'AAAA' ],
+ timeout: 2000, retries: 1 });
+ if (!res) {
+ log('warn', 'poll: query failed');
+ return;
+ }
+
+ let by = poll_index(res);
+
+ for (let name in targets) {
+ let got = poll_addresses(by, name);
+ let n = length(got.a) + length(got.aaaa);
+
+ if (n && unresolved[name]) {
+ unresolved[name] = false;
+ log('info', sprintf('poll: %s resolves again', name));
+ }
+ else if (!n && !unresolved[name]) {
+ let owners = state.matcher.test(name);
+
+ unresolved[name] = true;
+ log('warn', sprintf('poll: %s%s has no address - the policy entry has no effect until it resolves',
+ name, owners != null
+ ? sprintf(' (policy %s)', join(', ', owners)) : ''));
+ }
+ }
+
+ queue_writes(wq, poll_plan(res, state.matcher, targets), time());
+ }
+
+ // The cache is pruned here and not at the tail of poll_cycle():
+ // poll_cycle() returns early without resolv, without pollable names
+ // and on a failed query, while snoop keeps feeding queue_writes() in
+ // all three cases.
+ function tick() {
+ for (let argv in state.route.add)
+ quiet(argv);
+
+ poll_cycle();
+ cache.prune(time());
+ }
+
+ wq.timer = uloop.interval(WRITE_MIN * 1000, () => drain_writes(wq));
+
+ poll_cycle();
+ uloop.interval(state.cfg.global.poll_interval * 1000, tick);
+
+ if (resolv && length(targets))
+ log('info', sprintf('poll: %d name(s) every %ds', length(targets),
+ state.cfg.global.poll_interval));
+
+ let c = ubus();
+
+ if (c) {
+ let pending = null;
+
+ function rebuild_routes() {
+ pending = null;
+
+ let resolved = netifd_resolve(state.cfg.policies, netifd_dump());
+ let r = route_compile(resolved, state.nft.marks);
+
+ report('route', r.issues);
+
+ // A policy device may have just appeared - the boot-time case a
+ // static sysctl.d file misses, since /proc/<dev> did not exist
+ // yet. Re-apply so it is loose from the moment it comes up, then
+ // re-check: with manage on the check reads the value just set
+ // and stays silent, without it this is the moment to warn.
+ if (state.cfg.global.rp_filter_manage)
+ rp_filter_apply(resolved);
+
+ check_rp_filter(resolved);
+
+ for (let argv in state.route.del)
+ quiet(argv);
+ for (let argv in r.add)
+ quiet(argv);
+
+ state.route = r;
+ }
+
+ c.listener('network.interface', (type, msg) => {
+ if (msg?.action != 'ifup' && msg?.action != 'ifdown')
+ return;
+
+ log('info', sprintf('%s %s - rebuilding routes',
+ msg.action, msg.interface ?? '?'));
+
+ if (pending)
+ pending.set(500);
+ else
+ pending = uloop.timer(500, rebuild_routes);
+ });
+
+ // Must be total: an exception in a ubus handler halts uloop and takes
+ // snoop, poll and the keeper down with the reply.
+ function status_reply() {
+ let names = [];
+
+ for (let m in state.nft.marks)
+ push(names, m.name);
+
+ return {
+ started: stats.started,
+ policies: names,
+ poll: {
+ resolv: stats.resolv,
+ names: length(targets),
+ interval: state.cfg.global.poll_interval
+ },
+ snoop: {
+ devices: stats.snoop,
+ matched: stats.matched,
+ drops: stats.drops
+ },
+ dedupe: cache.size()
+ };
+ }
+
+ let obj = c.publish('shunt', { status: { call: () => status_reply() } });
+
+ if (!obj)
+ log('warn', sprintf('cannot publish ubus object: %s',
+ require('ubus').error() ?? 'unknown'));
+ }
+
+ let socks = [];
+
+ if (state.cfg.global.snoop) {
+ let socket = null;
+
+ for (let dev in state.cfg.global.snoop_devices) {
+ let s = snoop_open(dev);
+
+ if (!s.ok) {
+ log('err', sprintf('snoop %s: %s', dev, s.err));
+ continue;
+ }
+
+ if (socket == null)
+ socket = require('socket');
+
+ let sock = s.sock;
+
+ push(socks, sock);
+ push(stats.snoop, dev);
+
+ // Each handler closes over its own socket; binding the loop
+ // variable would leave them all reading the last one opened.
+ uloop.handle(sock, () => {
+ let frame;
+
+ while ((frame = sock.recv(RECV_LEN, socket.MSG_DONTWAIT)) != null) {
+ let v = observe(frame, state.matcher);
+
+ if (v.drop != null) {
+ stats.drops[v.drop] = (stats.drops[v.drop] ?? 0) + 1;
+ continue;
+ }
+
+ stats.matched++;
+
+ let writes = [];
+ for (let policy in v.policies) {
+ for (let a in v.a)
+ push(writes, { set: `d4_${policy}`, addr: a });
+ for (let a in v.aaaa)
+ push(writes, { set: `d6_${policy}`, addr: a });
+ }
+
+ debug(sprintf('snoop: %s -> %s (%d addr)',
+ v.qname, join(', ', v.policies), length(writes)));
+ queue_writes(wq, writes, time());
+ }
+ }, uloop.ULOOP_READ);
+ }
+
+ if (length(socks))
+ log('info', sprintf('snoop: listening on %s',
+ join(', ', stats.snoop)));
+ else if (!resolv || !length(targets)) {
+ log('err', 'neither snoop nor poll available - nothing to do');
+ flush(state);
+ return 1;
+ }
+ }
+
+ log('notice', sprintf('started: %d polic%s, mask 0x%08x',
+ length(state.nft.marks), length(state.nft.marks) == 1 ? 'y' : 'ies',
+ 0xff000000));
+
+ uloop.run();
+
+ log('notice', 'stopping');
+ for (let sock in socks)
+ sock.close();
+ flush(state);
+
+ return 0;
+}
+
+function check() {
+ verbose = true;
+
+ let state = build_state();
+ if (!state)
+ return 2;
+
+ printf('global: %.2J\n', state.cfg.global);
+ printf('policies: %d accepted, %d mark(s)\n',
+ length(state.cfg.policies), length(state.nft.marks));
+
+ for (let m in state.nft.marks)
+ printf(' %-16s mark 0x%08x table %d pref %d\n',
+ m.name, m.mark, m.rt_table, m.rt_prio);
+
+ let total = length(state.matcher.issues) + length(state.nft.issues) +
+ length(state.route.issues);
+
+ printf('issues: %d (see above)\n', total);
+ printf('poll: %d name(s)\n', length(poll_names(state.cfg.policies)));
+
+ return length(state.nft.marks) ? 0 : 2;
+}
+
+let cmd = null;
+
+for (let a in ARGV) {
+ if (a == '-v')
+ verbose = dbg = true;
+ else if (cmd == null)
+ cmd = a;
+}
+
+switch (cmd) {
+case 'run':
+ exit(run());
+case 'check':
+ exit(check());
+case 'flush':
+ flush(build_state(true));
+ exit(0);
+default:
+ warn('usage: shunt [-v] run|check|flush\n');
+ exit(2);
+}
--- /dev/null
+// shunt - configuration
+//
+// Turns UCI shaped sections into the structures the other modules consume.
+// load() reads UCI, parse() is pure and fed literals by the tests.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+export const DEFAULTS = {
+ enabled: true,
+ poll_interval: 300,
+ entry_ttl: 1200,
+ snoop: true,
+ snoop_devices: [ 'br-lan' ],
+ debug: false,
+ rp_filter_manage: false
+};
+
+export const MIN = {
+ poll_interval: 30,
+ entry_ttl: 60
+};
+
+function to_bool(v, dflt) {
+ if (v == null)
+ return dflt;
+ if (v === true || v === false)
+ return v;
+ if (v == '1' || v == 1)
+ return true;
+ if (v == '0' || v == 0)
+ return false;
+ return null;
+}
+
+function uniq(list) {
+ let seen = {};
+ let out = [];
+
+ for (let v in list) {
+ if (!seen[v]) {
+ seen[v] = true;
+ push(out, v);
+ }
+ }
+
+ return out;
+}
+
+function to_list(v) {
+ if (v == null)
+ return [];
+ if (type(v) == 'array')
+ return v;
+ return [ v ];
+}
+
+// require('uci') sits inside so the module stays importable without it, and
+// parse() stays pure for the tests.
+export function load() {
+ let uci;
+
+ try {
+ uci = require('uci');
+ }
+ catch (e) {
+ return null;
+ }
+
+ let sections = [];
+
+ uci.cursor().foreach('shunt', null, (s) => {
+ let values = {};
+
+ for (let k in s)
+ if (substr(k, 0, 1) != '.')
+ values[k] = s[k];
+
+ push(sections, { type: s['.type'], name: s['.name'], values });
+ });
+
+ return sections;
+};
+
+export function parse(sections) {
+ let g = { ...DEFAULTS };
+ let policies = [], issues = [];
+
+ function reject(section, option, reason) {
+ push(issues, { section, option, reason });
+ }
+
+ function num_opt(section, values, key) {
+ let v = values[key];
+ if (v == null)
+ return;
+
+ let n = +v;
+ if (type(v) == 'string' && match(v, /^[0-9]+$/) == null || n != n) {
+ reject(section, key, sprintf('not a number: %J, default %d kept',
+ v, g[key]));
+ return;
+ }
+ if (n < MIN[key]) {
+ reject(section, key, sprintf('%d below minimum, clamped to %d',
+ n, MIN[key]));
+ n = MIN[key];
+ }
+ g[key] = n;
+ }
+
+ function bool_opt(section, values, key) {
+ let b = to_bool(values[key], g[key]);
+ if (b === null) {
+ reject(section, key, sprintf('not a boolean: %J, default kept',
+ values[key]));
+ return;
+ }
+ g[key] = b;
+ }
+
+ for (let s in (sections ?? [])) {
+ if (s?.type == 'global') {
+ let v = s.values ?? {};
+
+ bool_opt(s.name ?? 'global', v, 'enabled');
+ bool_opt(s.name ?? 'global', v, 'snoop');
+ bool_opt(s.name ?? 'global', v, 'debug');
+ bool_opt(s.name ?? 'global', v, 'rp_filter_manage');
+ num_opt(s.name ?? 'global', v, 'poll_interval');
+ num_opt(s.name ?? 'global', v, 'entry_ttl');
+
+ if (v.snoop_device != null) {
+ let devs = [];
+
+ for (let d in to_list(v.snoop_device)) {
+ if (type(d) == 'string' && length(d))
+ push(devs, d);
+ else
+ reject(s.name ?? 'global', 'snoop_device',
+ sprintf('not a device name: %J, entry dropped', d));
+ }
+
+ if (length(devs))
+ g.snoop_devices = uniq(devs);
+ else
+ reject(s.name ?? 'global', 'snoop_device',
+ 'no usable device, default kept');
+ }
+ continue;
+ }
+
+ if (s?.type != 'policy')
+ continue;
+
+ let v = s.values ?? {};
+
+ if (to_bool(v.enabled, true) !== true)
+ continue;
+
+ push(policies, {
+ name: s.name,
+ interface: v.interface,
+ fallback: v.fallback,
+ gw4: v.gw4,
+ gw6: v.gw6,
+ src: to_list(v.src),
+ src_mac: to_list(v.src_mac),
+ dport: to_list(v.dport),
+ proto: to_list(v.proto),
+ dst: to_list(v.dst),
+ domains: to_list(v.domain)
+ });
+ }
+
+ return { global: g, policies, issues };
+};
--- /dev/null
+// shunt - write suppression
+//
+// Remembers which (set, address) pairs were written recently so a repeated
+// DNS answer does not rewrite an element that is still fresh.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+export function create(entry_ttl) {
+ let last = {};
+
+ function due(set, addr, now) {
+ let k = `${set}/${addr}`;
+ let t = last[k];
+
+ if (t != null && (now - t) * 2 < entry_ttl)
+ return false;
+
+ last[k] = now;
+ return true;
+ }
+
+ function prune(now) {
+ let n = 0;
+
+ for (let k in last) {
+ if (now - last[k] >= entry_ttl) {
+ delete last[k];
+ n++;
+ }
+ }
+
+ return n;
+ }
+
+ function size() {
+ return length(keys(last));
+ }
+
+ return { due, prune, size };
+};
--- /dev/null
+// shunt - DNS message parser
+//
+// Parses a response far enough to answer: which name was asked for, and
+// which A/AAAA addresses came back. Never trusts a length off the wire.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+// Hard limits: message size, name length, answers processed per message.
+export const LIM = {
+ msg: 4096,
+ labels: 63,
+ name: 255,
+ answers: 64
+};
+
+export const TYPE = {
+ A: 1,
+ NS: 2,
+ CNAME: 5,
+ SOA: 6,
+ TXT: 16,
+ AAAA: 28,
+ OPT: 41
+};
+
+// Parser verdicts. These identifiers are contract - fixtures and the LuCI
+// labels compare them verbatim.
+export const ERR = {
+ SHORT: 'E_SHORT',
+ MSGLEN: 'E_MSGLEN',
+ NOTRESP: 'E_NOTRESP',
+ TRUNC: 'E_TRUNC',
+ RCODE: 'E_RCODE',
+ QDCOUNT: 'E_QDCOUNT',
+ QPTR: 'E_QPTR',
+ LABEL: 'E_LABEL',
+ NAMELEN: 'E_NAMELEN',
+ CHARSET: 'E_CHARSET',
+ RDLEN: 'E_RDLEN',
+ ANSMAX: 'E_ANSMAX'
+};
+
+const HDR_LEN = 12;
+const RR_FIXED = 10;
+
+const F_QR = 0x8000;
+const F_TC = 0x0200;
+const M_RCODE = 0x000f;
+
+const LBL_MASK = 0xc0;
+const LBL_PTR = 0xc0;
+
+function u16at(buf, off) {
+ return (ord(buf, off) << 8) | ord(buf, off + 1);
+}
+
+function fmt4(buf, off) {
+ return sprintf('%d.%d.%d.%d',
+ ord(buf, off), ord(buf, off + 1),
+ ord(buf, off + 2), ord(buf, off + 3));
+}
+
+function fmt6(buf, off) {
+ let g = [];
+ for (let i = 0; i < 8; i++)
+ push(g, u16at(buf, off + i * 2));
+
+ let bs = -1, bl = 0, cs = -1, cl = 0;
+ for (let i = 0; i < 8; i++) {
+ if (g[i] != 0) {
+ cs = -1;
+ cl = 0;
+ continue;
+ }
+ if (cs < 0)
+ cs = i;
+ cl++;
+ if (cl > bl) {
+ bs = cs;
+ bl = cl;
+ }
+ }
+
+ if (bl < 2) {
+ bs = -1;
+ bl = 0;
+ }
+
+ let parts = [], i = 0;
+ while (i < 8) {
+ if (i == bs) {
+ push(parts, '');
+ i += bl;
+ continue;
+ }
+ push(parts, sprintf('%x', g[i]));
+ i++;
+ }
+
+ let out = join(':', parts);
+
+ if (bs == 0)
+ out = ':' + out;
+ if (bs >= 0 && bs + bl == 8)
+ out = out + ':';
+
+ return out;
+}
+
+export function decode_name(buf, off) {
+ let blen = length(buf), labels = [], total = 1;
+
+ while (true) {
+ if (off >= blen)
+ return { err: ERR.SHORT };
+
+ let len = ord(buf, off);
+
+ if ((len & LBL_MASK) == LBL_PTR)
+ return { err: ERR.QPTR };
+ if (len & LBL_MASK)
+ return { err: ERR.LABEL };
+
+ off++;
+ if (!len)
+ break;
+
+ total += len + 1;
+ if (total > LIM.name)
+ return { err: ERR.NAMELEN };
+ if (off + len > blen)
+ return { err: ERR.SHORT };
+
+ let lbl = lc(substr(buf, off, len));
+ for (let i = 0; i < len; i++) {
+ let c = ord(lbl, i);
+ if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) ||
+ c == 0x2d || c == 0x5f)
+ continue;
+ return { err: ERR.CHARSET };
+ }
+
+ push(labels, lbl);
+ off += len;
+ }
+
+ return { name: join('.', labels), next: off };
+};
+
+export function skip_name(buf, off) {
+ let blen = length(buf);
+
+ while (true) {
+ if (off >= blen)
+ return null;
+
+ let len = ord(buf, off);
+
+ if ((len & LBL_MASK) == LBL_PTR)
+ return (off + 2 <= blen) ? off + 2 : null;
+ if (len & LBL_MASK)
+ return null;
+
+ off++;
+ if (!len)
+ return off;
+
+ off += len;
+ if (off > blen)
+ return null;
+ }
+};
+
+export function parse(buf) {
+ let blen = length(buf ?? '');
+
+ if (blen > LIM.msg)
+ return { ok: false, err: ERR.MSGLEN };
+ if (blen < HDR_LEN)
+ return { ok: false, err: ERR.SHORT };
+
+ let flags = u16at(buf, 2);
+
+ if (!(flags & F_QR))
+ return { ok: false, err: ERR.NOTRESP };
+ if (flags & F_TC)
+ return { ok: false, err: ERR.TRUNC };
+ if (flags & M_RCODE)
+ return { ok: false, err: ERR.RCODE };
+
+ if (u16at(buf, 4) != 1)
+ return { ok: false, err: ERR.QDCOUNT };
+
+ let ancount = u16at(buf, 6);
+ if (ancount > LIM.answers)
+ return { ok: false, err: ERR.ANSMAX };
+
+ let q = decode_name(buf, HDR_LEN);
+ if (q.err)
+ return { ok: false, err: q.err };
+
+ let off = q.next;
+ if (off + 4 > blen)
+ return { ok: false, err: ERR.SHORT };
+
+ let qtype = u16at(buf, off);
+ off += 4;
+
+ let a = [], aaaa = [];
+
+ for (let i = 0; i < ancount; i++) {
+ off = skip_name(buf, off);
+ if (off === null)
+ return { ok: false, err: ERR.SHORT };
+
+ if (off + RR_FIXED > blen)
+ return { ok: false, err: ERR.SHORT };
+
+ let rtype = u16at(buf, off);
+ let rdlen = u16at(buf, off + 8);
+ off += RR_FIXED;
+
+ if (off + rdlen > blen)
+ return { ok: false, err: ERR.RDLEN };
+
+ if (rtype == TYPE.A) {
+ if (rdlen != 4)
+ return { ok: false, err: ERR.RDLEN };
+ push(a, fmt4(buf, off));
+ }
+ else if (rtype == TYPE.AAAA) {
+ if (rdlen != 16)
+ return { ok: false, err: ERR.RDLEN };
+ push(aaaa, fmt6(buf, off));
+ }
+
+ off += rdlen;
+ }
+
+ return {
+ ok: true,
+ id: u16at(buf, 0),
+ qname: q.name,
+ qtype,
+ a,
+ aaaa
+ };
+};
--- /dev/null
+// shunt - link layer decoder
+//
+// Ethernet, optional VLAN tag, IPv4/IPv6, UDP - down to the DNS payload.
+// Rejects anything malformed rather than guessing.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+// How far to walk before giving up: stacked VLAN tags, IPv6 extension headers.
+export const LIM = {
+ vlan: 3,
+ ext: 8
+};
+
+// Decoder verdicts, contract like the parser's.
+export const ERR = {
+ SHORT: 'E_SHORT',
+ ETHER: 'E_ETHER',
+ VLAN: 'E_VLAN',
+ IPLEN: 'E_IPLEN',
+ FRAG: 'E_FRAG',
+ EXTHDR: 'E_EXTHDR',
+ PROTO: 'E_PROTO',
+ UDPLEN: 'E_UDPLEN'
+};
+
+const ETH_HDR = 14;
+const ETYPE_OFF = 12;
+const VLAN_TAG = 4;
+
+const ET_IPV4 = 0x0800;
+const ET_IPV6 = 0x86dd;
+const ET_VLAN = 0x8100;
+const ET_QINQ = 0x88a8;
+
+const IP4_MIN = 20;
+const IP6_HDR = 40;
+const EXT_MIN = 8;
+const UDP_HDR = 8;
+
+const IP_UDP = 17;
+const IP_FRAG = 44;
+const IP_AH = 51;
+
+const IP4_FRAG_MASK = 0x3fff;
+
+function u16(buf, off) {
+ return (ord(buf, off) << 8) | ord(buf, off + 1);
+}
+
+function is_ext(proto) {
+ return proto == 0 || proto == 43 || proto == 60 || proto == IP_AH;
+}
+
+export function decap(buf) {
+ let len = length(buf ?? '');
+
+ if (len < ETH_HDR)
+ return { ok: false, err: ERR.SHORT };
+
+ let off = ETYPE_OFF, et = u16(buf, off), tags = 0;
+
+ while (et == ET_VLAN || et == ET_QINQ) {
+ if (++tags > LIM.vlan)
+ return { ok: false, err: ERR.VLAN };
+
+ off += VLAN_TAG;
+ if (off + 2 > len)
+ return { ok: false, err: ERR.SHORT };
+
+ et = u16(buf, off);
+ }
+
+ off += 2;
+
+ let af, proto;
+
+ if (et == ET_IPV4) {
+ if (off + IP4_MIN > len)
+ return { ok: false, err: ERR.SHORT };
+
+ let ihl = (ord(buf, off) & 0x0f) * 4;
+ if (ihl < IP4_MIN)
+ return { ok: false, err: ERR.IPLEN };
+ if (off + ihl > len)
+ return { ok: false, err: ERR.SHORT };
+
+ let tot = u16(buf, off + 2);
+ if (tot < ihl)
+ return { ok: false, err: ERR.IPLEN };
+ if (off + tot > len)
+ return { ok: false, err: ERR.SHORT };
+
+ len = off + tot;
+
+ if (u16(buf, off + 6) & IP4_FRAG_MASK)
+ return { ok: false, err: ERR.FRAG };
+
+ proto = ord(buf, off + 9);
+ af = 4;
+ off += ihl;
+ }
+ else if (et == ET_IPV6) {
+ if (off + IP6_HDR > len)
+ return { ok: false, err: ERR.SHORT };
+
+ let plen = u16(buf, off + 4);
+ if (off + IP6_HDR + plen > len)
+ return { ok: false, err: ERR.SHORT };
+
+ len = off + IP6_HDR + plen;
+
+ proto = ord(buf, off + 6);
+ af = 6;
+ off += IP6_HDR;
+
+ for (let i = 0; i < LIM.ext && is_ext(proto); i++) {
+ if (off + EXT_MIN > len)
+ return { ok: false, err: ERR.SHORT };
+
+ let hlen = (proto == IP_AH)
+ ? (ord(buf, off + 1) + 2) * 4
+ : (ord(buf, off + 1) + 1) * 8;
+
+ proto = ord(buf, off);
+ off += hlen;
+ }
+
+ if (proto == IP_FRAG)
+ return { ok: false, err: ERR.FRAG };
+ if (is_ext(proto))
+ return { ok: false, err: ERR.EXTHDR };
+ }
+ else
+ return { ok: false, err: ERR.ETHER };
+
+ if (proto != IP_UDP)
+ return { ok: false, err: ERR.PROTO };
+ if (off + UDP_HDR > len)
+ return { ok: false, err: ERR.SHORT };
+
+ let sport = u16(buf, off);
+ let ulen = u16(buf, off + 4);
+
+ if (ulen < UDP_HDR || off + ulen > len)
+ return { ok: false, err: ERR.UDPLEN };
+
+ return {
+ ok: true,
+ af,
+ sport,
+ payload: substr(buf, off + UDP_HDR, ulen - UDP_HDR)
+ };
+};
--- /dev/null
+// shunt - domain matcher
+//
+// Compiles the policies' domain patterns into an exact and a wildcard map
+// and answers which policies claim a queried name.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+export const LIM = {
+ name: 253,
+ label: 63
+};
+
+function ok_label(s) {
+ if (!length(s) || length(s) > LIM.label)
+ return false;
+
+ for (let i = 0; i < length(s); i++) {
+ let c = ord(s, i);
+ if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) ||
+ c == 0x2d || c == 0x5f)
+ continue;
+ return false;
+ }
+
+ return true;
+}
+
+export function normalize(s) {
+ s = lc(trim(s ?? ''));
+
+ while (length(s) && substr(s, -1) == '.')
+ s = substr(s, 0, length(s) - 1);
+
+ return s;
+};
+
+function validate(name) {
+ if (!length(name))
+ return 'empty';
+ if (length(name) > LIM.name)
+ return 'too long';
+
+ for (let l in split(name, '.'))
+ if (!ok_label(l))
+ return `bad label '${l}'`;
+
+ return null;
+}
+
+export function compile(policies) {
+ let exact = {}, wild = {}, issues = [];
+
+ function reject(policy, pattern, reason) {
+ push(issues, { policy, pattern, reason });
+ }
+
+ for (let pi = 0; pi < length(policies ?? []); pi++) {
+ let p = policies[pi];
+ let pname = p?.name ?? `#${pi}`;
+
+ for (let raw in (p?.domains ?? [])) {
+ let pat = normalize(raw);
+ let is_wild = false;
+
+ if (substr(pat, 0, 2) == '*.') {
+ is_wild = true;
+ pat = substr(pat, 2);
+ }
+
+ if (index(pat, '*') >= 0) {
+ reject(pname, raw, 'wildcard only allowed as leading *. label');
+ continue;
+ }
+
+ let bad = validate(pat);
+ if (bad) {
+ reject(pname, raw, bad);
+ continue;
+ }
+
+ let map = is_wild ? wild : exact;
+
+ if (!map[pat])
+ map[pat] = [];
+
+ let dup = false;
+
+ for (let owner in map[pat])
+ if (owner == pname)
+ dup = true;
+
+ if (!dup)
+ push(map[pat], pname);
+ }
+ }
+
+ // A list even for one element - a caller that has to distinguish shapes
+ // gets it wrong exactly once, in the rare case, in production.
+ function test(qname) {
+ let q = normalize(qname);
+
+ if (!length(q) || length(q) > LIM.name)
+ return null;
+
+ if (exists(exact, q))
+ return exact[q];
+
+ let off = index(q, '.');
+
+ while (off >= 0) {
+ let sfx = substr(q, off + 1);
+
+ if (exists(wild, sfx))
+ return wild[sfx];
+
+ let nxt = index(sfx, '.');
+ off = (nxt < 0) ? -1 : off + 1 + nxt;
+ }
+
+ return null;
+ }
+
+ return {
+ test,
+ issues,
+ size: { exact: length(keys(exact)), wild: length(keys(wild)) }
+ };
+};
--- /dev/null
+// shunt - interface resolution
+//
+// Turns a configured interface name into its device and gateways from a
+// netifd dump. Explicit config values always win.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+function nexthop(entry, fam) {
+ let dflt = (fam == 4) ? '0.0.0.0' : '::';
+
+ for (let r in (entry?.route ?? []))
+ if (r?.target == dflt && r?.mask == 0 && length(r?.nexthop ?? ''))
+ return r.nexthop;
+
+ return null;
+}
+
+export function resolve(policies, dump) {
+ let entries = dump?.interface ?? [];
+ let out = [];
+
+ for (let p in (policies ?? [])) {
+ let device = null;
+
+ for (let e in entries) {
+ if (e?.interface == p?.interface && length(e?.l3_device ?? '')) {
+ device = e.l3_device;
+ break;
+ }
+ }
+
+ if (device == null)
+ for (let e in entries)
+ if (e?.l3_device == p?.interface) {
+ device = p.interface;
+ break;
+ }
+
+ if (device == null) {
+ push(out, p);
+ continue;
+ }
+
+ let gw4 = null, gw6 = null;
+
+ for (let e in entries) {
+ if (e?.l3_device != device)
+ continue;
+
+ gw4 ??= nexthop(e, 4);
+ gw6 ??= nexthop(e, 6);
+ }
+
+ push(out, {
+ ...p,
+ interface: device,
+ gw4: p.gw4 ?? gw4,
+ gw6: p.gw6 ?? gw6
+ });
+ }
+
+ return out;
+};
--- /dev/null
+// shunt - nftables renderer
+//
+// Renders the whole ruleset: one table, two chains, per policy sets and
+// marks. Pure string building, no kernel access.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+export const TABLE = 'inet shunt';
+
+// The mask must stay a contiguous block: shift, capacity and mark are all
+// derived from it. That is why it is a constant and not a UCI option.
+export const DEFAULTS = {
+ mask: 0xff000000,
+ entry_ttl: 1200
+};
+
+const RE_NAME = /^[A-Za-z0-9_]{1,24}$/;
+const RE_V4 = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})(\/([0-9]{1,2}))?$/;
+const RE_V6 = /^[0-9A-Fa-f:]{2,45}(\/([0-9]{1,3}))?$/;
+
+export function set_name(kind, family, policy) {
+ return `${kind}${family}_${policy}`;
+};
+
+function valid_name(s) {
+ return type(s) == 'string' && match(s, RE_NAME) != null;
+}
+
+export function mac_addr(s) {
+ if (type(s) != 'string')
+ return null;
+
+ let m = trim(lc(s));
+
+ return match(m, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/) ? m : null;
+};
+
+// A destination port or an inclusive range, normalised to nft syntax. Ports
+// are 1-65535; 0 is reserved and never a destination.
+export function port_spec(s) {
+ let v = trim(`${s ?? ''}`);
+ let m = match(v, /^([0-9]{1,5})(-([0-9]{1,5}))?$/);
+
+ if (!m)
+ return null;
+
+ let lo = +m[1];
+ let hi = m[3] != null ? +m[3] : lo;
+
+ if (lo < 1 || hi > 65535 || lo > hi)
+ return null;
+
+ return lo == hi ? `${lo}` : `${lo}-${hi}`;
+};
+
+// tcp or udp only - nothing else carries a destination port, and naming a
+// protocol that cannot be filtered by port is a configuration error worth
+// reporting rather than silently rendering.
+export function proto_name(s) {
+ let v = lc(trim(`${s ?? ''}`));
+
+ return (v == 'tcp' || v == 'udp') ? v : null;
+};
+
+export function addr_family(s) {
+ if (type(s) != 'string')
+ return null;
+
+ let m = match(s, RE_V4);
+ if (m) {
+ for (let i = 1; i <= 4; i++)
+ if (+m[i] > 255)
+ return null;
+ if (m[6] != null && +m[6] > 32)
+ return null;
+ return 4;
+ }
+
+ m = match(s, RE_V6);
+ if (m) {
+ let body = split(s, '/')[0];
+ if (m[2] != null && +m[2] > 128)
+ return null;
+ if (index(body, ':::') >= 0)
+ return null;
+ if (length(split(body, '::')) > 2)
+ return null;
+ if (substr(body, 0, 1) == ':' && substr(body, 0, 2) != '::')
+ return null;
+ if (substr(body, -1) == ':' && substr(body, -2) != '::')
+ return null;
+ let groups = filter(split(body, ':'), (g) => g != '');
+ if (length(groups) > 8 || (length(groups) == 8 && index(body, '::') >= 0))
+ return null;
+ for (let g in groups)
+ if (length(g) > 4 || !match(g, /^[0-9A-Fa-f]+$/))
+ return null;
+ if (index(body, '::') < 0 && length(groups) != 8)
+ return null;
+ return 6;
+ }
+
+ return null;
+};
+
+function mask_shift(mask) {
+ let n = 0;
+ while (n < 32 && !((mask >> n) & 1))
+ n++;
+ return n;
+}
+
+export function compile(policies, opts) {
+ let mask = opts?.mask ?? DEFAULTS.mask;
+ let shift = mask_shift(mask);
+ let capacity = mask >> shift;
+ // Rule records, not strings: a MAC rule belongs in prerouting only, and
+ // both chains must render from one ordered list or precedence breaks.
+ let issues = [], marks = [], sets = [], rules4 = [], rules6 = [];
+ let idx = 0;
+ let learn = {};
+
+ function reject(policy, entry, reason) {
+ push(issues, { policy, entry, reason });
+ }
+
+ for (let pi = 0; pi < length(policies ?? []); pi++) {
+ let p = policies[pi];
+ let pname = p?.name;
+
+ if (!valid_name(pname)) {
+ reject(pname ?? `#${pi}`, null,
+ 'invalid policy name - must match [A-Za-z0-9_]{1,24}');
+ continue;
+ }
+
+ let src = { '4': [], '6': [] }, dst = { '4': [], '6': [] };
+
+ for (let a in (p.src ?? [])) {
+ let fam = addr_family(a);
+ if (fam)
+ push(src[sprintf('%d', fam)], a);
+ else
+ reject(pname, a, 'invalid src address');
+ }
+
+ for (let a in (p.dst ?? [])) {
+ let fam = addr_family(a);
+ if (fam)
+ push(dst[sprintf('%d', fam)], a);
+ else
+ reject(pname, a, 'invalid dst address');
+ }
+
+ let macs = [];
+
+ for (let a in (p.src_mac ?? [])) {
+ let m = mac_addr(a);
+ if (m)
+ push(macs, m);
+ else
+ reject(pname, a, 'invalid src_mac address');
+ }
+
+ let ports = [], protos = [];
+
+ for (let v in (p.dport ?? [])) {
+ let q = port_spec(v);
+ if (q)
+ push(ports, q);
+ else
+ reject(pname, v, 'invalid dport - expected 1-65535 or a range');
+ }
+
+ for (let v in (p.proto ?? [])) {
+ let q = proto_name(v);
+ if (q)
+ push(protos, q);
+ else
+ reject(pname, v, 'invalid proto - only tcp and udp carry ports');
+ }
+
+ // A port with no protocol means both, as banIP does it: "port 443 of
+ // this client" almost always includes QUIC, and requiring the
+ // protocol would let it slip through unnoticed.
+ if (length(ports) && !length(protos))
+ protos = [ 'tcp', 'udp' ];
+
+ let has_dom = length(p.domains ?? []) > 0;
+ let has_dst_any = length(dst['4']) || length(dst['6']);
+
+ // Ports and protocols were asked for and none survived validation.
+ // Rendering the policy anyway would drop the narrowing and mark
+ // everything the client sends - the same widening a mistyped client
+ // selector gets refused for.
+ if (length(p.dport ?? []) + length(p.proto ?? []) > 0 &&
+ !length(ports) && !length(protos)) {
+ reject(pname, null,
+ 'no usable port or protocol - policy skipped rather than widened to all traffic');
+ continue;
+ }
+ let has_ipsrc = length(src['4']) || length(src['6']);
+ let has_mac = length(macs) > 0;
+ let has_src = has_ipsrc || has_mac;
+ let has_any = has_src || length(dst['4']) || length(dst['6']) || has_dom;
+
+ if (!has_any) {
+ reject(pname, null, 'policy selects nothing');
+ continue;
+ }
+
+ if (length(p.src ?? []) + length(p.src_mac ?? []) > 0 && !has_src) {
+ reject(pname, null,
+ 'no usable client selector - policy skipped rather than widened to every client');
+ continue;
+ }
+
+ if (++idx > capacity) {
+ reject(pname, null,
+ sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x)',
+ capacity, mask));
+ continue;
+ }
+
+ let mark = idx << shift;
+ // One transport term for all three rule shapes. `th dport` reads the
+ // port at the transport header offset, which works for tcp and udp
+ // alike, so a port without a protocol needs no rule per protocol.
+ let l4 = '';
+
+ if (length(protos))
+ l4 = length(protos) == 1
+ ? sprintf('meta l4proto %s ', protos[0])
+ : sprintf('meta l4proto { %s } ', join(', ', protos));
+
+ if (length(ports))
+ l4 += length(ports) == 1
+ ? sprintf('th dport %s ', ports[0])
+ : sprintf('th dport { %s } ', join(', ', ports));
+
+ let stmt = sprintf('%smeta mark set (meta mark & 0x%08x) | 0x%08x counter return',
+ l4, ~mask & 0xffffffff, mark);
+
+ push(marks, { name: pname, index: idx, mark,
+ rt_table: 8000 + idx, rt_prio: 31000 + idx });
+
+ if (has_mac)
+ push(sets, sprintf(
+ '\tset %s { type ether_addr; counter; elements = { %s }; }',
+ set_name('m', '', pname), join(', ', macs)));
+
+ for (let fam in [ '4', '6' ]) {
+ let ip = (fam == '4') ? 'ip' : 'ip6';
+ let rules = (fam == '4') ? rules4 : rules6;
+ let atype = (fam == '4') ? 'ipv4_addr' : 'ipv6_addr';
+
+ if (has_dom)
+ push(sets, sprintf(
+ '\tset %s { type %s; flags timeout; counter; }',
+ set_name('d', fam, pname), atype));
+
+ let prefixes = [];
+
+ if (length(src[fam]))
+ prefixes = [ ...prefixes, {
+ pre: sprintf('%s saddr @%s ', ip, set_name('c', fam, pname)),
+ out: true, per_family: true
+ } ];
+
+ if (has_mac)
+ prefixes = [ ...prefixes, {
+ pre: sprintf('ether saddr @%s ', set_name('m', '', pname)),
+ out: false, per_family: false
+ } ];
+
+ if (!has_src)
+ prefixes = [ { pre: '', out: true, per_family: false } ];
+
+ if (!length(prefixes)) {
+ if (length(dst[fam]) || has_dom)
+ reject(pname, null, sprintf(
+ 'src has no v%s entry - v%s rules skipped to avoid over-marking',
+ fam, fam));
+ continue;
+ }
+
+ if (length(src[fam]))
+ push(sets, sprintf(
+ '\tset %s { type %s; flags interval; counter; elements = { %s }; }',
+ set_name('c', fam, pname), atype,
+ join(', ', src[fam])));
+
+ if (length(dst[fam]))
+ push(sets, sprintf(
+ '\tset %s { type %s; flags interval; counter; elements = { %s }; }',
+ set_name('s', fam, pname), atype,
+ join(', ', dst[fam])));
+
+ for (let px in prefixes)
+ if (length(dst[fam]))
+ push(rules, { out: px.out,
+ text: sprintf('\t\t%s%s daddr @%s %s',
+ px.pre, ip, set_name('s', fam, pname), stmt) });
+
+ if (has_dom)
+ learn[set_name('d', fam, pname)] = true;
+
+ // Destinations exist, but all in the other family: nothing for
+ // this one to route. A port or protocol alone does not have a
+ // family, so it does not trigger this.
+ if (!length(dst[fam]) && !has_dom && has_dst_any)
+ continue;
+
+ for (let px in prefixes) {
+ if (has_dom)
+ push(rules, { out: px.out,
+ text: sprintf('\t\t%s%s daddr @%s %s',
+ px.pre, ip, set_name('d', fam, pname), stmt) });
+
+ if (!has_dst_any && !has_dom && (px.per_family || fam == '4'))
+ push(rules, { out: px.out,
+ text: sprintf('\t\t%s%s', px.pre, stmt) });
+ }
+ }
+ }
+
+ let setup = join('\n', [
+ `destroy table ${TABLE}`,
+ `table ${TABLE} {`,
+ ...sets,
+ '\tchain prerouting {',
+ '\t\ttype filter hook prerouting priority mangle; policy accept;',
+ ...map(rules4, (r) => r.text), ...map(rules6, (r) => r.text),
+ '\t}',
+ '\tchain output {',
+ '\t\ttype route hook output priority mangle; policy accept;',
+ ...map(filter(rules4, (r) => r.out), (r) => r.text),
+ ...map(filter(rules6, (r) => r.out), (r) => r.text),
+ '\t}',
+ '}',
+ ''
+ ]);
+
+ return { setup, marks, issues, learn };
+};
+
+export function refresh(writes, entry_ttl) {
+ let ttl = entry_ttl ?? DEFAULTS.entry_ttl;
+ let out = [], issues = [];
+
+ for (let w in (writes ?? [])) {
+ let m = match(w?.set ?? '', /^[csd]([46])_[A-Za-z0-9_]{1,24}$/);
+ let fam = addr_family(w?.addr ?? '');
+
+ if (!m || fam == null || sprintf('%d', fam) != m[1] ||
+ index(w.addr, '/') >= 0) {
+ push(issues, { entry: w, reason: 'rejected, not rendered' });
+ continue;
+ }
+
+ push(out, sprintf('destroy element %s %s { %s }', TABLE, w.set, w.addr));
+ push(out, sprintf('add element %s %s { %s timeout %ds }',
+ TABLE, w.set, w.addr, ttl));
+ }
+
+ return { batch: length(out) ? join('\n', out) + '\n' : '', issues };
+};
+
+export function teardown() {
+ return `destroy table ${TABLE}\n`;
+};
--- /dev/null
+// shunt - active resolution
+//
+// Collects the resolvable names from the policies and turns query results
+// into set writes, following CNAME chains from the queried name.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+import { normalize } from 'shunt.match';
+import { set_name } from 'shunt.nft';
+
+export function names(policies) {
+ let seen = {}, out = [];
+
+ for (let p in (policies ?? [])) {
+ for (let raw in (p?.domains ?? [])) {
+ let n = normalize(raw);
+
+ if (!length(n) || index(n, '*') >= 0)
+ continue;
+ if (seen[n])
+ continue;
+
+ seen[n] = true;
+ push(out, n);
+ }
+ }
+
+ return out;
+};
+
+const CHAIN_MAX = 8;
+
+// resolv keys records by their own owner name, so a CNAME answer hides the
+// address under the canonical name. Walk from the name that was asked for.
+export function addresses(by_name, name) {
+ let seen = {};
+ let cur = normalize(name);
+ let a = [], aaaa = [];
+
+ for (let hop = 0; hop < CHAIN_MAX; hop++) {
+ if (!length(cur ?? '') || seen[cur])
+ break;
+
+ seen[cur] = true;
+
+ let e = by_name[cur];
+ if (!e)
+ break;
+
+ for (let v in (e.A ?? []))
+ push(a, v);
+ for (let v in (e.AAAA ?? []))
+ push(aaaa, v);
+
+ cur = normalize((e.CNAME ?? [])[0] ?? '');
+ }
+
+ return { a, aaaa };
+};
+
+export function index_results(results) {
+ let by = {};
+
+ for (let k in (results ?? {})) {
+ let n = normalize(k);
+
+ if (length(n))
+ by[n] = results[k];
+ }
+
+ return by;
+};
+
+export function plan(results, matcher, names) {
+ let writes = [];
+ let by = index_results(results);
+
+ for (let raw in (names ?? [])) {
+ let name = normalize(raw);
+
+ let policies = matcher.test(name);
+ if (policies == null)
+ continue;
+
+ let got = addresses(by, name);
+
+ for (let policy in policies) {
+ for (let a in got.a)
+ push(writes, { set: set_name('d', 4, policy), addr: a });
+
+ for (let a in got.aaaa)
+ push(writes, { set: set_name('d', 6, policy), addr: a });
+ }
+ }
+
+ return writes;
+};
--- /dev/null
+// shunt - ip rule and route renderer
+//
+// Renders the argv arrays for the policy routing tables and their rules.
+// Pure, like nft.uc - nothing here talks to the kernel.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+import { addr_family, DEFAULTS } from 'shunt.nft';
+
+const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/;
+
+const BLACKHOLE_METRIC = 9999;
+
+export function compile(policies, marks, opts) {
+ let mask = opts?.mask ?? DEFAULTS.mask;
+ let add = [], del = [], tables = [], issues = [];
+
+ let by_name = {};
+ for (let m in (marks ?? []))
+ by_name[m.name] = m;
+
+ function reject(policy, entry, reason) {
+ push(issues, { policy, entry, reason });
+ }
+
+ for (let p in (policies ?? [])) {
+ let m = by_name[p?.name];
+ if (!m)
+ continue;
+
+ let iface = p.interface;
+ if (type(iface) != 'string' || match(iface, RE_IFACE) == null) {
+ reject(p.name, iface, 'invalid or missing interface');
+ continue;
+ }
+
+ let fb = p.fallback ?? 'main';
+ if (fb != 'main' && fb != 'block') {
+ reject(p.name, p.fallback, "fallback must be 'main' or 'block'");
+ continue;
+ }
+
+ let gw = { '4': null, '6': null };
+ let gw_bad = false;
+
+ for (let fam in [ '4', '6' ]) {
+ let g = p[`gw${fam}`];
+ if (g == null)
+ continue;
+ if (sprintf('%d', addr_family(g)) == fam && index(g, '/') < 0)
+ gw[fam] = g;
+ else {
+ reject(p.name, g, `invalid gw${fam}`);
+ gw_bad = true;
+ }
+ }
+
+ if (gw_bad)
+ continue;
+
+ let fwmark = sprintf('0x%x/0x%x', m.mark, mask);
+ let table = sprintf('%d', m.rt_table);
+ let pref = sprintf('%d', m.rt_prio);
+
+ push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
+
+ for (let fam in [ '4', '6' ]) {
+ let v = `-${fam}`;
+
+ let route = [ 'ip', v, 'route', 'replace', 'default' ];
+ if (gw[fam])
+ push(route, 'via', gw[fam]);
+ push(route, 'dev', iface, 'table', table);
+ push(add, route);
+
+ if (fb == 'block')
+ push(add, [ 'ip', v, 'route', 'replace', 'blackhole',
+ 'default', 'metric',
+ sprintf('%d', BLACKHOLE_METRIC),
+ 'table', table ]);
+
+ push(add, [ 'ip', v, 'rule', 'add', 'pref', pref,
+ 'fwmark', fwmark, 'lookup', table ]);
+
+ unshift(del, [ 'ip', v, 'route', 'flush', 'table', table ]);
+ unshift(del, [ 'ip', v, 'rule', 'del', 'pref', pref ]);
+ }
+ }
+
+ return {
+ add,
+ del,
+ rt_tables: length(tables) ? join('\n', tables) + '\n' : '',
+ issues
+ };
+};
--- /dev/null
+// shunt - passive DNS observer
+//
+// Opens an AF_PACKET socket with a BPF filter on DNS answers and turns a
+// captured frame into a verdict: which policies want it, or why not.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+
+import { decap } from 'shunt.frame';
+import { parse, TYPE } from 'shunt.dns';
+
+export const RECV_LEN = 4160;
+
+export function open(dev) {
+ let sock, bpf;
+
+ try {
+ sock = require('socket');
+ }
+ catch (e) {
+ return { ok: false, err: 'socket module missing - install ucode-mod-socket' };
+ }
+
+ try {
+ bpf = require('shunt.snoop_bpf').BPF;
+ }
+ catch (e) {
+ return { ok: false,
+ err: 'shunt.snoop_bpf missing - reinstall the shunt package' };
+ }
+
+ let s = sock.create(sock.AF_PACKET, sock.SOCK_RAW, 0);
+ if (!s)
+ return { ok: false, err: `create: ${sock.error()}` };
+
+ if (!s.setopt(sock.SOL_SOCKET, sock.SO_ATTACH_FILTER,
+ { len: length(bpf), filter: bpf })) {
+ let err = `SO_ATTACH_FILTER: ${sock.error()}`;
+ s.close();
+ return { ok: false, err };
+ }
+
+ if (!s.bind({ family: sock.AF_PACKET, interface: dev,
+ protocol: 0x0003, address: '00:00:00:00:00:00' })) {
+ let err = `bind: ${sock.error()}`;
+ s.close();
+ return { ok: false, err };
+ }
+
+ return { ok: true, sock: s };
+};
+
+// Returns { policies, qname, a, aaaa } or { drop: <verdict> }. The verdict
+// strings are contract; the fixtures compare them verbatim.
+export function observe(frame, matcher) {
+ let f = decap(frame);
+ if (!f.ok)
+ return { drop: `frame:${f.err}` };
+
+ let r = parse(f.payload);
+ if (!r.ok)
+ return { drop: `dns:${r.err}` };
+
+ if (r.qtype != TYPE.A && r.qtype != TYPE.AAAA)
+ return { drop: 'qtype' };
+
+ if (!length(r.a) && !length(r.aaaa))
+ return { drop: 'noaddr' };
+
+ let policies = matcher.test(r.qname);
+ if (policies == null)
+ return { drop: 'nomatch' };
+
+ return { policies, qname: r.qname, a: r.a, aaaa: r.aaaa };
+};
--- /dev/null
+// shunt - BPF program for the snoop socket
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
+//
+// GENERATED - do not edit.
+//
+// Expression: udp src port 53 or (vlan and udp src port 53)
+// Link type: EN10MB (br-lan)
+// Instructions: 52
+// tcpdump version 4.99.6
+// libpcap version 1.10.6 (64-bit time_t, with TPACKET_V3)
+// 64-bit build, 64-bit time_t
+//
+// Take it whole. The vlan primitive prefixes `ld #0; st M[0];
+// st M[1]` and the later branches read those scratch slots, so
+// dropping the preamble or splicing the two halves breaks the
+// tagged path silently. Without `vlan` the program would start
+// at `ldh [12]` and be 16 instructions instead of 52.
+//
+// Return style, not export style, and that is load bearing:
+// snoop.uc loads this with require() at open() time so the
+// module itself stays loadable without the constant, and
+// require() only accepts return style - export syntax fails to
+// compile outside an import.
+
+return {
+ BPF: [
+ [ 0, 0, 0, 0 ],
+ [ 2, 0, 0, 0 ],
+ [ 2, 0, 0, 1 ],
+ [ 40, 0, 0, 12 ],
+ [ 21, 0, 4, 34525 ],
+ [ 48, 0, 0, 20 ],
+ [ 21, 0, 10, 17 ],
+ [ 40, 0, 0, 54 ],
+ [ 21, 41, 8, 53 ],
+ [ 21, 0, 7, 2048 ],
+ [ 48, 0, 0, 23 ],
+ [ 21, 0, 5, 17 ],
+ [ 40, 0, 0, 20 ],
+ [ 69, 3, 0, 8191 ],
+ [ 177, 0, 0, 14 ],
+ [ 72, 0, 0, 14 ],
+ [ 21, 33, 0, 53 ],
+ [ 48, 0, 0, 4294963248 ],
+ [ 21, 7, 0, 1 ],
+ [ 0, 0, 0, 4 ],
+ [ 2, 0, 0, 0 ],
+ [ 2, 0, 0, 1 ],
+ [ 40, 0, 0, 12 ],
+ [ 21, 2, 0, 33024 ],
+ [ 21, 1, 0, 34984 ],
+ [ 21, 0, 25, 37120 ],
+ [ 97, 0, 0, 1 ],
+ [ 72, 0, 0, 12 ],
+ [ 21, 0, 6, 34525 ],
+ [ 97, 0, 0, 0 ],
+ [ 80, 0, 0, 20 ],
+ [ 21, 0, 19, 17 ],
+ [ 97, 0, 0, 0 ],
+ [ 72, 0, 0, 54 ],
+ [ 21, 15, 16, 53 ],
+ [ 21, 0, 15, 2048 ],
+ [ 97, 0, 0, 0 ],
+ [ 80, 0, 0, 23 ],
+ [ 21, 0, 12, 17 ],
+ [ 97, 0, 0, 0 ],
+ [ 72, 0, 0, 20 ],
+ [ 69, 9, 0, 8191 ],
+ [ 97, 0, 0, 0 ],
+ [ 80, 0, 0, 14 ],
+ [ 84, 0, 0, 15 ],
+ [ 100, 0, 0, 2 ],
+ [ 12, 0, 0, 0 ],
+ [ 7, 0, 0, 0 ],
+ [ 72, 0, 0, 14 ],
+ [ 21, 0, 1, 53 ],
+ [ 6, 0, 0, 262144 ],
+ [ 6, 0, 0, 0 ],
+ ]
+};
--- /dev/null
+#!/bin/sh
+# shunt has no version output by design: the runtime version comes from
+# rpc-sys packagelist via ubus, which is not available in the CI
+# container. The forced generic version check can therefore never match
+# PKG_VERSION in the output of the daemon.
+
+[ "$1" = "shunt" ] || exit 1
+
+exit 0
--- /dev/null
+#!/bin/sh
+# compile and load with the shipped ucode.
+#
+
+shunt 2>&1 | grep 'usage: shunt' || exit 1
+
+ucode -e 'import * as a from "shunt.config"; import * as b from "shunt.nft";
+ import * as c from "shunt.dns"; import * as d from "shunt.frame";
+ import * as e from "shunt.match"; print("modules-ok\n");' |
+ grep 'modules-ok'