]> git.99rst.org Git - openwrt-packages.git/commitdiff
shunt: update 0.1.10
authorDirk Brenken <redacted>
Fri, 18 Sep 2026 14:06:24 +0000 (16:06 +0200)
committerDirk Brenken <redacted>
Fri, 18 Sep 2026 14:06:46 +0000 (16:06 +0200)
* snooped elements expire with the TTL of the DNS answer,
  clamped to 60..entry_ttl; entry_ttl is now the ceiling, polled elements still use it
* write cache tracks the expiry it last wrote per element and rewrites
  when the new expiry moves by at least half of the answer's lifetime, in either direction
* a flow's route is decided on its first packet and kept on the conntrack entry:
  route statements set ct mark, one restore rule per policy marks later packets
  of the original direction, settled flows skip the lookups
* LuCI: change the entry_ttl option text
* update the README

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

index b8d200cf6ba1989bb92db701ab36ac38b1f60505..892042a675b9021f90274cb23a10e07aa592928c 100644 (file)
@@ -5,7 +5,7 @@
 include $(TOPDIR)/rules.mk
 
 PKG_NAME:=shunt
-PKG_VERSION:=0.1.8
+PKG_VERSION:=0.1.10
 PKG_RELEASE:=1
 PKG_LICENSE:=GPL-3.0-or-later
 PKG_LICENSE_FILES:=
index 1a4b4550186c30568f9f6088d6f2255e1facb423..8620e8242dbc6dd8f7f97198fa3b91341546fb81 100644 (file)
@@ -173,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
 
@@ -332,10 +332,14 @@ Two sources feed the same nftables sets, union with an element timeout. They are
 * **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:
 
@@ -461,6 +465,7 @@ table inet shunt                     own table, see below
   set m_<policy>                     client MACs, no family digit, counter
 
 fwmark                               <index> << 24, mask 0xff000000
+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>
@@ -486,6 +491,8 @@ The interval follows what the last write actually cost, between 2 and 60 seconds
 
 **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.
@@ -494,7 +501,7 @@ Running both at once during a migration is safe by construction:
 
 | | pbr | mwan3 | shunt |
 | :--- | :--- | :--- | :--- |
-| fwmark mask | `0x00ff0000` | `0x00003f00` | `0xff000000` |
+| 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 |
@@ -584,7 +591,7 @@ 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.
index 62d15da00bd55beb5b59decf1299b8a853e834ec..06e1a0315ce9fa84bfd5dd2a51e5f89ed817a6b5 100755 (executable)
@@ -11,7 +11,7 @@
 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 { 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';
@@ -332,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) {
@@ -646,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)',
index 2cdd5c9c76d167fd379120b769ebac34a547994c..70fb17a26289bb3b83f5652d57422639f665d965 100644 (file)
@@ -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 = {};
-
-       function due(set, addr, now) {
+       let exp = {};
+
+       // 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++;
                        }
                }
@@ -36,11 +44,11 @@ export function create(entry_ttl) {
        // 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 = {};
+               exp = {};
        }
 
        function size() {
-               return length(keys(last));
+               return length(keys(exp));
        }
 
        return { due, prune, reset, size };
index 517cb817a6b739f3b07c22e6ce5bd2a6d77dc834..0c6847ab1ca7e9509d4fb1ffbfc653db2bb653f1 100644 (file)
@@ -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
        };
 };
index a5ec3f909e44fa5573eaaf991f09bebcec91a79d..5d37ea1b5ceacccb32fa822248e00fa3dae0afba 100644 (file)
@@ -132,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 = {};
 
@@ -269,10 +270,28 @@ export function compile(policies, opts) {
 
                // 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 counter return',
-                               l4, ~mask & 0xffffffff, mark);
+                       : 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);
+
+               // 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
@@ -366,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}',
@@ -386,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 ?? [])) {
@@ -402,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 };
index 5d8a38575c95e6cf0808442122c98ea1575235fe..bfef43e58dbfc55c7fef1f8f202ca56e799ab488 100644 (file)
@@ -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 };
 };
git clone https://git.99rst.org/PROJECT