]> git.99rst.org Git - openwrt-packages.git/commitdiff
shunt: update to 0.1.6
authorDirk Brenken <redacted>
Mon, 7 Sep 2026 19:50:46 +0000 (21:50 +0200)
committerDirk Brenken <redacted>
Mon, 7 Sep 2026 19:50:46 +0000 (21:50 +0200)
- *Per-policy `action`, `route` (default) or `bypass` - the equivalent of pbr's `ignore`,
  an exception carved out of every policy below it.
- `keep_local`, default on - a marked packet to one of your own subnets
  stays on `main` instead of taking the policy's default route.
- The ruleset is re-applied when the table is deleted - e.g. with `/etc/init.d/firewall stop`
- README update

Signed-off-by: Dirk Brenken <redacted>
Co-authored-by: Claude <redacted>
Signed-off-by: Dirk Brenken <redacted>
net/shunt/Makefile
net/shunt/README.md
net/shunt/files/shunt.rpcd
net/shunt/files/shunt.uc
net/shunt/src/config.uc
net/shunt/src/dedupe.uc
net/shunt/src/nft.uc
net/shunt/src/route.uc

index caba5916fb20b178e234baddaba094fc2c1bd81d..a51bedbbd8679bc34611d07139823bb06e7f6b21 100644 (file)
@@ -5,8 +5,8 @@
 include $(TOPDIR)/rules.mk
 
 PKG_NAME:=shunt
-PKG_VERSION:=0.1.5
-PKG_RELEASE:=2
+PKG_VERSION:=0.1.6
+PKG_RELEASE:=1
 PKG_LICENSE:=GPL-3.0-or-later
 PKG_LICENSE_FILES:=
 PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
index 9fbc798a39c6ade77df807e768fdfcc2c756a377..bc58c40c85c2ee115d77c6a20c1ad8109c085f34 100644 (file)
@@ -51,6 +51,9 @@ shunt check
 * 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
+* 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
@@ -129,7 +132,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>
@@ -175,8 +178,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` |
@@ -232,6 +237,43 @@ 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.
 
+### 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,7 +284,7 @@ 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
@@ -327,6 +369,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**
 
 ```
@@ -354,7 +412,7 @@ Nothing is ever handed to a shell for parsing: `system()` takes an argument arra
 ## 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,15 +421,25 @@ 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>
+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
 ```
 
-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`.
@@ -387,7 +455,7 @@ 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 |
+| 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,7 +486,7 @@ 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.
 
@@ -442,6 +510,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`.
@@ -468,6 +548,7 @@ These are consequences of the design, stated rather than worked around:
 * **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.
 
index c4d51179243ad52c92469ac3a336d336c288e1f0..16411b8b85c61457b4ba8f7e531555df2ed2b5da 100644 (file)
@@ -19,7 +19,7 @@ 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 { 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';
 
@@ -80,6 +80,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 +168,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 +209,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 +258,16 @@ 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]
+                                               rules: (m.action == 'route' && rules)
+                                                       ? length(rules[m.name] ?? []) : null,
+                                               routes: (m.action == 'route') ? routes[m.name] : null
                                        });
                                }
 
index eb94660138d74b00c132f7a3a72775665164b3fa..abb65f043280127379bf2f7892a5a94a6e137602 100755 (executable)
@@ -13,7 +13,7 @@ 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 { action_name, compile as nft_compile, refresh, teardown, TABLE } 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,
@@ -143,6 +143,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;
@@ -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);
@@ -319,6 +351,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;
 
@@ -377,7 +412,7 @@ function run() {
        let targets = poll_names(state.cfg.policies);
 
        let stats = { started: time(), resolv: false, snoop: [],
-               matched: 0, drops: {} };
+               matched: 0, drops: {}, reapplied: 0 };
 
        try {
                resolv = require('resolv');
@@ -392,7 +427,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))
@@ -436,6 +472,8 @@ function run() {
                for (let argv in state.route.add)
                        quiet(argv);
 
+               ensure_table(wq);
+
                poll_cycle();
                cache.prune(time());
        }
@@ -514,6 +552,7 @@ function run() {
                                        matched: stats.matched,
                                        drops: stats.drops
                                },
+                               reapplied: stats.reapplied,
                                dedupe: cache.size()
                        };
                }
@@ -611,9 +650,14 @@ 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);
index b3c402649ae22998fef58c521df35f39d28feb33..11e16c4b504692f89e7ddfd7f5fc6dec0975775a 100644 (file)
@@ -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),
index 5b515baeb2ceed3e516cb7815f3d20dd969492a4..2cdd5c9c76d167fd379120b769ebac34a547994c 100644 (file)
@@ -33,9 +33,15 @@ export function create(entry_ttl) {
                return n;
        }
 
+       // 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() {
+               last = {};
+       }
+
        function size() {
                return length(keys(last));
        }
 
-       return { due, prune, size };
+       return { due, prune, reset, size };
 };
index 86dc18bbffa50a16858e7210f7848308d01363e4..a964d5592e31ccd7fad597be89ae5f63778ac942 100644 (file)
@@ -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))
@@ -135,6 +149,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 ?? [])) {
@@ -216,14 +238,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 +263,24 @@ 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);
-
-               push(marks, { name: pname, index: idx, mark,
-                       rt_table: 8000 + idx, rt_prio: 31000 + idx });
+               // bypass keeps whatever mark the packet carries: no shunt rule after
+               // this one is reached, and the bits outside the mask are not ours.
+               let stmt = (action == 'bypass')
+                       ? sprintf('%scounter return', l4)
+                       : sprintf('%smeta mark set (meta mark & 0x%08x) | 0x%08x counter return',
+                               l4, ~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(
index f2b03b277e29db9d7a6a02f6106d923071e15450..b3b71bba6db92f205ba39d0fef585354a0a05db6 100644 (file)
@@ -12,6 +12,20 @@ const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/;
 
 const BLACKHOLE_METRIC = 9999;
 
+// 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 +43,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,9 +78,17 @@ export function compile(policies, marks, opts) {
                if (gw_bad)
                        continue;
 
+               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 fwmark = sprintf('0x%x/0x%x', m.mark, mask);
                let table = sprintf('%d', m.rt_table);
                let pref = sprintf('%d', m.rt_prio);
+               let pref_local = sprintf('%d', m.rt_prio_local);
 
                push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
 
@@ -80,11 +107,24 @@ export function compile(policies, marks, opts) {
                                        sprintf('%d', BLACKHOLE_METRIC),
                                        'table', 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, [ 'ip', v, 'rule', 'add', 'pref', pref_local,
+                                       'fwmark', fwmark, 'lookup', 'main',
+                                       'suppress_prefixlength', '0' ]);
+
                        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 ]);
+                       // Deleted whether or not it is rendered now: keep_local may have
+                       // been on when the running ruleset was applied.
+                       unshift(del, [ 'ip', v, 'rule', 'del', 'pref', pref_local ]);
                }
        }
 
git clone https://git.99rst.org/PROJECT