⛅ Sync 2026-08-24 23:49:27

This commit is contained in:
github-actions[bot] committed 2026-08-24 23:49:27 +08:00
1 parent e187a41f8d
commit 1bd7b2c053
48 files changed
+4980 -4592

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
config global
option enabled '1'
option poll_interval '300'
option entry_ttl '1200'
option snoop '1'
option debug '0'
option rp_filter_manage '0'
list snoop_device 'br-lan'
config policy 'vpn'
option enabled '0'
option interface 'wg0'
option fallback 'main'
list src '192.168.1.50'
list domain 'example.com'
list domain '*.example.com'
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh /etc/rc.common
# shunt - policy based routing
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
START=95
USE_PROCD=1
shunt_init="/etc/init.d/shunt"
if [ -z "${IPKG_INSTROOT}" ]; then
case "${action}" in
"stop")
"${shunt_init}" running || exit 0
;;
esac
fi
start_service() {
procd_open_instance "shunt"
procd_set_param command /usr/sbin/shunt run
procd_set_param respawn 300 5 3
procd_set_param stdout 0
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
/usr/sbin/shunt flush 2>/dev/null
}
service_triggers() {
procd_add_reload_trigger "shunt"
}
reload_service() {
restart
}
+309
View File
@@ -0,0 +1,309 @@
// shunt - rpcd backend for the LuCI frontend
//
// Stateless: renders the configuration through the same modules the daemon
// uses and reads the rest from the kernel. The daemon is asked only for the
// facts nothing outside its process can see.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, readfile } from 'fs';
const ubus = require('ubus');
const rtnl = require('rtnl');
// The rtnl constants hang off a `const` sub-object, not off the module - the
// example in lib/rtnl.c's own header says otherwise and yields null.
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 { compile as route_compile } from 'shunt.route';
import { names as poll_names } from 'shunt.poll';
const TABLE_FAMILY = 'inet';
const TABLE_NAME = 'shunt';
function daemon_status() {
let conn = ubus.connect();
if (!conn)
return null;
let r = conn.call('shunt', 'status');
return r ?? null;
}
// The same sequence as the daemon's build_state(), minus the logging: two
// readings of one config file are how a status view starts to lie.
function render() {
let sections = config_load();
if (sections == null)
return null;
let cfg = config_parse(sections);
if (!cfg)
return null;
let dump = null;
let conn = ubus.connect();
if (conn)
dump = conn.call('network.interface', 'dump');
cfg.policies = netifd_resolve(cfg.policies, dump);
let m = match_compile(cfg.policies);
let n = nft_compile(cfg.policies);
let r = route_compile(cfg.policies, n.marks);
return { cfg, matcher: m, nft: n, route: r };
}
// Policy devices whose marked traffic the kernel would drop: max(all, <dev>),
// blocked only when all is strict (1) and the device is not loose itself. Same
// logic as the daemon. When rp_filter_manage is on the daemon has already set
// these to 2, so this reads back empty on its own.
function rp_filter_blocked(policies) {
let rp = (k) => trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
if (rp('all') != '1')
return [];
let seen = {}, blocked = [];
for (let p in (policies ?? [])) {
let dev = p.interface;
if (!length(dev ?? '') || seen[dev])
continue;
seen[dev] = true;
let v = rp(dev);
// Absent device: no traffic, nothing dropped - not blocked. The
// daemon re-checks on ifup when it appears.
if (v == '')
continue;
if (v != '2')
push(blocked, dev);
}
return blocked;
}
// Same check the daemon logs, surfaced for the UI: which of all/default carry
// strict reverse path filtering, which drops shunt's asymmetric traffic.
function rp_filter_strict() {
let strict = [];
for (let key in [ 'all', 'default' ])
if (trim(readfile(`/proc/sys/net/ipv4/conf/${key}/rp_filter`) ?? '') == '1')
push(strict, key);
return strict;
}
function nft_table() {
let fh = popen(sprintf('nft -j list table %s %s 2>/dev/null',
TABLE_FAMILY, TABLE_NAME), 'r');
if (!fh)
return null;
let out = fh.read('all');
fh.close();
if (!length(out ?? ''))
return null;
let j = json(out);
return j?.nftables ? j : null;
}
function nft_sets(table) {
let out = {};
for (let item in (table?.nftables ?? [])) {
let s = item?.set;
if (!s?.name)
continue;
let elems = [];
for (let e in (s.elem ?? [])) {
let v = e?.elem ?? e;
let val = v?.val ?? v;
push(elems, {
addr: (type(val) == 'object') ? (val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) : null) : val,
expires: v?.expires,
packets: v?.counter?.packets,
bytes: v?.counter?.bytes
});
}
out[s.name] = elems;
}
return out;
}
function kernel_rules(marks) {
let want = {};
for (let m in (marks ?? []))
want[sprintf('%d', m.mark)] = m.name;
let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP,
{ family: RT.AF_UNSPEC });
if (res == null)
return null;
let out = {};
for (let r in res) {
if (r?.fwmark == null)
continue;
let name = want[sprintf('%d', r.fwmark)];
if (!name)
continue;
if (!out[name])
out[name] = [];
push(out[name], {
family: r.family,
priority: r.priority,
table: r.table,
fwmark: r.fwmark,
fwmask: r.fwmask
});
}
return out;
}
function kernel_routes(marks) {
let out = {};
for (let m in (marks ?? [])) {
let n = 0;
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 });
if (res == null) {
n = null;
break;
}
for (let r in res)
if (r?.table == m.rt_table)
n++;
}
out[m.name] = n;
}
return out;
}
return {
'luci.shunt': {
status: {
args: {},
call: function(req) {
let st = render();
if (!st)
return { error: 'cannot read /etc/config/shunt' };
let svc = daemon_status();
let table = nft_table();
let rules = kernel_rules(st.nft.marks);
let routes = kernel_routes(st.nft.marks);
let policies = [];
for (let m in st.nft.marks) {
let p = null;
for (let c in st.cfg.policies)
if (c.name == m.name)
p = c;
push(policies, {
name: m.name,
mark: m.mark,
rt_table: m.rt_table,
rt_prio: m.rt_prio,
interface: p?.interface,
fallback: p?.fallback,
domains: length(p?.domains ?? []),
rules: rules ? length(rules[m.name] ?? []) : null,
routes: routes[m.name]
});
}
return {
running: (svc != null),
applied: (table != null),
service: svc,
global: st.cfg.global,
policies,
poll_names: length(poll_names(st.cfg.policies)),
rp_filter_blocked: rp_filter_blocked(st.cfg.policies),
rp_filter_strict: rp_filter_strict(),
issues: [
...st.cfg.issues,
...st.matcher.issues,
...st.nft.issues,
...st.route.issues
]
};
}
},
sets: {
args: { policy: '' },
call: function(req) {
let table = nft_table();
if (!table)
return { sets: {} };
let all = nft_sets(table);
let want = req.args?.policy;
if (!length(want ?? ''))
return { sets: all };
let out = {};
for (let name in all) {
let at = (substr(name, 0, 1) == 'm') ? 2 : 3;
if (substr(name, at) == want)
out[name] = all[name];
}
return { sets: out };
}
}
}
};
+646
View File
@@ -0,0 +1,646 @@
#!/usr/bin/ucode
// shunt - policy based routing daemon
//
// Reads the config, renders the ruleset and the routes, applies them, then
// keeps the learned sets fed from a poll cycle and a passive DNS observer.
// All decisions live in the modules; this file is wiring.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, writefile, readfile, unlink, mkdir, lstat, error as fs_error } from 'fs';
import { 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 { 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,
addresses as poll_addresses,
index_results as poll_index } from 'shunt.poll';
import { resolve as netifd_resolve } from 'shunt.netifd';
import { create as dedupe_create } from 'shunt.dedupe';
const RT_TABLES = '/etc/iproute2/rt_tables.d/shunt.conf';
const TAG = 'shunt';
let verbose = false;
let dbg = false;
// syslog(3) through the binding, not a `logger` process per line. '%s' as the
// format because syslog() runs sprintf over its arguments, and an ip error
// text can contain a percent sign.
const PRIO = { err: LOG_ERR, warn: LOG_WARNING, notice: LOG_NOTICE,
info: LOG_INFO, debug: LOG_DEBUG };
openlog(TAG, LOG_PID, LOG_DAEMON);
function log(prio, msg) {
syslog(PRIO[prio] ?? LOG_NOTICE, '%s', msg);
if (verbose)
warn(sprintf('[%s] %s\n', prio, msg));
}
function debug(msg) {
if (dbg)
log('debug', msg);
}
let ubus_conn = null;
function ubus() {
if (ubus_conn != null)
return ubus_conn;
try {
ubus_conn = require('ubus').connect();
}
catch (e) {
ubus_conn = false;
}
if (!ubus_conn)
log('warn', 'ubus unavailable - gateway discovery and interface events disabled');
return ubus_conn;
}
function netifd_dump() {
let c = ubus();
return c ? c.call('network.interface', 'dump') : null;
}
function load_config() {
let sections = cfg_load();
if (sections == null) {
log('err', 'ucode-mod-uci missing');
return null;
}
return cfg_parse(sections);
}
function report(kind, issues) {
for (let i in issues)
log('warn', sprintf('%s: %J', kind, i));
}
const RUN_DIR = '/tmp/.shunt';
const RUN_ERR = RUN_DIR + '/cmd.err';
function capture_ok() {
mkdir(RUN_DIR, 0o700);
let st = lstat(RUN_DIR);
return st != null && st.type == 'directory' && st.uid == 0 &&
!st.perm.group_write && !st.perm.other_write &&
!st.perm.group_read && !st.perm.other_read;
}
function loud(argv) {
if (!capture_ok())
return { rc: quiet(argv), err: '' };
let rc = system([ '/bin/sh', '-c',
sprintf('exec "$0" "$@" 2>%s', RUN_ERR), ...argv ]);
let err = '';
if (rc != 0)
err = replace(trim(readfile(RUN_ERR) ?? ''), /\s*\n\s*/g, '; ');
unlink(RUN_ERR);
return { rc, err };
}
// quiet() drops the child's stderr, loud() keeps it for the warning. Neither
// may be called `run` - that name is the daemon's own entry point.
function quiet(argv) {
return system([ '/bin/sh', '-c', 'exec "$0" "$@" 2>/dev/null', ...argv ]);
}
function nft_pipe(batch, what) {
let fh = popen('nft -f -', 'w');
if (!fh) {
log('err', sprintf('%s: cannot spawn nft: %s', what, fs_error()));
return false;
}
fh.write(batch);
let rc = fh.close();
if (rc != 0) {
log('err', sprintf('%s: nft exited %d', what, rc));
return false;
}
return true;
}
function apply(state) {
if (!nft_pipe(state.nft.setup, 'setup'))
return false;
if (length(state.route.rt_tables)) {
mkdir('/etc/iproute2/rt_tables.d', 0o755);
if (!writefile(RT_TABLES, state.route.rt_tables))
log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error()));
}
for (let argv in state.route.del)
quiet(argv);
let failed = 0;
let reasons = {};
for (let argv in state.route.add) {
let r = loud(argv);
if (r.rc != 0) {
let why = length(r.err) ? r.err : sprintf('exit %d', r.rc);
failed++;
reasons[why] = (reasons[why] ?? 0) + 1;
debug(sprintf('not applied: %s - %s', join(' ', argv), why));
}
}
for (let why in reasons)
log('warn', sprintf('%d of %d route/rule command(s) not applied - %s',
reasons[why], length(state.route.add), why));
if (failed)
log('warn', 'policy not applied yet - traffic falls through to main; restart once the interface is up');
return true;
}
function flush(state) {
if (state)
for (let argv in state.route.del)
quiet(argv);
nft_pipe(teardown(), 'teardown');
if (readfile(RT_TABLES) != null)
unlink(RT_TABLES);
}
function rp_read(k) {
return trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
}
// Distinct, existing policy devices. Deduped so a device shared by several
// policies is set or reported once.
function policy_devices(policies) {
let seen = {}, out = [];
for (let p in (policies ?? [])) {
let dev = p.interface;
if (length(dev ?? '') && !seen[dev]) {
seen[dev] = true;
push(out, dev);
}
}
return out;
}
// Which policy devices the kernel would drop marked traffic on. rp_filter
// takes max(conf.all, conf.<dev>), so a device is blocked only when all is
// strict (1 - 0 is off, 2 is loose) AND the device is not loosened itself. A
// device with no /proc entry does not exist yet and inherits default.
function rp_filter_blocked(policies) {
if (rp_read('all') != '1')
return [];
let blocked = [];
for (let dev in policy_devices(policies)) {
let v = rp_read(dev);
if (v == '')
continue;
if (v != '2')
push(blocked, dev);
}
return blocked;
}
// With rp_filter_manage set, shunt loosens rp_filter on its own policy devices
// - the per-interface fix the README documents, done automatically. Bounded to
// exactly the devices shunt routes into, never all/default, and only on a
// device that exists. Off by default: changing a security setting is opt-in.
function rp_filter_apply(policies) {
for (let dev in policy_devices(policies))
if (rp_read(dev) != '' && rp_read(dev) != '2')
loud([ 'sysctl', '-w', sprintf('net.ipv4.conf.%s.rp_filter=2', dev) ]);
}
// Reads the live /proc value, so when rp_filter_apply has done its job the
// list is empty on its own - no need to consult the switch a second time.
function check_rp_filter(policies) {
for (let dev in rp_filter_blocked(policies))
log('warn', sprintf('rp_filter is strict on %s - shunt\'s marked traffic will be dropped there; set net.ipv4.conf.%s.rp_filter=2 or enable rp_filter_manage, see the README',
dev, dev));
}
// silent: build only what a teardown consumes and say nothing about the
// configuration - flush() needs route.del and nothing else.
function build_state(silent) {
let cfg = load_config();
if (!cfg)
return null;
if (!silent)
report('config', cfg.issues);
cfg.policies = netifd_resolve(cfg.policies, netifd_dump());
let matcher = null;
if (!silent) {
matcher = match_compile(cfg.policies);
report('domain', matcher.issues);
}
let n = nft_compile(cfg.policies);
if (!silent)
report('nft', n.issues);
let r = route_compile(cfg.policies, n.marks);
if (!silent)
report('route', r.issues);
return { cfg, matcher, nft: n, route: r };
}
// nft -f reads the entire ruleset before resolving a single name, so on a box
// with large sets from another tool one add element costs seconds of CPU.
// Writes are collected and applied by a timer whose interval follows the
// measured cost: an ordinary box stays at the floor, an expensive one backs
// off.
const WRITE_MIN = 2;
const WRITE_MAX = 60;
const WRITE_FACTOR = 3;
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))
st.pending[`${w.set}/${w.addr}`] = w;
}
function drain_writes(st) {
let due = values(st.pending);
st.pending = {};
if (!length(due))
return;
let r = refresh(due, st.state.cfg.global.entry_ttl);
report('refresh', r.issues);
if (!length(r.batch))
return;
let t0 = time();
let ok = nft_pipe(r.batch, 'refresh');
let cost = time() - t0;
if (ok)
debug(sprintf('%d element(s) written in %ds', length(due), cost));
let want = cost * WRITE_FACTOR;
if (want < WRITE_MIN)
want = WRITE_MIN;
if (want > WRITE_MAX)
want = WRITE_MAX;
if (want != st.interval) {
debug(sprintf('write interval %ds -> %ds (last write %ds)',
st.interval, want, cost));
st.interval = want;
st.timer.set(want * 1000);
}
}
function run() {
let uloop, resolv;
try {
uloop = require('uloop');
}
catch (e) {
log('err', 'ucode-mod-uloop missing');
return 1;
}
let state = build_state();
if (!state)
return 2;
dbg = verbose || state.cfg.global.debug;
if (!state.cfg.global.enabled) {
log('notice', 'disabled in config');
return 0;
}
if (!length(state.nft.marks)) {
log('err', 'no usable policy - not starting, run `shunt check` for the reasons');
return 2;
}
if (state.cfg.global.rp_filter_manage)
rp_filter_apply(state.cfg.policies);
check_rp_filter(state.cfg.policies);
if (!apply(state)) {
flush(state);
return 1;
}
let cache = dedupe_create(state.cfg.global.entry_ttl);
let targets = poll_names(state.cfg.policies);
let stats = { started: time(), resolv: false, snoop: [],
matched: 0, drops: {} };
try {
resolv = require('resolv');
}
catch (e) {
resolv = null;
if (length(targets))
log('warn', 'ucode-mod-resolv missing - poll disabled, snoop only');
}
stats.resolv = (resolv != null);
let unresolved = {};
let wq = { state, cache, pending: {}, interval: WRITE_MIN, timer: null };
function poll_cycle() {
if (!resolv || !length(targets))
return;
let res = resolv.query(targets, { type: [ 'A', 'AAAA' ],
timeout: 2000, retries: 1 });
if (!res) {
log('warn', 'poll: query failed');
return;
}
let by = poll_index(res);
for (let name in targets) {
let got = poll_addresses(by, name);
let n = length(got.a) + length(got.aaaa);
if (n && unresolved[name]) {
unresolved[name] = false;
log('info', sprintf('poll: %s resolves again', name));
}
else if (!n && !unresolved[name]) {
let owners = state.matcher.test(name);
unresolved[name] = true;
log('warn', sprintf('poll: %s%s has no address - the policy entry has no effect until it resolves',
name, owners != null
? sprintf(' (policy %s)', join(', ', owners)) : ''));
}
}
queue_writes(wq, poll_plan(res, state.matcher, targets), time());
}
// The cache is pruned here and not at the tail of poll_cycle():
// poll_cycle() returns early without resolv, without pollable names
// and on a failed query, while snoop keeps feeding queue_writes() in
// all three cases.
function tick() {
for (let argv in state.route.add)
quiet(argv);
poll_cycle();
cache.prune(time());
}
wq.timer = uloop.interval(WRITE_MIN * 1000, () => drain_writes(wq));
poll_cycle();
uloop.interval(state.cfg.global.poll_interval * 1000, tick);
if (resolv && length(targets))
log('info', sprintf('poll: %d name(s) every %ds', length(targets),
state.cfg.global.poll_interval));
let c = ubus();
if (c) {
let pending = null;
function rebuild_routes() {
pending = null;
let resolved = netifd_resolve(state.cfg.policies, netifd_dump());
let r = route_compile(resolved, state.nft.marks);
report('route', r.issues);
// A policy device may have just appeared - the boot-time case a
// static sysctl.d file misses, since /proc/<dev> did not exist
// yet. Re-apply so it is loose from the moment it comes up, then
// re-check: with manage on the check reads the value just set
// and stays silent, without it this is the moment to warn.
if (state.cfg.global.rp_filter_manage)
rp_filter_apply(resolved);
check_rp_filter(resolved);
for (let argv in state.route.del)
quiet(argv);
for (let argv in r.add)
quiet(argv);
state.route = r;
}
c.listener('network.interface', (type, msg) => {
if (msg?.action != 'ifup' && msg?.action != 'ifdown')
return;
log('info', sprintf('%s %s - rebuilding routes',
msg.action, msg.interface ?? '?'));
if (pending)
pending.set(500);
else
pending = uloop.timer(500, rebuild_routes);
});
// Must be total: an exception in a ubus handler halts uloop and takes
// snoop, poll and the keeper down with the reply.
function status_reply() {
let names = [];
for (let m in state.nft.marks)
push(names, m.name);
return {
started: stats.started,
policies: names,
poll: {
resolv: stats.resolv,
names: length(targets),
interval: state.cfg.global.poll_interval
},
snoop: {
devices: stats.snoop,
matched: stats.matched,
drops: stats.drops
},
dedupe: cache.size()
};
}
let obj = c.publish('shunt', { status: { call: () => status_reply() } });
if (!obj)
log('warn', sprintf('cannot publish ubus object: %s',
require('ubus').error() ?? 'unknown'));
}
let socks = [];
if (state.cfg.global.snoop) {
let socket = null;
for (let dev in state.cfg.global.snoop_devices) {
let s = snoop_open(dev);
if (!s.ok) {
log('err', sprintf('snoop %s: %s', dev, s.err));
continue;
}
if (socket == null)
socket = require('socket');
let sock = s.sock;
push(socks, sock);
push(stats.snoop, dev);
// Each handler closes over its own socket; binding the loop
// variable would leave them all reading the last one opened.
uloop.handle(sock, () => {
let frame;
while ((frame = sock.recv(RECV_LEN, socket.MSG_DONTWAIT)) != null) {
let v = observe(frame, state.matcher);
if (v.drop != null) {
stats.drops[v.drop] = (stats.drops[v.drop] ?? 0) + 1;
continue;
}
stats.matched++;
let writes = [];
for (let policy in v.policies) {
for (let a in v.a)
push(writes, { set: `d4_${policy}`, addr: a });
for (let a in v.aaaa)
push(writes, { set: `d6_${policy}`, addr: a });
}
debug(sprintf('snoop: %s -> %s (%d addr)',
v.qname, join(', ', v.policies), length(writes)));
queue_writes(wq, writes, time());
}
}, uloop.ULOOP_READ);
}
if (length(socks))
log('info', sprintf('snoop: listening on %s',
join(', ', stats.snoop)));
else if (!resolv || !length(targets)) {
log('err', 'neither snoop nor poll available - nothing to do');
flush(state);
return 1;
}
}
log('notice', sprintf('started: %d polic%s, mask 0x%08x',
length(state.nft.marks), length(state.nft.marks) == 1 ? 'y' : 'ies',
0xff000000));
uloop.run();
log('notice', 'stopping');
for (let sock in socks)
sock.close();
flush(state);
return 0;
}
function check() {
verbose = true;
let state = build_state();
if (!state)
return 2;
printf('global: %.2J\n', state.cfg.global);
printf('policies: %d accepted, %d mark(s)\n',
length(state.cfg.policies), length(state.nft.marks));
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);
let total = length(state.matcher.issues) + length(state.nft.issues) +
length(state.route.issues);
printf('issues: %d (see above)\n', total);
printf('poll: %d name(s)\n', length(poll_names(state.cfg.policies)));
return length(state.nft.marks) ? 0 : 2;
}
let cmd = null;
for (let a in ARGV) {
if (a == '-v')
verbose = dbg = true;
else if (cmd == null)
cmd = a;
}
switch (cmd) {
case 'run':
exit(run());
case 'check':
exit(check());
case 'flush':
flush(build_state(true));
exit(0);
default:
warn('usage: shunt [-v] run|check|flush\n');
exit(2);
}