- *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>
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>
* 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
* 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>
| 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` |
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.
`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
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**
```
## 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
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`.
| | 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 |
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.
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`.
* **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.
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';
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;
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 });
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 });
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
});
}
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,
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;
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);
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;
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');
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))
for (let argv in state.route.add)
quiet(argv);
+ ensure_table(wq);
+
poll_cycle();
cache.prune(time());
}
matched: stats.matched,
drops: stats.drops
},
+ reapplied: stats.reapplied,
dedupe: cache.size()
};
}
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);
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),
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 };
};
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}))?$/;
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))
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 ?? [])) {
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.
? 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(
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 = [];
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');
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));
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 ]);
}
}