// shunt - nftables renderer // // Renders the whole ruleset: one table, two chains, per policy sets and // marks. Pure string building, no kernel access. // // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2026 Dirk Brenken export const TABLE = 'inet shunt'; // The mask must stay a contiguous block: shift, capacity and mark are all // derived from it. That is why it is a constant and not a UCI option. export const DEFAULTS = { mask: 0xff000000, entry_ttl: 1200 }; // 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}))?$/; export function set_name(kind, family, policy) { return `${kind}${family}_${policy}`; }; function valid_name(s) { return type(s) == 'string' && match(s, RE_NAME) != null; } export function mac_addr(s) { if (type(s) != 'string') return null; let m = trim(lc(s)); return match(m, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/) ? m : null; }; // A destination port or an inclusive range, normalised to nft syntax. Ports // are 1-65535; 0 is reserved and never a destination. export function port_spec(s) { let v = trim(`${s ?? ''}`); let m = match(v, /^([0-9]{1,5})(-([0-9]{1,5}))?$/); if (!m) return null; let lo = +m[1]; let hi = m[3] != null ? +m[3] : lo; if (lo < 1 || hi > 65535 || lo > hi) return null; return lo == hi ? `${lo}` : `${lo}-${hi}`; }; // tcp or udp only - nothing else carries a destination port, and naming a // protocol that cannot be filtered by port is a configuration error worth // reporting rather than silently rendering. export function proto_name(s) { let v = lc(trim(`${s ?? ''}`)); return (v == 'tcp' || v == 'udp') ? v : null; }; export function addr_family(s) { if (type(s) != 'string') return null; let m = match(s, RE_V4); if (m) { for (let i = 1; i <= 4; i++) if (+m[i] > 255) return null; if (m[6] != null && +m[6] > 32) return null; return 4; } m = match(s, RE_V6); if (m) { let body = split(s, '/')[0]; if (m[2] != null && +m[2] > 128) return null; if (index(body, ':::') >= 0) return null; if (length(split(body, '::')) > 2) return null; if (substr(body, 0, 1) == ':' && substr(body, 0, 2) != '::') return null; if (substr(body, -1) == ':' && substr(body, -2) != '::') return null; let groups = filter(split(body, ':'), (g) => g != ''); if (length(groups) > 8 || (length(groups) == 8 && index(body, '::') >= 0)) return null; for (let g in groups) if (length(g) > 4 || !match(g, /^[0-9A-Fa-f]+$/)) return null; if (index(body, '::') < 0 && length(groups) != 8) return null; return 6; } return null; }; 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)) n++; return n; } export function compile(policies, opts) { let mask = opts?.mask ?? DEFAULTS.mask; let shift = mask_shift(mask); let capacity = mask >> shift; // Rule records, not strings: a MAC rule belongs in prerouting only, and // both chains must render from one ordered list or precedence breaks. let issues = [], marks = [], sets = [], rules4 = [], rules6 = []; let restore = []; let idx = 0; let learn = {}; function reject(policy, entry, reason) { push(issues, { policy, entry, reason }); } for (let pi = 0; pi < length(policies ?? []); pi++) { let p = policies[pi]; let pname = p?.name; if (!valid_name(pname)) { reject(pname ?? `#${pi}`, null, 'invalid policy name - must match [A-Za-z0-9_]{1,24}'); continue; } let 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 ?? [])) { let fam = addr_family(a); if (fam) push(src[sprintf('%d', fam)], a); else reject(pname, a, 'invalid src address'); } for (let a in (p.dst ?? [])) { let fam = addr_family(a); if (fam) push(dst[sprintf('%d', fam)], a); else reject(pname, a, 'invalid dst address'); } let macs = []; for (let a in (p.src_mac ?? [])) { let m = mac_addr(a); if (m) push(macs, m); else reject(pname, a, 'invalid src_mac address'); } let ports = [], protos = []; for (let v in (p.dport ?? [])) { let q = port_spec(v); if (q) push(ports, q); else reject(pname, v, 'invalid dport - expected 1-65535 or a range'); } for (let v in (p.proto ?? [])) { let q = proto_name(v); if (q) push(protos, q); else reject(pname, v, 'invalid proto - only tcp and udp carry ports'); } // A port with no protocol means both, as banIP does it: "port 443 of // this client" almost always includes QUIC, and requiring the // protocol would let it slip through unnoticed. if (length(ports) && !length(protos)) protos = [ 'tcp', 'udp' ]; // 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. // Rendering the policy anyway would drop the narrowing and mark // everything the client sends - the same widening a mistyped client // selector gets refused for. if (length(p.dport ?? []) + length(p.proto ?? []) > 0 && !length(ports) && !length(protos)) { reject(pname, null, 'no usable port or protocol - policy skipped rather than widened to all traffic'); continue; } let has_ipsrc = length(src['4']) || length(src['6']); let has_mac = length(macs) > 0; let has_src = has_ipsrc || has_mac; let has_any = has_src || length(dst['4']) || length(dst['6']) || has_dom; if (!has_any) { reject(pname, null, 'policy selects nothing'); continue; } if (length(p.src ?? []) + length(p.src_mac ?? []) > 0 && !has_src) { reject(pname, null, 'no usable client selector - policy skipped rather than widened to every client'); continue; } // 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 = (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. let l4 = ''; if (length(protos)) l4 = length(protos) == 1 ? sprintf('meta l4proto %s ', protos[0]) : sprintf('meta l4proto { %s } ', join(', ', protos)); if (length(ports)) l4 += length(ports) == 1 ? sprintf('th dport %s ', ports[0]) : sprintf('th dport { %s } ', join(', ', ports)); // 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); // 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( '\tset %s { type ether_addr; counter; elements = { %s }; }', set_name('m', '', pname), join(', ', macs))); for (let fam in [ '4', '6' ]) { let ip = (fam == '4') ? 'ip' : 'ip6'; let rules = (fam == '4') ? rules4 : rules6; let atype = (fam == '4') ? 'ipv4_addr' : 'ipv6_addr'; if (has_dom) push(sets, sprintf( '\tset %s { type %s; flags timeout; counter; }', set_name('d', fam, pname), atype)); let prefixes = []; if (length(src[fam])) prefixes = [ ...prefixes, { pre: sprintf('%s saddr @%s ', ip, set_name('c', fam, pname)), out: true, per_family: true } ]; if (has_mac) prefixes = [ ...prefixes, { pre: sprintf('ether saddr @%s ', set_name('m', '', pname)), out: false, per_family: false } ]; if (!has_src) prefixes = [ { pre: '', out: true, per_family: false } ]; if (!length(prefixes)) { if (length(dst[fam]) || has_dom) reject(pname, null, sprintf( 'src has no v%s entry - v%s rules skipped to avoid over-marking', fam, fam)); continue; } if (length(src[fam])) push(sets, sprintf( '\tset %s { type %s; flags interval; counter; elements = { %s }; }', set_name('c', fam, pname), atype, join(', ', src[fam]))); if (length(dst[fam])) push(sets, sprintf( '\tset %s { type %s; flags interval; counter; elements = { %s }; }', set_name('s', fam, pname), atype, join(', ', dst[fam]))); for (let px in prefixes) if (length(dst[fam])) push(rules, { out: px.out, text: sprintf('\t\t%s%s daddr @%s %s', px.pre, ip, set_name('s', fam, pname), stmt) }); if (has_dom) learn[set_name('d', fam, pname)] = true; // Destinations exist, but all in the other family: nothing for // this one to route. A port or protocol alone does not have a // family, so it does not trigger this. if (!length(dst[fam]) && !has_dom && has_dst_any) continue; for (let px in prefixes) { if (has_dom) push(rules, { out: px.out, text: sprintf('\t\t%s%s daddr @%s %s', px.pre, ip, set_name('d', fam, pname), stmt) }); if (!has_dst_any && !has_dom && (px.per_family || fam == '4')) push(rules, { out: px.out, text: sprintf('\t\t%s%s', px.pre, stmt) }); } } } // 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}', '}', '' ]); 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 dflt = entry_ttl ?? DEFAULTS.entry_ttl; let out = [], issues = []; for (let w in (writes ?? [])) { let m = match(w?.set ?? '', /^[csd]([46])_[A-Za-z0-9_]{1,24}$/); let fam = addr_family(w?.addr ?? ''); if (!m || fam == null || sprintf('%d', fam) != m[1] || index(w.addr, '/') >= 0) { push(issues, { entry: w, reason: 'rejected, not rendered' }); continue; } push(out, sprintf('destroy element %s %s { %s }', TABLE, w.set, w.addr)); push(out, sprintf('add element %s %s { %s timeout %ds }', TABLE, w.set, w.addr, w.ttl ?? dflt)); } return { batch: length(out) ? join('\n', out) + '\n' : '', issues }; }; export function teardown() { return `destroy table ${TABLE}\n`; };