mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 06:05:13 +08:00
🌴 Sync 2026-09-28 20:19:40
This commit is contained in:
1 parent
6853603b0c
commit
8655ede11d
16 files changed
+943
-181
No files matched your search
@@ -10,7 +10,7 @@ include $(INCLUDE_DIR)/kernel.mk
|
||||
|
||||
PKG_NAME:=my-default-settings
|
||||
PKG_VERSION:=2
|
||||
PKG_RELEASE:=24
|
||||
PKG_RELEASE:=25
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
|
||||
File renamed without changes.
+5
-5
@@ -5,8 +5,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shunt
|
||||
PKG_VERSION:=0.1.5
|
||||
PKG_RELEASE:=3
|
||||
PKG_VERSION:=0.1.10
|
||||
PKG_RELEASE:=4
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
PKG_LICENSE_FILES:=
|
||||
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
|
||||
@@ -19,7 +19,7 @@ define Package/shunt
|
||||
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
|
||||
+ucode-mod-rtnl +ucode-mod-log +rpcd-mod-ucode +nftables-json
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
@@ -29,8 +29,8 @@ define Package/shunt/description
|
||||
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.
|
||||
counters, routing via fwmark rules and per-policy tables written over
|
||||
rtnetlink, with gateway discovery and interface events from netifd.
|
||||
endef
|
||||
|
||||
define Package/shunt/conffiles
|
||||
|
||||
+150
-22
@@ -50,7 +50,11 @@ shunt check
|
||||
* 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
|
||||
* Domain lists from a file - a community-maintained set of thousands of names, without a single UCI entry per name
|
||||
* Per-policy killswitch: hold the traffic when the interface drops, instead of leaking it out of the normal uplink
|
||||
* Per-policy action: `route` marks the traffic for the policy interface, `bypass` exempts it from every policy below
|
||||
* Your own networks stay reachable from a policy client, without listing them anywhere
|
||||
* Puts its own table back when something deletes it, `fw4 flush` included
|
||||
* 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
|
||||
@@ -59,7 +63,7 @@ shunt check
|
||||
<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` 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; `ip` is not needed, routes and rules are written over rtnetlink
|
||||
|
||||
`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.
|
||||
|
||||
@@ -72,6 +76,10 @@ Any of them, and there is no supported-protocols list to check against, because
|
||||
* **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.
|
||||
|
||||
**WireGuard: `route_allowed_ips` belongs off.** shunt renders `default dev <device>` into the policy's own table, so it needs no route from the tunnel itself. With the option on and `allowed_ips 0.0.0.0/0` netifd installs that prefix as a route in `main` instead, and the whole box follows the tunnel - the situation policy routing exists to avoid. Turning it off is all that is required; nothing else about the peer changes.
|
||||
|
||||
**`AllowedIPs` is not a route, and it still applies.** `route_allowed_ips` decides only whether netifd installs routes for those prefixes - they are written to the wireguard configuration either way, and the kernel uses them as its cryptokey filter. A packet whose destination matches no peer's `AllowedIPs` is dropped inside wireguard, however correctly it was routed there. A policy meant to carry general traffic therefore needs `0.0.0.0/0` on the peer, and `::/0` if it carries IPv6; what actually enters the tunnel is decided by the policy's selectors, not by the peer. Narrowing `AllowedIPs` to a few subnets is right only when those subnets really are everything the tunnel should ever carry.
|
||||
|
||||
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:
|
||||
@@ -129,7 +137,7 @@ Symptoms of getting this wrong are worth knowing, because they do not look like
|
||||
* 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
|
||||
* Enable and start the service, then run `shunt check` - it prints the mark, routing table and rule priority of every accepted policy, a bypass policy by name alone, 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>
|
||||
@@ -140,6 +148,7 @@ All functions are available from the command line, and the config file can be ed
|
||||
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 refresh # make the running daemon re-read its domain files
|
||||
shunt -v <cmd> # echo every message to the terminal as well
|
||||
```
|
||||
|
||||
@@ -151,6 +160,8 @@ Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows everyt
|
||||
|
||||
`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.
|
||||
|
||||
`shunt refresh` asks the daemon over ubus to re-read every `domain_file` and reports what it found; it is the one change that does not need a restart. Without a running daemon it says so and exits 1 - nothing is lost, the next start reads the files anyway. Everything else in the config still takes `/etc/init.d/shunt restart`.
|
||||
|
||||
<a id="shunt-config-options"></a>
|
||||
## shunt config options
|
||||
|
||||
@@ -162,11 +173,11 @@ Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows everyt
|
||||
| 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 |
|
||||
| entry_ttl | `1200` | ceiling for the 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.
|
||||
Values below the minimum are clamped, not rejected, and the clamp is logged. `entry_ttl` should stay well above `poll_interval` - a polled element is rewritten once its remaining timeout drops below half of `entry_ttl`, so the default pair refreshes comfortably within two poll cycles. Elements learned by snoop expire with the TTL of the answer instead, see [How addresses are learned](#how-addresses-are-learned).
|
||||
|
||||
### Policy sections
|
||||
|
||||
@@ -175,8 +186,10 @@ Each `config policy` section is one routing policy. **The section must be named,
|
||||
| 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 |
|
||||
| action | `route` (default) or `bypass`, see below |
|
||||
| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`); `route` only |
|
||||
| fallback | `main` (default) or `block`, see below; `route` only |
|
||||
| keep_local | `1` (default) keeps traffic on `main` where `main` has a route for it, see below; `route` only |
|
||||
| gw4 / gw6 | gateway override; normally unnecessary |
|
||||
| src | client addresses or CIDRs whose traffic this policy owns |
|
||||
| src_mac | client MAC addresses, ORed with `src` |
|
||||
@@ -184,8 +197,9 @@ Each `config policy` section is one routing policy. **The section must be named,
|
||||
| dport | destination ports, single or a range like `8000-8080` |
|
||||
| dst | destination addresses or CIDRs |
|
||||
| domain | domain patterns, see below |
|
||||
| domain_file | files with domain patterns, one per line, see below |
|
||||
|
||||
`src`, `src_mac`, `dst` and `domain` are lists and may repeat.
|
||||
`src`, `src_mac`, `dst`, `domain` and `domain_file` 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.
|
||||
|
||||
@@ -232,6 +246,73 @@ Rule 3 is what makes one domain usable by two client groups over two different u
|
||||
|
||||
Matching is label aligned, never string suffix: `evilexample.com` does not match `*.example.com`. A bad pattern is collected as an issue, never fatal.
|
||||
|
||||
<a id="domain-lists-from-a-file"></a>
|
||||
### Domain lists from a file
|
||||
|
||||
A `list domain` entry is right for a handful of names. A community-maintained list has thousands, and the place for those is a file, not UCI:
|
||||
|
||||
```
|
||||
config policy 'vpn'
|
||||
option interface 'wg0'
|
||||
list src '192.168.1.0/24'
|
||||
list domain 'my-own-exception.example'
|
||||
list domain_file 'community.txt'
|
||||
```
|
||||
|
||||
A bare name lives under `/tmp/shunt/`, which shunt creates at start; an absolute path is taken as given, for a list kept on a mounted drive. The default is tmpfs on purpose: a list of that size rewritten every day belongs in RAM, not on the flash the overlay sits on. The price is that after a reboot the file is not there until whatever produces it has run again, and that is handled, not worked around - see below.
|
||||
|
||||
The file holds one pattern per line in the same two shapes as `list domain` - `example.com` or `*.example.com` - with `#` starting a comment and blank lines ignored. It is read at start, validated by the same rules, and its patterns join the policy's own; `domain` and `domain_file` are additive, and a file may be named by more than one policy. A file that is not there, is unreadable or holds no usable pattern is reported as an issue against the policy and contributes nothing; the policy itself is still rendered, so with `domain_file` as its only domain selector its learned sets simply stay empty until the file arrives. Bad lines are counted and the first one is named, rather than logging every one of them.
|
||||
|
||||
Two things distinguish a file entry from a `list domain` one, both deliberate:
|
||||
|
||||
* **File entries are learned by snoop only.** poll resolves the names you typed, not the ones from a file - ten thousand names every `poll_interval` is a DNS storm, not a service. A name from a file reaches its set when a client on the observed LAN segment asks for it, which for a large list is exactly when it matters and never before. The [first-connection caveat](#known-limitations) applies to every one of them.
|
||||
* **shunt reads the file, it does not fetch it.** Where the list comes from and how often it changes is a job for cron, a script or a package of its own, and whatever writes the file runs `shunt refresh` afterwards: the daemon re-reads every domain file and swaps its matcher, and nothing else moves - no restart, the nftables table, the routes and the learned addresses all stay as they are. Write the new list to a temporary name and rename it over the old one, so a half-written file is never read:
|
||||
|
||||
```sh
|
||||
uclient-fetch -q -O /tmp/shunt/community.tmp https://example.org/domains.txt && mv /tmp/shunt/community.tmp /tmp/shunt/community.txt && shunt refresh
|
||||
```
|
||||
|
||||
Run from cron that covers the reboot case as well: the first run after boot creates the file and the refresh loads it. That keeps shunt independent of the network, of any download tool and of the format a list publisher happens to choose - converting `hosts` or dnsmasq syntax into one pattern per line is one `sed` away and stays outside the daemon.
|
||||
|
||||
What a large list costs: 100k patterns take about two seconds to load and roughly 40 MB of memory on an x86 test box, so expect several seconds and a proportionally smaller footprint on a router, at start and on every refresh. A file above 4 MiB is refused outright as an issue. `shunt check` prints how many files were read and how many patterns they contributed, `shunt refresh` does the same for the running daemon, and `ubus call shunt status` shows the count under `files` together with the time of the last refresh.
|
||||
|
||||
### Actions: route or bypass
|
||||
|
||||
`action 'route'` (default) is the policy shape everything above describes: matching traffic is marked and looked up in the policy's own table.
|
||||
|
||||
`action 'bypass'` marks nothing. Its rule ends evaluation and the packet leaves shunt untouched, which is the equivalent of pbr's `ignore`: since a packet takes the first policy it matches, a bypass section carves an exception out of every policy **below** it. It needs no `interface`, consumes no mark, and gets no routing table or ip rule of its own - `fallback`, `keep_local` and the gateway overrides are not read.
|
||||
|
||||
The selectors are the same ones a routing policy uses, domains included, so an exception can be as narrow as one client's traffic to one name:
|
||||
|
||||
```
|
||||
config policy 'no_vpn'
|
||||
option action 'bypass'
|
||||
list src '192.168.1.50'
|
||||
list domain 'bank.example.com'
|
||||
|
||||
config policy 'vpn'
|
||||
option interface 'wg0'
|
||||
list src '192.168.1.50'
|
||||
```
|
||||
|
||||
Order matters and only order: a bypass section placed after the policy it is meant to except from never sees the packet.
|
||||
|
||||
<a id="local-traffic-keep_local"></a>
|
||||
### Local traffic: keep_local
|
||||
|
||||
A policy that names only clients marks everything those clients send, and the mark decides the routing table before the destination is looked at. Without `keep_local` that includes traffic to your own networks: a marked packet to another VLAN would find only the policy table's default route and leave through the tunnel, and with `fallback 'block'` it would be dropped.
|
||||
|
||||
`keep_local '1'` (default) renders a second ip rule per family, on the same mark and one priority ahead of the policy rule, that consults `main` with the default route suppressed:
|
||||
|
||||
```
|
||||
ip rule add pref 31001 fwmark 0x1000000/0xff000000 lookup main suppress_prefixlength 0
|
||||
ip rule add pref 31501 fwmark 0x1000000/0xff000000 lookup 8001
|
||||
```
|
||||
|
||||
Anything `main` has a **specific** route for - every attached subnet, every static route - keeps taking it; everything that would have used the default route falls through to the policy rule. The policy itself is unaffected, because its whole purpose is the default route it renders into its own table.
|
||||
|
||||
`keep_local '0'` renders the policy rule alone. That is the hermetic form: with `fallback 'block'` nothing marked can leave through another interface, at the price of the local traffic above. Set it only where a leak matters more than reaching your own networks, and expect to name the destinations you still want reachable in a `bypass` section instead.
|
||||
|
||||
### 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.
|
||||
@@ -242,19 +323,23 @@ Note that domain precedence is resolved *before* this, at the matcher: the most
|
||||
|
||||
`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.
|
||||
`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. It stops at the destinations the policy actually owns - see [keep_local](#local-traffic-keep_local) for what a marked packet to one of your own networks does, and how to make the killswitch hermetic.
|
||||
|
||||
<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.
|
||||
* **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, and names from a `domain_file` are not polled by design - see [Domain lists from a file](#domain-lists-from-a-file).
|
||||
* **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.
|
||||
|
||||
The two sources differ in how long an element lives. A snooped element carries the TTL of the answer it came from - the shortest one, when a message mixes several - clamped to the range 60 to `entry_ttl` seconds. So an address a CDN hands out for 30 seconds is gone from the set within a minute of the last answer that named it, while a name with a day-long TTL is capped at `entry_ttl` and re-learned on its next answer. A polled element carries `entry_ttl`: the resolver interface hands back addresses without their TTL, so poll has nothing better than the ceiling. Where both sources see the same address the shorter bound wins - the next snooped answer cuts a polled element down to its TTL, and the write cache only rewrites an element when the new expiry moves by at least half of the lifetime the answer carries, so a burst of identical answers costs one write.
|
||||
|
||||
This is also the honest answer to the question every IP-based policy router gets: what about a CDN address that the domain stops using while some other site starts to? Nothing on layer 3 can tell two names apart once they share an address, shunt included. What shunt can do is not keep the address longer than the resolver would have, which is exactly what the TTL says.
|
||||
|
||||
<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.
|
||||
"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. A polled element is only rewritten when its remaining lifetime has dropped below half.
|
||||
|
||||
Two costs worth knowing:
|
||||
|
||||
@@ -327,6 +412,22 @@ config policy 'vpn'
|
||||
list domain 'www.example.com'
|
||||
```
|
||||
|
||||
**An exception for one client, over a policy that covers the whole subnet**
|
||||
|
||||
The bypass section comes first, so the packets it matches never reach the policy below it. Everything else from the subnet goes into the tunnel:
|
||||
|
||||
```
|
||||
config policy 'no_vpn'
|
||||
option action 'bypass'
|
||||
list src '192.168.1.50'
|
||||
list domain 'bank.example.com'
|
||||
list domain '*.bank.example.com'
|
||||
|
||||
config policy 'subnet'
|
||||
option interface 'wg0'
|
||||
list src '192.168.1.0/24'
|
||||
```
|
||||
|
||||
**A destination range without any domain**
|
||||
|
||||
```
|
||||
@@ -341,20 +442,21 @@ config policy 'office'
|
||||
|
||||
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:
|
||||
One external command remains:
|
||||
|
||||
| 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.
|
||||
Routes and rules go over rtnetlink through `ucode-mod-rtnl`, the same binding the rpcd backend reads them with, so there is no `ip` process per command and no `ip` dependency. A route on a device that does not exist yet is reported by the kernel's own reason rather than an exit code, and `rp_filter_manage` writes the `/proc/sys` value directly instead of calling `sysctl`.
|
||||
|
||||
Nothing is ever handed to a shell for parsing: `system()` takes an argument array and only ever runs `nft`, and `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
|
||||
table inet shunt own table, see below
|
||||
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
|
||||
@@ -363,21 +465,34 @@ table inet shunt own table, survives fw4 reloads
|
||||
set m_<policy> client MACs, no family digit, counter
|
||||
|
||||
fwmark <index> << 24, mask 0xff000000
|
||||
ip rule pref 31000 + <index>
|
||||
ct mark same bits, set on a flow's first packet
|
||||
ip rule pref 31000 + <index> keep_local's main lookup
|
||||
31500 + <index> the policy table
|
||||
routing table 8000 + <index>
|
||||
/etc/iproute2/rt_tables.d/shunt.conf the table name mapping
|
||||
/etc/iproute2/rt_tables.d/shunt.conf the table name mapping, for `ip route show` only
|
||||
```
|
||||
|
||||
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.
|
||||
A `bypass` policy is only a rule in the prerouting and output chains: no mark, no table, no ip rule, and it does not count against the 255. 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.
|
||||
|
||||
**The table can be deleted from outside, and comes back.** `fw4 reload` and `fw4 restart` touch only `table inet fw4`, so shunt's table survives both. `fw4 flush` does not: it walks `nft list tables` and deletes every table it finds, shunt's included - and that is exactly what `/etc/init.d/firewall stop` runs. Nothing in the kernel reports the loss, so the daemon checks that its table is still there once per poll cycle, and immediately whenever a write into a learned set fails. If it is gone, the ruleset is re-applied and the write cache is dropped, so poll and snoop refill the learned sets rather than suppressing addresses the kernel no longer has:
|
||||
|
||||
```
|
||||
shunt: ruleset gone from the kernel - re-applying; `fw4 flush`, as run by `/etc/init.d/firewall stop`, deletes every nftables table
|
||||
shunt: ruleset re-applied - learned addresses refill from the next poll cycle and from snoop
|
||||
```
|
||||
|
||||
The routes and ip rules are not affected by any of this - they are re-asserted every poll cycle anyway. Until the check runs, marked traffic simply is not marked, so it takes the normal uplink; with `fallback 'block'` that means the killswitch is not in force either. `ubus call shunt status` counts the recoveries as `reapplied`.
|
||||
|
||||
**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 flow is routed once, on its first packet.** The mark a `route` policy sets is also written to the flow's conntrack entry, and every later packet of the flow in the original direction takes its mark from there and never reaches the set lookups; flows that got no mark, or a `bypass`, stay settled the same way. That is what makes a set change safe for connections already running: the kernel kills a masqueraded conntrack entry whose output interface changes, so re-marking a live flow would reset it - measured, not assumed. The same rule holds across a reload. Conntrack survives it, so a connection keeps the decision it started with, including the policy index encoded in its mark; if a reload reorders the policies, that index may now belong to a different policy. Existing connections are not re-evaluated against the new configuration, new connections follow it. Reboot, or restart the client's connections, if that matters after a reorder.
|
||||
|
||||
<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.
|
||||
@@ -386,8 +501,8 @@ 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 |
|
||||
| fwmark and ct mark mask | `0x00ff0000` | `0x00003f00` | `0xff000000` |
|
||||
| ip rule pref | 30000 counting down | ~1001-3250 | 31000 and 31500 counting up |
|
||||
| routing tables | dynamic from ~256 | 1-250 | 8000+n |
|
||||
| nft | chains in fw4's table | | own `inet shunt` table |
|
||||
|
||||
@@ -418,9 +533,9 @@ 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 answer must name the policy table, the second the normal uplink. With `keep_local` on - the default - pick a destination **outside** your own networks for this: an address in an attached subnet or behind a static route deliberately answers with `main` in both lines, and that is the feature working, not the policy failing.
|
||||
|
||||
**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.
|
||||
**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 itself no longer depends on `ip`, so install `ip-tiny` for this check: `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.
|
||||
|
||||
@@ -442,6 +557,18 @@ The checks run in order and the **first** one wins, so these are "first reason t
|
||||
|
||||
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 the whole ruleset is gone
|
||||
|
||||
`/etc/init.d/firewall stop` runs `fw4 flush`, which deletes **every** nftables table on the box, shunt's among them - `fw4 restart` and `fw4 reload` do not. Nothing is marked from that moment, so all policy traffic takes the normal uplink and a `fallback 'block'` killswitch is not in force either.
|
||||
|
||||
The daemon notices this within one `poll_interval`, or immediately if a learned address is written in the meantime, and re-applies its ruleset; the log says so twice, once for the loss and once for the recovery. `nft list table inet shunt` and the LuCI overview both show whether the table is there now, and `ubus call shunt status` counts how often it had to come back:
|
||||
|
||||
```sh
|
||||
ubus call shunt status | grep reapplied
|
||||
```
|
||||
|
||||
A number that keeps climbing means something on the box flushes nftables repeatedly. Look for a service that calls `fw4 flush` or `nft flush ruleset` in an init or hotplug script - shunt only puts its own table back, it cannot stop whatever removes it.
|
||||
|
||||
### 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`.
|
||||
@@ -464,10 +591,11 @@ 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.
|
||||
* **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. That connection stays where it started, by design: a flow's route is decided on its first packet and kept on the conntrack entry, so the switch happens at a connection boundary and never mid-stream - see [What shunt creates on the system](#what-shunt-creates-on-the-system). The next connection to the same host starts on the policy interface. 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.
|
||||
* **An outside flush of nftables is repaired, not prevented.** Any tool may delete shunt's table - `fw4 flush` does. shunt re-applies it within one poll interval, or sooner, and until then nothing is marked.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+25
-7
@@ -19,7 +19,8 @@ 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 { load as files_load } from 'shunt.domain_file';
|
||||
import { action_name, compile as nft_compile } from 'shunt.nft';
|
||||
import { compile as route_compile } from 'shunt.route';
|
||||
import { names as poll_names } from 'shunt.poll';
|
||||
|
||||
@@ -58,11 +59,15 @@ function render() {
|
||||
|
||||
cfg.policies = netifd_resolve(cfg.policies, dump);
|
||||
|
||||
let f = files_load(cfg.policies);
|
||||
|
||||
cfg.policies = f.policies;
|
||||
|
||||
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 };
|
||||
return { cfg, files: f, matcher: m, nft: n, route: r };
|
||||
}
|
||||
|
||||
// Policy devices whose marked traffic the kernel would drop: max(all, <dev>),
|
||||
@@ -80,6 +85,11 @@ function rp_filter_blocked(policies) {
|
||||
for (let p in (policies ?? [])) {
|
||||
let dev = p.interface;
|
||||
|
||||
// A bypass policy routes into nothing, so its interface - if one is
|
||||
// left over in the config at all - is not a device shunt marks for.
|
||||
if (action_name(p.action) != 'route')
|
||||
continue;
|
||||
|
||||
if (!length(dev ?? '') || seen[dev])
|
||||
continue;
|
||||
|
||||
@@ -163,7 +173,8 @@ function kernel_rules(marks) {
|
||||
let want = {};
|
||||
|
||||
for (let m in (marks ?? []))
|
||||
want[sprintf('%d', m.mark)] = m.name;
|
||||
if (m.mark != null)
|
||||
want[sprintf('%d', m.mark)] = m.name;
|
||||
|
||||
let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP,
|
||||
{ family: RT.AF_UNSPEC });
|
||||
@@ -203,6 +214,9 @@ function kernel_routes(marks) {
|
||||
for (let m in (marks ?? [])) {
|
||||
let n = 0;
|
||||
|
||||
if (m.rt_table == null)
|
||||
continue;
|
||||
|
||||
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 });
|
||||
@@ -249,14 +263,17 @@ return {
|
||||
|
||||
push(policies, {
|
||||
name: m.name,
|
||||
action: m.action,
|
||||
mark: m.mark,
|
||||
rt_table: m.rt_table,
|
||||
rt_prio: m.rt_prio,
|
||||
interface: p?.interface,
|
||||
fallback: p?.fallback,
|
||||
interface: (m.action == 'route') ? p?.interface : null,
|
||||
fallback: (m.action == 'route') ? p?.fallback : null,
|
||||
domains: length(p?.domains ?? []),
|
||||
rules: rules ? length(rules[m.name] ?? []) : null,
|
||||
routes: routes[m.name]
|
||||
file_domains: length(p?.file_domains ?? []),
|
||||
rules: (m.action == 'route' && rules)
|
||||
? length(rules[m.name] ?? []) : null,
|
||||
routes: (m.action == 'route') ? routes[m.name] : null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,6 +288,7 @@ return {
|
||||
rp_filter_strict: rp_filter_strict(),
|
||||
issues: [
|
||||
...st.cfg.issues,
|
||||
...st.files.issues,
|
||||
...st.matcher.issues,
|
||||
...st.nft.issues,
|
||||
...st.route.issues
|
||||
|
||||
+210
-68
@@ -8,13 +8,15 @@
|
||||
// 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 { popen, writefile, readfile, unlink, mkdir, 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 { load as cfg_load, parse as cfg_parse, MIN as cfg_min } from 'shunt.config';
|
||||
import { compile as match_compile } from 'shunt.match';
|
||||
import { compile as nft_compile, refresh, teardown } from 'shunt.nft';
|
||||
import { load as files_load, DIR as FILES_DIR } from 'shunt.domain_file';
|
||||
import { action_name, compile as nft_compile, refresh, teardown, TABLE } from 'shunt.nft';
|
||||
import { compile as route_compile } from 'shunt.route';
|
||||
import { open as rt_open, exec as rt_exec, describe as rt_describe } from 'shunt.rt';
|
||||
import { open as snoop_open, observe, RECV_LEN } from 'shunt.snoop';
|
||||
import { names as poll_names, plan as poll_plan,
|
||||
addresses as poll_addresses,
|
||||
@@ -89,41 +91,42 @@ function report(kind, 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.
|
||||
// The one remaining child process besides `nft -f -`: nft's own existence
|
||||
// check for the table. Not 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 ]);
|
||||
}
|
||||
|
||||
let rtnl_mod = null;
|
||||
|
||||
// Routes and rules go over rtnetlink through ucode-mod-rtnl. Without the
|
||||
// module nothing can be routed, so a missing one is an error, once.
|
||||
function rtnl() {
|
||||
if (rtnl_mod != null)
|
||||
return rtnl_mod;
|
||||
|
||||
rtnl_mod = rt_open() ?? false;
|
||||
|
||||
if (!rtnl_mod)
|
||||
log('err', 'ucode-mod-rtnl missing - routes and rules cannot be applied');
|
||||
|
||||
return rtnl_mod;
|
||||
}
|
||||
|
||||
// Fire and forget, the keeper's and the teardown's way: an EEXIST on a
|
||||
// rule that is already there or an ESRCH on one that is already gone is
|
||||
// the expected answer, not a fault.
|
||||
function rt_quiet(ops) {
|
||||
let rt = rtnl();
|
||||
|
||||
if (!rt)
|
||||
return;
|
||||
|
||||
for (let op in ops)
|
||||
rt_exec(rt, op);
|
||||
}
|
||||
|
||||
function nft_pipe(batch, what) {
|
||||
let fh = popen('nft -f -', 'w');
|
||||
|
||||
@@ -143,6 +146,34 @@ function nft_pipe(batch, what) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// shunt's table check
|
||||
function table_present() {
|
||||
return quiet([ 'nft', '-t', 'list', 'table', ...split(TABLE, ' ') ]) == 0;
|
||||
}
|
||||
|
||||
// Re-creates the table if it went away
|
||||
function ensure_table(st) {
|
||||
if (table_present()) {
|
||||
st.lost = false;
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
if (!st.lost) {
|
||||
st.lost = true;
|
||||
log('warn', 'ruleset gone from the kernel - re-applying; `fw4 flush`, as run by `/etc/init.d/firewall stop`, deletes every nftables table');
|
||||
}
|
||||
|
||||
if (!nft_pipe(st.state.nft.setup, 'setup'))
|
||||
return 'failed';
|
||||
|
||||
st.lost = false;
|
||||
st.stats.reapplied++;
|
||||
st.cache.reset();
|
||||
log('notice', 'ruleset re-applied - learned addresses refill from the next poll cycle and from snoop');
|
||||
|
||||
return 'recreated';
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
if (!nft_pipe(state.nft.setup, 'setup'))
|
||||
return false;
|
||||
@@ -153,26 +184,24 @@ function apply(state) {
|
||||
log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error()));
|
||||
}
|
||||
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
rt_quiet(state.route.del);
|
||||
|
||||
let rt = rtnl();
|
||||
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);
|
||||
for (let op in state.route.add) {
|
||||
let why = rt ? rt_exec(rt, op) : 'ucode-mod-rtnl missing';
|
||||
|
||||
if (why != null) {
|
||||
failed++;
|
||||
reasons[why] = (reasons[why] ?? 0) + 1;
|
||||
debug(sprintf('not applied: %s - %s', join(' ', argv), why));
|
||||
debug(sprintf('not applied: %s - %s', rt_describe(op), why));
|
||||
}
|
||||
}
|
||||
|
||||
for (let why in reasons)
|
||||
log('warn', sprintf('%d of %d route/rule command(s) not applied - %s',
|
||||
log('warn', sprintf('%d of %d route/rule operation(s) not applied - %s',
|
||||
reasons[why], length(state.route.add), why));
|
||||
|
||||
if (failed)
|
||||
@@ -183,8 +212,7 @@ function apply(state) {
|
||||
|
||||
function flush(state) {
|
||||
if (state)
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
rt_quiet(state.route.del);
|
||||
|
||||
nft_pipe(teardown(), 'teardown');
|
||||
|
||||
@@ -204,6 +232,10 @@ function policy_devices(policies) {
|
||||
for (let p in (policies ?? [])) {
|
||||
let dev = p.interface;
|
||||
|
||||
// bypass policies route into nothing - rp_filter does not apply.
|
||||
if (action_name(p.action) != 'route')
|
||||
continue;
|
||||
|
||||
if (length(dev ?? '') && !seen[dev]) {
|
||||
seen[dev] = true;
|
||||
push(out, dev);
|
||||
@@ -242,8 +274,9 @@ function rp_filter_blocked(policies) {
|
||||
// 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) ]);
|
||||
if (rp_read(dev) != '' && rp_read(dev) != '2' &&
|
||||
!writefile(`/proc/sys/net/ipv4/conf/${dev}/rp_filter`, '2\n'))
|
||||
log('warn', sprintf('cannot set rp_filter on %s: %s', dev, fs_error()));
|
||||
}
|
||||
|
||||
// Reads the live /proc value, so when rp_filter_apply has done its job the
|
||||
@@ -266,9 +299,15 @@ function build_state(silent) {
|
||||
|
||||
cfg.policies = netifd_resolve(cfg.policies, netifd_dump());
|
||||
|
||||
let matcher = null;
|
||||
let matcher = null, files = null;
|
||||
|
||||
// The files are read only when the matcher is built from them - a
|
||||
// teardown renders the policy from its config alone, see nft.uc.
|
||||
if (!silent) {
|
||||
files = files_load(cfg.policies);
|
||||
cfg.policies = files.policies;
|
||||
report('file', files.issues);
|
||||
|
||||
matcher = match_compile(cfg.policies);
|
||||
report('domain', matcher.issues);
|
||||
}
|
||||
@@ -281,7 +320,7 @@ function build_state(silent) {
|
||||
if (!silent)
|
||||
report('route', r.issues);
|
||||
|
||||
return { cfg, matcher, nft: n, route: r };
|
||||
return { cfg, matcher, files, nft: n, route: r };
|
||||
}
|
||||
|
||||
// nft -f reads the entire ruleset before resolving a single name, so on a box
|
||||
@@ -293,10 +332,24 @@ const WRITE_MIN = 2;
|
||||
const WRITE_MAX = 60;
|
||||
const WRITE_FACTOR = 3;
|
||||
|
||||
// A write from snoop carries the TTL of the answer it was learned from; one
|
||||
// from poll carries none. Either way the element lives no longer than
|
||||
// entry_ttl and no shorter than its floor, so a zero TTL still gets a few
|
||||
// seconds of coverage and a week-long one does not pin a stale address.
|
||||
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))
|
||||
let max = st.state.cfg.global.entry_ttl;
|
||||
let min = cfg_min.entry_ttl;
|
||||
|
||||
for (let w in writes) {
|
||||
if (!st.state.nft.learn[w.set])
|
||||
continue;
|
||||
|
||||
let ttl = w.ttl ?? max;
|
||||
w.ttl = (ttl < min) ? min : (ttl > max) ? max : ttl;
|
||||
|
||||
if (st.cache.due(w.set, w.addr, now, w.ttl))
|
||||
st.pending[`${w.set}/${w.addr}`] = w;
|
||||
}
|
||||
}
|
||||
|
||||
function drain_writes(st) {
|
||||
@@ -319,6 +372,9 @@ function drain_writes(st) {
|
||||
|
||||
if (ok)
|
||||
debug(sprintf('%d element(s) written in %ds', length(due), cost));
|
||||
else if (ensure_table(st) == 'recreated')
|
||||
for (let w in due)
|
||||
st.pending[`${w.set}/${w.addr}`] = w;
|
||||
|
||||
let want = cost * WRITE_FACTOR;
|
||||
|
||||
@@ -373,11 +429,36 @@ function run() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
mkdir(FILES_DIR, 0o755);
|
||||
|
||||
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: {} };
|
||||
matched: 0, drops: {}, reapplied: 0,
|
||||
files: { count: state.files.files, entries: state.files.entries,
|
||||
refreshed: null } };
|
||||
|
||||
// Re-reads the domain files and swaps the matcher - the one thing a
|
||||
// changed list needs. Nothing else moves: the sets exist whether the
|
||||
// file does, poll never had the file names, routes and rules are not
|
||||
// touched, and the learned addresses stay put. Runs on the uloop
|
||||
// thread between callbacks, so snoop never sees a half-built matcher.
|
||||
function refresh_files() {
|
||||
let f = files_load(state.cfg.policies);
|
||||
|
||||
report('file', f.issues);
|
||||
|
||||
state.cfg.policies = f.policies;
|
||||
state.matcher = match_compile(state.cfg.policies);
|
||||
|
||||
stats.files = { count: f.files, entries: f.entries, refreshed: time() };
|
||||
|
||||
log('info', sprintf('refresh: %d file(s), %d pattern(s), %d issue(s)',
|
||||
f.files, f.entries, length(f.issues)));
|
||||
|
||||
return { files: f.files, entries: f.entries, issues: f.issues };
|
||||
}
|
||||
|
||||
try {
|
||||
resolv = require('resolv');
|
||||
@@ -392,7 +473,8 @@ function run() {
|
||||
|
||||
let unresolved = {};
|
||||
|
||||
let wq = { state, cache, pending: {}, interval: WRITE_MIN, timer: null };
|
||||
let wq = { state, cache, stats, pending: {}, lost: false,
|
||||
interval: WRITE_MIN, timer: null };
|
||||
|
||||
function poll_cycle() {
|
||||
if (!resolv || !length(targets))
|
||||
@@ -433,8 +515,9 @@ function run() {
|
||||
// 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);
|
||||
rt_quiet(state.route.add);
|
||||
|
||||
ensure_table(wq);
|
||||
|
||||
poll_cycle();
|
||||
cache.prune(time());
|
||||
@@ -472,10 +555,8 @@ function run() {
|
||||
|
||||
check_rp_filter(resolved);
|
||||
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
for (let argv in r.add)
|
||||
quiet(argv);
|
||||
rt_quiet(state.route.del);
|
||||
rt_quiet(r.add);
|
||||
|
||||
state.route = r;
|
||||
}
|
||||
@@ -514,11 +595,26 @@ function run() {
|
||||
matched: stats.matched,
|
||||
drops: stats.drops
|
||||
},
|
||||
dedupe: cache.size()
|
||||
reapplied: stats.reapplied,
|
||||
dedupe: cache.size(),
|
||||
files: stats.files
|
||||
};
|
||||
}
|
||||
|
||||
let obj = c.publish('shunt', { status: { call: () => status_reply() } });
|
||||
function refresh_reply() {
|
||||
try {
|
||||
return refresh_files();
|
||||
}
|
||||
catch (e) {
|
||||
log('err', sprintf('refresh failed: %s', e));
|
||||
return { error: `${e}` };
|
||||
}
|
||||
}
|
||||
|
||||
let obj = c.publish('shunt', {
|
||||
status: { call: () => status_reply() },
|
||||
refresh: { call: () => refresh_reply() }
|
||||
});
|
||||
|
||||
if (!obj)
|
||||
log('warn', sprintf('cannot publish ubus object: %s',
|
||||
@@ -564,9 +660,9 @@ function run() {
|
||||
let writes = [];
|
||||
for (let policy in v.policies) {
|
||||
for (let a in v.a)
|
||||
push(writes, { set: `d4_${policy}`, addr: a });
|
||||
push(writes, { set: `d4_${policy}`, addr: a, ttl: v.ttl });
|
||||
for (let a in v.aaaa)
|
||||
push(writes, { set: `d6_${policy}`, addr: a });
|
||||
push(writes, { set: `d6_${policy}`, addr: a, ttl: v.ttl });
|
||||
}
|
||||
|
||||
debug(sprintf('snoop: %s -> %s (%d addr)',
|
||||
@@ -600,6 +696,43 @@ function run() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Asks the running daemon to re-read its domain files. Without a daemon
|
||||
// there is nothing to refresh and nothing lost: the next start reads them.
|
||||
function refresh() {
|
||||
let c = null;
|
||||
|
||||
try {
|
||||
c = require('ubus').connect();
|
||||
}
|
||||
catch (e) {
|
||||
c = null;
|
||||
}
|
||||
|
||||
if (!c) {
|
||||
warn('ubus unavailable - cannot reach the daemon\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
let r = c.call('shunt', 'refresh');
|
||||
|
||||
if (r == null) {
|
||||
warn('shunt is not running - the files are read at start\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (r.error) {
|
||||
warn(sprintf('refresh failed: %s\n', r.error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf('files: %d file(s), %d pattern(s)\n', r.files, r.entries);
|
||||
|
||||
for (let i in r.issues)
|
||||
printf('issue: %J\n', i);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function check() {
|
||||
verbose = true;
|
||||
|
||||
@@ -611,14 +744,21 @@ function check() {
|
||||
printf('policies: %d accepted, %d mark(s)\n',
|
||||
length(state.cfg.policies), length(state.nft.marks));
|
||||
|
||||
// A bypass policy has none of the three - printing them would render
|
||||
// its nulls as a mark of 0 in table 0.
|
||||
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);
|
||||
if (m.action == 'bypass')
|
||||
printf(' %-16s bypass\n', m.name);
|
||||
else
|
||||
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);
|
||||
let total = length(state.files.issues) + length(state.matcher.issues) +
|
||||
length(state.nft.issues) + length(state.route.issues);
|
||||
|
||||
printf('issues: %d (see above)\n', total);
|
||||
printf('files: %d file(s), %d pattern(s)\n', state.files.files,
|
||||
state.files.entries);
|
||||
printf('poll: %d name(s)\n', length(poll_names(state.cfg.policies)));
|
||||
|
||||
return length(state.nft.marks) ? 0 : 2;
|
||||
@@ -641,7 +781,9 @@ case 'check':
|
||||
case 'flush':
|
||||
flush(build_state(true));
|
||||
exit(0);
|
||||
case 'refresh':
|
||||
exit(refresh());
|
||||
default:
|
||||
warn('usage: shunt [-v] run|check|flush\n');
|
||||
warn('usage: shunt [-v] run|check|flush|refresh\n');
|
||||
exit(2);
|
||||
}
|
||||
+4
-1
@@ -160,8 +160,10 @@ export function parse(sections) {
|
||||
|
||||
push(policies, {
|
||||
name: s.name,
|
||||
action: v.action,
|
||||
interface: v.interface,
|
||||
fallback: v.fallback,
|
||||
keep_local: v.keep_local,
|
||||
gw4: v.gw4,
|
||||
gw6: v.gw6,
|
||||
src: to_list(v.src),
|
||||
@@ -169,7 +171,8 @@ export function parse(sections) {
|
||||
dport: to_list(v.dport),
|
||||
proto: to_list(v.proto),
|
||||
dst: to_list(v.dst),
|
||||
domains: to_list(v.domain)
|
||||
domains: to_list(v.domain),
|
||||
domain_files: to_list(v.domain_file)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+27
-13
@@ -1,31 +1,39 @@
|
||||
// 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.
|
||||
// Remembers when each (set, address) element expires in the kernel 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 = {};
|
||||
let exp = {};
|
||||
|
||||
function due(set, addr, now) {
|
||||
// A write is due when it moves the expiry by at least half of the
|
||||
// lifetime it carries, in either direction: an answer with a long TTL
|
||||
// extends an element that has aged past half, a short one cuts an
|
||||
// element that poll or an earlier answer left long. Anything closer is
|
||||
// a rewrite the kernel would not notice.
|
||||
function due(set, addr, now, ttl) {
|
||||
let k = `${set}/${addr}`;
|
||||
let t = last[k];
|
||||
let t = ttl ?? entry_ttl;
|
||||
let e = now + t;
|
||||
let cur = exp[k];
|
||||
let d = (cur == null) ? t : (e > cur) ? e - cur : cur - e;
|
||||
|
||||
if (t != null && (now - t) * 2 < entry_ttl)
|
||||
if (d * 2 < t)
|
||||
return false;
|
||||
|
||||
last[k] = now;
|
||||
exp[k] = e;
|
||||
return true;
|
||||
}
|
||||
|
||||
function prune(now) {
|
||||
let n = 0;
|
||||
|
||||
for (let k in last) {
|
||||
if (now - last[k] >= entry_ttl) {
|
||||
delete last[k];
|
||||
for (let k in exp) {
|
||||
if (now >= exp[k]) {
|
||||
delete exp[k];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
@@ -33,9 +41,15 @@ export function create(entry_ttl) {
|
||||
return n;
|
||||
}
|
||||
|
||||
function size() {
|
||||
return length(keys(last));
|
||||
// Dropped wholesale when the table had to be re-created: the kernel has
|
||||
// no elements any more, so every pair is due again regardless of age.
|
||||
function reset() {
|
||||
exp = {};
|
||||
}
|
||||
|
||||
return { due, prune, size };
|
||||
function size() {
|
||||
return length(keys(exp));
|
||||
}
|
||||
|
||||
return { due, prune, reset, size };
|
||||
};
|
||||
+13
-4
@@ -1,7 +1,8 @@
|
||||
// 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.
|
||||
// Parses a response far enough to answer: which name was asked for, which
|
||||
// A/AAAA addresses came back, and how long the answer is good for. Never
|
||||
// trusts a length off the wire.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
@@ -207,7 +208,7 @@ export function parse(buf) {
|
||||
let qtype = u16at(buf, off);
|
||||
off += 4;
|
||||
|
||||
let a = [], aaaa = [];
|
||||
let a = [], aaaa = [], ttl = null;
|
||||
|
||||
for (let i = 0; i < ancount; i++) {
|
||||
off = skip_name(buf, off);
|
||||
@@ -218,6 +219,7 @@ export function parse(buf) {
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let rtype = u16at(buf, off);
|
||||
let rttl = (u16at(buf, off + 4) << 16) | u16at(buf, off + 6);
|
||||
let rdlen = u16at(buf, off + 8);
|
||||
off += RR_FIXED;
|
||||
|
||||
@@ -235,6 +237,12 @@ export function parse(buf) {
|
||||
push(aaaa, fmt6(buf, off));
|
||||
}
|
||||
|
||||
// The records of one RRset share a TTL (RFC 2181 5.2); across the
|
||||
// sets of a message the shortest one bounds the whole answer.
|
||||
if ((rtype == TYPE.A || rtype == TYPE.AAAA) &&
|
||||
(ttl == null || rttl < ttl))
|
||||
ttl = rttl;
|
||||
|
||||
off += rdlen;
|
||||
}
|
||||
|
||||
@@ -244,6 +252,7 @@ export function parse(buf) {
|
||||
qname: q.name,
|
||||
qtype,
|
||||
a,
|
||||
aaaa
|
||||
aaaa,
|
||||
ttl
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// shunt - domain patterns from files
|
||||
//
|
||||
// Reads the files a policy names in `domain_file` and attaches their
|
||||
// patterns as `file_domains`, kept apart from the hand-entered `domains`:
|
||||
// the matcher takes both, poll takes only the latter. A file is the shape
|
||||
// a community list comes in, and resolving ten thousand names every poll
|
||||
// interval is not a service, it is a DNS storm - so file entries are
|
||||
// learned passively, by snoop, when a client asks for them.
|
||||
//
|
||||
// Where the file comes from is not shunt's business. cron, a script, a
|
||||
// package of its own - whatever writes it runs `shunt refresh`, and the
|
||||
// daemon re-reads. A bare name lives under DIR, which is tmpfs: a list of
|
||||
// that size has no business on flash, and a file that is not there yet
|
||||
// after a reboot is the expected state, not a fault.
|
||||
//
|
||||
// load() is pure and takes the reader as an argument; read() is the one
|
||||
// backed by the file system.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
import { readfile, stat, error as fs_error } from 'fs';
|
||||
import { pattern } from 'shunt.match';
|
||||
|
||||
// Hard limit on one file: a list of that size is the wrong tool on a
|
||||
// router, and reading it into a ucode array would show why.
|
||||
export const LIM = {
|
||||
size: 4 * 1024 * 1024
|
||||
};
|
||||
|
||||
export const DIR = '/tmp/shunt';
|
||||
|
||||
// A bare name resolves under DIR; an absolute path is taken as given, for
|
||||
// a list that lives on a mounted drive or in an overlay of the user's
|
||||
// choosing. Anything else - empty, or a relative path with directories -
|
||||
// is not a file name.
|
||||
export function resolve(name) {
|
||||
if (type(name) != 'string' || !length(name))
|
||||
return null;
|
||||
if (substr(name, 0, 1) == '/')
|
||||
return name;
|
||||
if (index(name, '/') >= 0)
|
||||
return null;
|
||||
|
||||
return `${DIR}/${name}`;
|
||||
};
|
||||
|
||||
export function read(path) {
|
||||
let st = stat(path);
|
||||
|
||||
if (st == null)
|
||||
return { error: 'not found - `shunt refresh` loads it once it is there', missing: true };
|
||||
|
||||
if (st.type != 'file')
|
||||
return { error: `not a regular file (${st.type})` };
|
||||
|
||||
if (st.size > LIM.size)
|
||||
return { error: sprintf('too large (%d bytes, limit %d)', st.size, LIM.size) };
|
||||
|
||||
let text = readfile(path);
|
||||
|
||||
if (text == null)
|
||||
return { error: fs_error() ?? 'unreadable' };
|
||||
|
||||
return { text };
|
||||
};
|
||||
|
||||
// One pattern per line, `#` starts a comment, blanks are skipped. Only the
|
||||
// count and the first offender are reported, not every bad line - a list
|
||||
// with a thousand broken entries must not turn into a thousand log lines.
|
||||
export function parse(text) {
|
||||
let patterns = [], seen = {};
|
||||
let lines = 0, rejected = 0, first = null;
|
||||
|
||||
for (let line in split(text ?? '', '\n')) {
|
||||
lines++;
|
||||
|
||||
let hash = index(line, '#');
|
||||
if (hash >= 0)
|
||||
line = substr(line, 0, hash);
|
||||
|
||||
line = trim(line);
|
||||
if (!length(line))
|
||||
continue;
|
||||
|
||||
let r = pattern(line);
|
||||
|
||||
if (r.error) {
|
||||
rejected++;
|
||||
if (first == null)
|
||||
first = sprintf('line %d: %s', lines, r.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = (r.wild ? '*.' : '') + r.pat;
|
||||
|
||||
if (seen[key])
|
||||
continue;
|
||||
|
||||
seen[key] = true;
|
||||
push(patterns, key);
|
||||
}
|
||||
|
||||
return { patterns, rejected, first };
|
||||
};
|
||||
|
||||
export function load(policies, reader) {
|
||||
let rd = reader ?? read;
|
||||
let cache = {}, issues = [], out = [];
|
||||
let files = 0, entries = 0;
|
||||
|
||||
function reject(policy, entry, reason) {
|
||||
push(issues, { policy, entry, reason });
|
||||
}
|
||||
|
||||
function fetch(path) {
|
||||
if (cache[path] != null)
|
||||
return cache[path];
|
||||
|
||||
let file = resolve(path);
|
||||
let res = { file: file ?? path, patterns: [], error: null };
|
||||
|
||||
if (file == null) {
|
||||
res.error = 'not a file name - a bare name or an absolute path';
|
||||
}
|
||||
else {
|
||||
let f = rd(file);
|
||||
|
||||
if (f.error) {
|
||||
res.error = f.missing ? f.error : `cannot read: ${f.error}`;
|
||||
}
|
||||
else {
|
||||
let p = parse(f.text);
|
||||
|
||||
res.patterns = p.patterns;
|
||||
|
||||
if (!length(p.patterns))
|
||||
res.error = p.rejected
|
||||
? sprintf('no usable pattern, %d line(s) rejected (%s)',
|
||||
p.rejected, p.first)
|
||||
: 'no usable pattern';
|
||||
else if (p.rejected)
|
||||
res.warn = sprintf('%d line(s) rejected (%s), %d pattern(s) kept',
|
||||
p.rejected, p.first, length(p.patterns));
|
||||
|
||||
files++;
|
||||
entries += length(p.patterns);
|
||||
}
|
||||
}
|
||||
|
||||
cache[path] = res;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
for (let p in (policies ?? [])) {
|
||||
let file_domains = [];
|
||||
|
||||
for (let path in (p?.domain_files ?? [])) {
|
||||
let res = fetch(path);
|
||||
|
||||
if (res.error)
|
||||
reject(p?.name, res.file, res.error);
|
||||
else if (res.warn)
|
||||
reject(p?.name, res.file, res.warn);
|
||||
|
||||
for (let pat in res.patterns)
|
||||
push(file_domains, pat);
|
||||
}
|
||||
|
||||
push(out, { ...p, file_domains });
|
||||
}
|
||||
|
||||
return { policies: out, issues, files, entries };
|
||||
};
|
||||
+61
-28
@@ -35,19 +35,49 @@ export function normalize(s) {
|
||||
return s;
|
||||
};
|
||||
|
||||
// The regex is the fast path and says the same thing ok_label() does; the
|
||||
// label walk runs only on failure, to name the offender. Measured on 100k
|
||||
// patterns from a file: four times faster than walking every label.
|
||||
const RE_NAME = /^[a-z0-9_-]{1,63}(\.[a-z0-9_-]{1,63})*$/;
|
||||
|
||||
function validate(name) {
|
||||
if (!length(name))
|
||||
return 'empty';
|
||||
if (length(name) > LIM.name)
|
||||
return 'too long';
|
||||
|
||||
if (match(name, RE_NAME))
|
||||
return null;
|
||||
|
||||
for (let l in split(name, '.'))
|
||||
if (!ok_label(l))
|
||||
return `bad label '${l}'`;
|
||||
|
||||
return null;
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
// One pattern, checked the way compile() checks it: `{ pat, wild }` for a
|
||||
// usable one, `{ error }` otherwise. Exported so a domain file is validated
|
||||
// by the same rules as a `list domain` entry.
|
||||
export function pattern(raw) {
|
||||
let pat = normalize(raw);
|
||||
let wild = false;
|
||||
|
||||
if (substr(pat, 0, 2) == '*.') {
|
||||
wild = true;
|
||||
pat = substr(pat, 2);
|
||||
}
|
||||
|
||||
if (index(pat, '*') >= 0)
|
||||
return { error: 'wildcard only allowed as leading *. label' };
|
||||
|
||||
let bad = validate(pat);
|
||||
if (bad)
|
||||
return { error: bad };
|
||||
|
||||
return { pat, wild };
|
||||
};
|
||||
|
||||
export function compile(policies) {
|
||||
let exact = {}, wild = {}, issues = [];
|
||||
|
||||
@@ -55,43 +85,46 @@ export function compile(policies) {
|
||||
push(issues, { policy, pattern, reason });
|
||||
}
|
||||
|
||||
function insert(pname, pat, is_wild) {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
let r = pattern(raw);
|
||||
|
||||
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');
|
||||
if (r.error) {
|
||||
reject(pname, raw, r.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bad = validate(pat);
|
||||
if (bad) {
|
||||
reject(pname, raw, bad);
|
||||
continue;
|
||||
}
|
||||
insert(pname, r.pat, r.wild);
|
||||
}
|
||||
|
||||
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);
|
||||
// file_domains arrive canonical from domain_file.uc - validated,
|
||||
// lower case, deduplicated - so they are not checked a second
|
||||
// time: with a list of a hundred thousand names that is the
|
||||
// difference between a start and a stall.
|
||||
for (let pat in (p?.file_domains ?? [])) {
|
||||
if (substr(pat, 0, 2) == '*.')
|
||||
insert(pname, substr(pat, 2), true);
|
||||
else
|
||||
insert(pname, pat, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-9
@@ -15,6 +15,11 @@ export const DEFAULTS = {
|
||||
entry_ttl: 1200
|
||||
};
|
||||
|
||||
// What a policy does with the traffic it selects. `route` marks it for its
|
||||
// own table, `bypass` only ends rule evaluation, so a later policy cannot
|
||||
// claim the same packet. Everything else is a configuration error.
|
||||
export const ACTIONS = { route: true, bypass: true };
|
||||
|
||||
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}))?$/;
|
||||
@@ -104,6 +109,15 @@ export function addr_family(s) {
|
||||
return null;
|
||||
};
|
||||
|
||||
export function action_name(s) {
|
||||
let v = lc(trim(`${s ?? ''}`));
|
||||
|
||||
if (!length(v))
|
||||
return 'route';
|
||||
|
||||
return ACTIONS[v] ? v : null;
|
||||
};
|
||||
|
||||
function mask_shift(mask) {
|
||||
let n = 0;
|
||||
while (n < 32 && !((mask >> n) & 1))
|
||||
@@ -118,6 +132,7 @@ export function compile(policies, opts) {
|
||||
// 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 restore = [];
|
||||
let idx = 0;
|
||||
let learn = {};
|
||||
|
||||
@@ -135,6 +150,14 @@ export function compile(policies, opts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let action = action_name(p.action);
|
||||
|
||||
if (action == null) {
|
||||
reject(pname, p.action,
|
||||
"invalid action - 'route' or 'bypass'");
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = { '4': [], '6': [] }, dst = { '4': [], '6': [] };
|
||||
|
||||
for (let a in (p.src ?? [])) {
|
||||
@@ -187,7 +210,11 @@ export function compile(policies, opts) {
|
||||
if (length(ports) && !length(protos))
|
||||
protos = [ 'tcp', 'udp' ];
|
||||
|
||||
let has_dom = length(p.domains ?? []) > 0;
|
||||
// A named domain file counts as a domain selector whether or not it
|
||||
// was readable at this moment: the policy's sets exist and stay
|
||||
// empty until the file is, and `shunt flush` tears the policy down
|
||||
// without having to read anything.
|
||||
let has_dom = length(p.domains ?? []) + length(p.domain_files ?? []) > 0;
|
||||
let has_dst_any = length(dst['4']) || length(dst['6']);
|
||||
|
||||
// Ports and protocols were asked for and none survived validation.
|
||||
@@ -216,14 +243,16 @@ export function compile(policies, opts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++idx > capacity) {
|
||||
// A bypass policy owns no mark, no table and no rule - it only ends
|
||||
// evaluation - so it costs nothing from the mark capacity.
|
||||
if (action == 'route' && ++idx > capacity) {
|
||||
reject(pname, null,
|
||||
sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x)',
|
||||
capacity, mask));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mark = idx << shift;
|
||||
let mark = (action == 'route') ? idx << shift : null;
|
||||
// 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.
|
||||
@@ -239,11 +268,42 @@ export function compile(policies, opts) {
|
||||
? 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);
|
||||
// bypass keeps whatever mark the packet carries: no shunt rule after
|
||||
// this one is reached, and the bits outside the mask are not ours.
|
||||
// route also records the mark on the conntrack entry, so the flow's
|
||||
// later packets can be marked from it without another lookup - see
|
||||
// the restore rules below.
|
||||
let stmt = (action == 'bypass')
|
||||
? sprintf('%scounter return', l4)
|
||||
: sprintf('%smeta mark set (meta mark & 0x%08x) | 0x%08x ct mark set (ct mark & 0x%08x) | 0x%08x counter return',
|
||||
l4, ~mask & 0xffffffff, mark, ~mask & 0xffffffff, mark);
|
||||
|
||||
push(marks, { name: pname, index: idx, mark,
|
||||
rt_table: 8000 + idx, rt_prio: 31000 + idx });
|
||||
// The decision for a flow is made on its first packet and kept:
|
||||
// later packets in the original direction take the mark from the
|
||||
// conntrack entry, and never reach the lookups. That is what makes
|
||||
// a set change safe for connections already running - masquerade
|
||||
// kills a conntrack entry whose output interface changed
|
||||
// (nf_nat_inet_fn, oif_changed), so re-marking a live flow would
|
||||
// reset it. One rule per route policy, with its mark as a constant:
|
||||
// nft has no expression-to-expression OR. Family-agnostic, so one
|
||||
// rule serves both. Replies stay unmarked, as they always did - a
|
||||
// marked reply would look up the policy table and miss the LAN.
|
||||
if (action == 'route')
|
||||
push(restore, sprintf(
|
||||
'\t\tct state != new ct direction original ct mark & 0x%08x == 0x%08x meta mark set (meta mark & 0x%08x) | 0x%08x counter return',
|
||||
mask, mark, ~mask & 0xffffffff, mark));
|
||||
|
||||
// Two rule priorities per routing policy, in two bands 500 apart:
|
||||
// keep_local's main lookup keeps the band a released version already
|
||||
// used, so an upgrade removes the old rule with the new one's del,
|
||||
// and the policy table rule moves up out of the way.
|
||||
push(marks, (action == 'bypass')
|
||||
? { name: pname, action, index: null, mark: null,
|
||||
rt_table: null, rt_prio: null, rt_prio_local: null }
|
||||
: { name: pname, action, index: idx, mark,
|
||||
rt_table: 8000 + idx,
|
||||
rt_prio: 31500 + idx,
|
||||
rt_prio_local: 31000 + idx });
|
||||
|
||||
if (has_mac)
|
||||
push(sets, sprintf(
|
||||
@@ -325,16 +385,24 @@ export function compile(policies, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every other flow past its first packet is settled too: a bypass
|
||||
// decision, no policy, or older than the ruleset. It keeps whatever it
|
||||
// has and is not re-evaluated against sets that changed since.
|
||||
let settled = length(restore)
|
||||
? [ '\t\tct state != new counter return' ] : [];
|
||||
|
||||
let setup = join('\n', [
|
||||
`destroy table ${TABLE}`,
|
||||
`table ${TABLE} {`,
|
||||
...sets,
|
||||
'\tchain prerouting {',
|
||||
'\t\ttype filter hook prerouting priority mangle; policy accept;',
|
||||
...restore, ...settled,
|
||||
...map(rules4, (r) => r.text), ...map(rules6, (r) => r.text),
|
||||
'\t}',
|
||||
'\tchain output {',
|
||||
'\t\ttype route hook output priority mangle; policy accept;',
|
||||
...restore, ...settled,
|
||||
...map(filter(rules4, (r) => r.out), (r) => r.text),
|
||||
...map(filter(rules6, (r) => r.out), (r) => r.text),
|
||||
'\t}',
|
||||
@@ -345,8 +413,10 @@ export function compile(policies, opts) {
|
||||
return { setup, marks, issues, learn };
|
||||
};
|
||||
|
||||
// A write may carry its own timeout - the TTL of the answer it came from,
|
||||
// already clamped by the caller - or fall back to entry_ttl.
|
||||
export function refresh(writes, entry_ttl) {
|
||||
let ttl = entry_ttl ?? DEFAULTS.entry_ttl;
|
||||
let dflt = entry_ttl ?? DEFAULTS.entry_ttl;
|
||||
let out = [], issues = [];
|
||||
|
||||
for (let w in (writes ?? [])) {
|
||||
@@ -361,7 +431,7 @@ export function refresh(writes, entry_ttl) {
|
||||
|
||||
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));
|
||||
TABLE, w.set, w.addr, w.ttl ?? dflt));
|
||||
}
|
||||
|
||||
return { batch: length(out) ? join('\n', out) + '\n' : '', issues };
|
||||
|
||||
+77
-19
@@ -1,7 +1,9 @@
|
||||
// shunt - ip rule and route renderer
|
||||
// shunt - 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.
|
||||
// Renders the netlink operations for the policy routing tables and their
|
||||
// rules, as rt.uc sends them. Pure, like nft.uc - nothing here talks to
|
||||
// the kernel, which is why the handful of kernel constants it needs are
|
||||
// spelled out below rather than read from the rtnl module.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
@@ -12,6 +14,33 @@ const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/;
|
||||
|
||||
const BLACKHOLE_METRIC = 9999;
|
||||
|
||||
// Kernel ABI, from linux/rtnetlink.h and linux/fib_rules.h. Fixed for as
|
||||
// long as netlink exists, so a literal here costs nothing and keeps the
|
||||
// renderer importable without ucode-mod-rtnl.
|
||||
const AF = { '4': 2, '6': 10 };
|
||||
const ANY = { '4': '0.0.0.0/0', '6': '::/0' };
|
||||
const RTN_UNICAST = 1;
|
||||
const RTN_BLACKHOLE = 6;
|
||||
const RTPROT_BOOT = 3;
|
||||
const RT_SCOPE_UNIVERSE = 0;
|
||||
const RT_SCOPE_LINK = 253;
|
||||
const RT_TABLE_MAIN = 254;
|
||||
const FR_ACT_TO_TBL = 1;
|
||||
|
||||
// Policy options arrive as UCI strings - config.uc only collects them - so the
|
||||
// one boolean among them is read here, with the rest of the routing checks.
|
||||
function to_bool(v, dflt) {
|
||||
if (v == null || v == '')
|
||||
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;
|
||||
}
|
||||
|
||||
export function compile(policies, marks, opts) {
|
||||
let mask = opts?.mask ?? DEFAULTS.mask;
|
||||
let add = [], del = [], tables = [], issues = [];
|
||||
@@ -29,6 +58,11 @@ export function compile(policies, marks, opts) {
|
||||
if (!m)
|
||||
continue;
|
||||
|
||||
// A bypass policy has no mark, so it has no table and no rule; the
|
||||
// nft chain returning is the whole of it. `interface` is not read.
|
||||
if (m.action == 'bypass')
|
||||
continue;
|
||||
|
||||
let iface = p.interface;
|
||||
if (type(iface) != 'string' || match(iface, RE_IFACE) == null) {
|
||||
reject(p.name, iface, 'invalid or missing interface');
|
||||
@@ -59,32 +93,56 @@ export function compile(policies, marks, opts) {
|
||||
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);
|
||||
let keep = to_bool(p.keep_local, true);
|
||||
|
||||
if (keep === null) {
|
||||
reject(p.name, p.keep_local, 'keep_local must be 0 or 1, default kept');
|
||||
keep = true;
|
||||
}
|
||||
|
||||
let table = m.rt_table;
|
||||
|
||||
push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
|
||||
|
||||
for (let fam in [ '4', '6' ]) {
|
||||
let v = `-${fam}`;
|
||||
let family = AF[fam];
|
||||
|
||||
let route = [ 'ip', v, 'route', 'replace', 'default' ];
|
||||
// `ip route replace default [via gw] dev iface table n`: a
|
||||
// gateway makes it a global-scope route, without one it is
|
||||
// the point to point form with link scope, as ip renders it.
|
||||
let route = { family, table, dst: ANY[fam], oif: iface,
|
||||
type: RTN_UNICAST, protocol: RTPROT_BOOT,
|
||||
scope: gw[fam] ? RT_SCOPE_UNIVERSE : RT_SCOPE_LINK };
|
||||
if (gw[fam])
|
||||
push(route, 'via', gw[fam]);
|
||||
push(route, 'dev', iface, 'table', table);
|
||||
push(add, route);
|
||||
route.gateway = gw[fam];
|
||||
push(add, { cmd: 'newroute', msg: route });
|
||||
|
||||
if (fb == 'block')
|
||||
push(add, [ 'ip', v, 'route', 'replace', 'blackhole',
|
||||
'default', 'metric',
|
||||
sprintf('%d', BLACKHOLE_METRIC),
|
||||
'table', table ]);
|
||||
push(add, { cmd: 'newroute', msg: { family, table,
|
||||
dst: ANY[fam], type: RTN_BLACKHOLE,
|
||||
protocol: RTPROT_BOOT, scope: RT_SCOPE_UNIVERSE,
|
||||
priority: BLACKHOLE_METRIC } });
|
||||
|
||||
push(add, [ 'ip', v, 'rule', 'add', 'pref', pref,
|
||||
'fwmark', fwmark, 'lookup', table ]);
|
||||
// Ahead of the policy rule and on the same mark: main is
|
||||
// consulted with its default route suppressed, so marked traffic
|
||||
// to anything main has a specific route for - every attached
|
||||
// subnet, every static route - keeps taking it, and only what
|
||||
// would have used the default route reaches the policy table.
|
||||
if (keep)
|
||||
push(add, { cmd: 'newrule', msg: { family,
|
||||
action: FR_ACT_TO_TBL, priority: m.rt_prio_local,
|
||||
fwmark: m.mark, fwmask: mask, table: RT_TABLE_MAIN,
|
||||
suppress_prefixlen: 0 } });
|
||||
|
||||
unshift(del, [ 'ip', v, 'route', 'flush', 'table', table ]);
|
||||
unshift(del, [ 'ip', v, 'rule', 'del', 'pref', pref ]);
|
||||
push(add, { cmd: 'newrule', msg: { family,
|
||||
action: FR_ACT_TO_TBL, priority: m.rt_prio,
|
||||
fwmark: m.mark, fwmask: mask, table } });
|
||||
|
||||
unshift(del, { cmd: 'flush', msg: { family, table } });
|
||||
unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio } });
|
||||
// Deleted whether or not it is rendered now: keep_local may have
|
||||
// been on when the running ruleset was applied.
|
||||
unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio_local } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// shunt - route and rule application
|
||||
//
|
||||
// Sends the operations route.uc renders to the kernel over rtnetlink,
|
||||
// through ucode-mod-rtnl, where an `ip` process per command used to be.
|
||||
//
|
||||
// rtnl.request() answers null on success, false when the kernel refused
|
||||
// and null again when an attribute did not encode - an `oif` naming a
|
||||
// device that is not there yet, the boot-time case. So the return value
|
||||
// is not the verdict; rtnl.error() is, and it is null after a success.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
// require() inside so the module stays importable without rtnl, and the
|
||||
// caller decides what a missing module means for it.
|
||||
export function open() {
|
||||
let rtnl;
|
||||
|
||||
try {
|
||||
rtnl = require('rtnl');
|
||||
}
|
||||
catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtnl;
|
||||
};
|
||||
|
||||
function call(rtnl, cmd, flags, msg) {
|
||||
rtnl.request(cmd, flags, msg);
|
||||
|
||||
return rtnl.error();
|
||||
}
|
||||
|
||||
// The kernel does not filter a route dump by table on its own - the whole
|
||||
// forwarding base comes back - so the table is picked out here. A default
|
||||
// route carries no dst attribute in the dump but needs one to be deleted,
|
||||
// and the rest of the entry is echoed so the match is unambiguous.
|
||||
function flush(rtnl, C, msg) {
|
||||
let routes = rtnl.request(C.RTM_GETROUTE, C.NLM_F_DUMP,
|
||||
{ family: msg.family, table: msg.table });
|
||||
|
||||
if (type(routes) != 'array')
|
||||
return rtnl.error();
|
||||
|
||||
let err = null;
|
||||
|
||||
for (let r in routes) {
|
||||
if (r?.table != msg.table)
|
||||
continue;
|
||||
|
||||
let del = { family: r.family, table: r.table,
|
||||
dst: r.dst ?? (r.family == C.AF_INET6 ? '::/0' : '0.0.0.0/0'),
|
||||
type: r.type, scope: r.scope, protocol: r.protocol };
|
||||
|
||||
for (let k in [ 'oif', 'gateway', 'priority' ])
|
||||
if (r[k] != null)
|
||||
del[k] = r[k];
|
||||
|
||||
err = call(rtnl, C.RTM_DELROUTE, 0, del) ?? err;
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
// One rendered operation. Returns null when the kernel took it, otherwise
|
||||
// the reason as text - with no exit code left to interpret.
|
||||
export function exec(rtnl, op) {
|
||||
let C = rtnl.const;
|
||||
|
||||
switch (op?.cmd) {
|
||||
case 'newroute':
|
||||
return call(rtnl, C.RTM_NEWROUTE, C.NLM_F_CREATE | C.NLM_F_REPLACE, op.msg);
|
||||
case 'newrule':
|
||||
return call(rtnl, C.RTM_NEWRULE, C.NLM_F_CREATE | C.NLM_F_EXCL, op.msg);
|
||||
case 'delrule':
|
||||
return call(rtnl, C.RTM_DELRULE, 0, op.msg);
|
||||
case 'flush':
|
||||
return flush(rtnl, C, op.msg);
|
||||
default:
|
||||
return sprintf('unknown operation %J', op?.cmd);
|
||||
}
|
||||
};
|
||||
|
||||
// For the log: the operation in the shape a reader knows from `ip`.
|
||||
export function describe(op) {
|
||||
let m = op?.msg ?? {};
|
||||
let v = (m.family == 10) ? '-6' : '-4';
|
||||
|
||||
switch (op?.cmd) {
|
||||
case 'newroute':
|
||||
return sprintf('ip %s route replace %sdefault%s%s%s table %d', v,
|
||||
m.type == 6 ? 'blackhole ' : '',
|
||||
m.gateway ? ` via ${m.gateway}` : '',
|
||||
m.oif ? ` dev ${m.oif}` : '',
|
||||
m.priority != null ? ` metric ${m.priority}` : '', m.table);
|
||||
case 'newrule':
|
||||
return sprintf('ip %s rule add pref %d fwmark 0x%x/0x%x lookup %s%s', v,
|
||||
m.priority, m.fwmark, m.fwmask,
|
||||
m.table == 254 ? 'main' : sprintf('%d', m.table),
|
||||
m.suppress_prefixlen != null
|
||||
? sprintf(' suppress_prefixlength %d', m.suppress_prefixlen) : '');
|
||||
case 'delrule':
|
||||
return sprintf('ip %s rule del pref %d', v, m.priority);
|
||||
case 'flush':
|
||||
return sprintf('ip %s route flush table %d', v, m.table);
|
||||
default:
|
||||
return sprintf('%J', op);
|
||||
}
|
||||
};
|
||||
+3
-3
@@ -50,8 +50,8 @@ export function open(dev) {
|
||||
return { ok: true, sock: s };
|
||||
};
|
||||
|
||||
// Returns { policies, qname, a, aaaa } or { drop: <verdict> }. The verdict
|
||||
// strings are contract; the fixtures compare them verbatim.
|
||||
// Returns { policies, qname, a, aaaa, ttl } 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)
|
||||
@@ -71,5 +71,5 @@ export function observe(frame, matcher) {
|
||||
if (policies == null)
|
||||
return { drop: 'nomatch' };
|
||||
|
||||
return { policies, qname: r.qname, a: r.a, aaaa: r.aaaa };
|
||||
return { policies, qname: r.qname, a: r.a, aaaa: r.aaaa, ttl: r.ttl };
|
||||
};
|
||||
+3
-1
@@ -6,5 +6,7 @@ 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");' |
|
||||
import * as e from "shunt.match"; import * as f from "shunt.domain_file";
|
||||
import * as g from "shunt.route"; import * as h from "shunt.rt";
|
||||
print("modules-ok\n");' |
|
||||
grep 'modules-ok'
|
||||
Reference in new issue
Block a user