⛄ Sync 2026-11-01 05:43:29

This commit is contained in:
github-actions[bot] committed 2026-11-01 05:43:29 +08:00
1 parent fae68afc83
commit d1e51134a0
187 files changed
+3752 -2114

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=shunt
PKG_VERSION:=0.2.0
PKG_RELEASE:=9
PKG_RELEASE:=10
PKG_LICENSE:=GPL-3.0-or-later
PKG_LICENSE_FILES:=
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
+23 -3
View File
@@ -65,7 +65,9 @@ shunt check
* OpenWrt with fw4/nftables
* `ucode` plus `ucode-mod-fs`, `ucode-mod-socket`, `ucode-mod-uci`, `ucode-mod-uloop`, `ucode-mod-resolv`, `ucode-mod-ubus`, `ucode-mod-rtnl`, `ucode-mod-log` and `rpcd-mod-ucode` - all pulled in by the package; `ip` is not needed, routes and rules are written over rtnetlink
`kmod-nft-tproxy` only for a `tproxy` policy. It is not pulled in: without it a tproxy policy is skipped as an issue and everything else is applied.
`kmod-nft-tproxy` only for a `tproxy` policy, unless the kernel has tproxy built in. It is not pulled in: without it a tproxy policy is skipped as an issue and everything else is applied.
IPv6 is optional. On a kernel without it - booted with `ipv6.disable=1` - the IPv6 routes and rules are not rendered, which costs one line in the log; the IPv4 side is applied as usual.
`ucode-mod-resolv` and `ucode-mod-ubus` are soft at runtime: without resolv, poll is skipped and the observer carries the service alone; without ubus, gateway discovery and interface events are skipped and the config's own values are used. Both cost one warning in the log, not a failed start.
@@ -133,6 +135,8 @@ uci commit firewall
Symptoms of getting this wrong are worth knowing, because they do not look like a firewall problem: the prerouting counters rise, `nft list set` shows the learned address being hit, and the client's connection simply times out.
**The router's own traffic needs the masquerade as well.** A policy without client selectors also covers what the router sends itself, in the `output` chain. Such a packet gets its source address before shunt sees it, from the route `main` would take - usually the WAN address. The mark re-routes the packet into the tunnel, the address stays, and the far end drops it: a WireGuard peer accepts only sources within its `allowed_ips`, and most providers filter the same way. fw4's source NAT runs for local traffic too, so the zone's `masq` fixes this - the zone above is therefore needed even if no LAN client ever uses the policy. Measured: without it, the router's own traffic through a `route` policy is lost, including another tunnel's encapsulated packets; with it, it passes. Destinations `main` already routes into the tunnel - the tunnel's own subnet - are not affected, their source address fits from the start.
<a id="installation-and-usage"></a>
## Installation and Usage
* Update your router's apk repository (`apk update`)
@@ -339,7 +343,7 @@ Order matters and only order: a bypass section placed after the policy it is mea
<a id="transparent-proxy-tproxy"></a>
### Transparent proxy: tproxy
`action 'tproxy'` hands the selected traffic to a transparent proxy running on the router - `hev-socks5-tproxy`, `sing-box` or `xray` in tproxy mode, or anything else that accepts `IP_TRANSPARENT` connections. shunt only steers: it does not start, configure or watch the proxy. It needs `kmod-nft-tproxy`.
`action 'tproxy'` hands the selected traffic to a transparent proxy running on the router - `hev-socks5-tproxy`, `sing-box` or `xray` in tproxy mode, or anything else that accepts `IP_TRANSPARENT` connections. shunt only steers: it does not start, configure or watch the proxy. It needs `kmod-nft-tproxy`, or a kernel with tproxy built in. A loaded or installed module is taken from the file system; otherwise shunt asks the kernel itself, with an `nft -c` dry run of a single tproxy rule that installs nothing.
```
config policy 'proxy'
@@ -402,6 +406,8 @@ udp:
Point `socks5` at your server, then enable the service with `uci set hev-socks5-tproxy.config.enabled='1'`, `uci commit hev-socks5-tproxy` and `/etc/init.d/hev-socks5-tproxy start`. Its own connections to the SOCKS5 server start on the router, so the tproxy policy never sees them; a `route` policy can still send them through a tunnel.
**br_netfilter breaks tproxy behind a bridge.** With `kmod-br-netfilter` loaded and `net.bridge.bridge-nf-call-iptables` (or `-ip6tables`) at `1` - dockerd needs it and turns it on - bridged traffic runs through the inet hooks in the bridge's own pass. For a client on `br-lan` the tproxy statement still finds the listener, but TCP answers the client's SYN with a reset, and the connection never opens. This is the kernel's behaviour, not shunt's; it fails the same way with a hand-written tproxy rule. shunt does not change a box-wide switch that something else set, it reports it as an issue on each tproxy policy. Setting the key back to `0` makes tproxy work, at the price of whatever needed it.
<a id="local-traffic-keep_local"></a>
### Local traffic: keep_local
@@ -455,6 +461,21 @@ In both forms the policy's own table decides for its clients in both address fam
`fallback 'block'` covers the policy's clients only. In the tunnel-as-default form, unselected clients move to the uplink when the tunnel goes down, because netifd withdraws the tunnel's route from `main` and the uplink's is left. Preventing that is a matter of the firewall, not of shunt.
**A tunnel inside another tunnel needs no policy.** To carry WireGuard `wg1` through `wg2`, route `wg1`'s endpoint into `wg2` with a plain route in `main`. Only the destination decides, and WireGuard's own route lookup sees that route, so it picks `wg2` and its address together. A policy would mark the encapsulated packets only after that, with the WAN address already set, and need masquerade on top - see [Prerequisites](#prerequisites). netifd already pins each endpoint with a host route; `tunlink` keeps `wg2`'s on the uplink, so it cannot follow a default route through `wg1` into a loop:
```
config interface 'wg2'
option proto 'wireguard'
option tunlink 'wan'
...
config route
option interface 'wg2'
option target '<wg1 endpoint>/32'
```
`wg2`'s peer must cover that endpoint in `allowed_ips` - `0.0.0.0/0` with `route_allowed_ips '0'` does, and keeps `wg2` usable as a policy interface for other traffic. `wg1` needs an MTU at least 80 below `wg2`'s. To send the router's own traffic through `wg1`, make it the default in `main` as above (`route_allowed_ips '1'`, lower `metric` than the uplink); shunt is then left with what it is for, the exceptions.
<a id="how-addresses-are-learned"></a>
## How addresses are learned
Two sources feed the same nftables sets, union with an element timeout. They are complementary, not alternative modes.
@@ -750,7 +771,6 @@ 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.
* **tproxy is detected by its module file.** shunt applies a tproxy policy when `nft_tproxy` is loaded or installed under `/lib/modules`. A custom kernel with tproxy built in has neither, and its tproxy policies are skipped as an issue.
* **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.
+9 -12
View File
@@ -7,7 +7,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, readfile, access } from 'fs';
import { popen, readfile } from 'fs';
const ubus = require('ubus');
const rtnl = require('rtnl');
@@ -23,6 +23,7 @@ import { load as files_load } from 'shunt.list_file';
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';
import { tproxy as tproxy_available, br_netfilter, ipv6 } from 'shunt.probe';
const TABLE_FAMILY = 'inet';
const TABLE_NAME = 'shunt';
@@ -38,14 +39,6 @@ function daemon_status() {
return r ?? null;
}
// Same test as the daemon's, so LuCI reports the policy the daemon skips.
function tproxy_available() {
let rel = trim(readfile('/proc/sys/kernel/osrelease') ?? '');
return !!(access('/sys/module/nft_tproxy') ||
(length(rel) && access(`/lib/modules/${rel}/nft_tproxy.ko`)));
}
// 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() {
@@ -72,8 +65,9 @@ function render() {
cfg.policies = f.policies;
let m = match_compile(cfg.policies);
let n = nft_compile(cfg.policies, { tproxy: tproxy_available() });
let r = route_compile(cfg.policies, n.marks);
let n = nft_compile(cfg.policies, { tproxy: tproxy_available(cfg.policies),
br_netfilter: br_netfilter() });
let r = route_compile(cfg.policies, n.marks, { ipv6: ipv6() });
return { cfg, files: f, matcher: m, nft: n, route: r };
}
@@ -234,8 +228,11 @@ function kernel_routes(marks) {
break;
}
// A kernel without a family's dump handler answers with
// every family it has - IPv4 under an IPv6 request on a kernel
// without IPv6 - so the family is checked, not assumed.
for (let r in res)
if (r?.table == m.rt_table)
if (r?.table == m.rt_table && r?.family == fam)
n++;
}
+10 -16
View File
@@ -8,7 +8,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, writefile, readfile, unlink, mkdir, access, error as fs_error } from 'fs';
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, MIN as cfg_min } from 'shunt.config';
@@ -22,6 +22,7 @@ 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 { tproxy as tproxy_available, br_netfilter, ipv6 } from 'shunt.probe';
import { create as dedupe_create } from 'shunt.dedupe';
const RT_TABLES = '/etc/iproute2/rt_tables.d/shunt.conf';
@@ -287,17 +288,6 @@ function check_rp_filter(policies) {
dev, dev));
}
// kmod-nft-tproxy is not a dependency, so a tproxy policy may name a module
// that is not there - and nft loads the ruleset as one batch, so one rule
// the kernel cannot resolve would take every policy down. Loaded, or
// installed and left to the kernel's autoload on first use.
function tproxy_available() {
let rel = trim(readfile('/proc/sys/kernel/osrelease') ?? '');
return !!(access('/sys/module/nft_tproxy') ||
(length(rel) && access(`/lib/modules/${rel}/nft_tproxy.ko`)));
}
// silent: build only what a teardown consumes and say nothing about the
// configuration - flush() needs route.del and nothing else.
function build_state(silent) {
@@ -326,14 +316,18 @@ function build_state(silent) {
// A teardown renders without the check: a policy whose module has
// gone since it was applied still owns a table and rules to remove.
// Kept, so a refresh renders against the same set of policies.
let opts = { tproxy: silent ? true : tproxy_available() };
let opts = { tproxy: silent ? true : tproxy_available(cfg.policies),
br_netfilter: silent ? [] : br_netfilter(), ipv6: ipv6() };
let n = nft_compile(cfg.policies, opts);
if (!silent)
report('nft', n.issues);
let r = route_compile(cfg.policies, n.marks);
if (!silent)
let r = route_compile(cfg.policies, n.marks, opts);
if (!silent) {
report('route', r.issues);
if (!opts.ipv6)
log('info', 'no IPv6 in this kernel - IPv6 routes and rules skipped');
}
return { cfg, matcher, files, opts, nft: n, route: r };
}
@@ -575,7 +569,7 @@ function run() {
pending = null;
let resolved = netifd_resolve(state.cfg.policies, netifd_dump());
let r = route_compile(resolved, state.nft.marks);
let r = route_compile(resolved, state.nft.marks, state.opts);
report('route', r.issues);
+5 -1
View File
@@ -150,8 +150,12 @@ export function parse(sections) {
continue;
}
if (s?.type != 'policy')
// A typo in a section type would otherwise leave the section
// silently unread - a misspelt `config global` runs on defaults.
if (s?.type != 'policy') {
reject(s?.name, null, sprintf("unknown section type '%s' - ignored", s?.type));
continue;
}
let v = s.values ?? {};
+7 -1
View File
@@ -187,7 +187,7 @@ export function compile(policies, opts) {
// policy down with it.
if (opts?.tproxy === false) {
reject(pname, null,
'tproxy needs kmod-nft-tproxy - policy skipped');
'tproxy is not available in this kernel - install kmod-nft-tproxy, policy skipped');
continue;
}
@@ -199,6 +199,12 @@ export function compile(policies, opts) {
continue;
}
// Applied anyway: only clients behind a bridge are hit, and the
// switch is not shunt's to flip.
if (length(opts?.br_netfilter))
reject(pname, null, sprintf('%s at 1 - tproxy fails for clients behind a bridge, set to 0',
join(' and ', opts.br_netfilter)));
let fb = p.fallback ?? 'main';
if (fb != 'main' && fb != 'block') {
+87
View File
@@ -0,0 +1,87 @@
// shunt - kernel feature probes
//
// What the running kernel can do, read once by the daemon at build time and
// by the rpcd backend per status call, so both report the same verdict.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, readfile, access } from 'fs';
import { action_name } from 'shunt.nft';
// The probe is a rule, not a table: nft -c hands the batch to the kernel and
// aborts it, so the kernel resolves the tproxy expression - autoloading the
// module if it is installed - and installs nothing. Both families, in the
// form nft.uc renders them. The destroy goes first so that a table of the
// same name, whatever it holds, cannot fail the probe; aborted with the
// rest, it never touches that table.
const TPROXY_PROBE = `destroy table inet shunt_probe
table inet shunt_probe {
chain p {
type filter hook prerouting priority mangle; policy accept;
meta nfproto ipv4 meta l4proto tcp tproxy ip to 127.0.0.1:1
meta nfproto ipv6 meta l4proto tcp tproxy ip6 to [::1]:1
}
}
`;
let tproxy_ok = false;
// kmod-nft-tproxy is not a dependency, so a tproxy policy may name a module
// that is not there - and nft loads the ruleset as one batch, so one rule
// the kernel cannot resolve would take every policy down. Loaded or
// installed is decided from the file system; anything else, a kernel with
// tproxy built in among it, by asking the kernel. Only a yes is kept: a
// module installed later turns up in /sys/module without a restart of the
// caller. Without a tproxy policy there is nothing to ask, and no nft to
// spawn.
export function tproxy(policies) {
if (tproxy_ok)
return true;
let wanted = false;
for (let p in (policies ?? []))
if (action_name(p?.action) == 'tproxy')
wanted = true;
if (!wanted)
return true;
let rel = trim(readfile('/proc/sys/kernel/osrelease') ?? '');
if (access('/sys/module/nft_tproxy') ||
(length(rel) && access(`/lib/modules/${rel}/nft_tproxy.ko`)))
return (tproxy_ok = true);
let fh = popen('nft -c -f - >/dev/null 2>&1', 'w');
if (!fh)
return false;
fh.write(TPROXY_PROBE);
return (tproxy_ok = (fh.close() == 0));
};
// With br_netfilter calling the inet hooks for bridged traffic, a tproxy
// statement for a client behind a bridge is evaluated in the bridge's
// prerouting pass: the socket is assigned, and TCP still answers the SYN
// with a reset - measured, with and without shunt. The switch is box-wide
// and something else turned it on, so it is reported, not changed.
// Returns the keys that are on.
export function br_netfilter() {
let on = [];
for (let k in [ 'bridge-nf-call-iptables', 'bridge-nf-call-ip6tables' ])
if (trim(readfile(`/proc/sys/net/bridge/${k}`) ?? '') == '1')
push(on, `net.bridge.${k}`);
return on;
};
// A kernel booted with ipv6.disable=1 has no IPv6 family at all, and every
// IPv6 route and rule is refused. Disabling IPv6 per device keeps the tree.
export function ipv6() {
return !!access('/proc/sys/net/ipv6');
};
+5 -2
View File
@@ -45,6 +45,9 @@ function to_bool(v, dflt) {
export function compile(policies, marks, opts) {
let mask = opts?.mask ?? DEFAULTS.mask;
// Without the IPv6 family the kernel refuses every IPv6 operation, so
// none is rendered; the nft side loads either way.
let fams = (opts?.ipv6 === false) ? [ '4' ] : [ '4', '6' ];
let add = [], del = [], tables = [], issues = [];
let by_name = {};
@@ -77,7 +80,7 @@ export function compile(policies, marks, opts) {
push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
for (let fam in [ '4', '6' ]) {
for (let fam in fams) {
let family = AF[fam];
push(add, { cmd: 'newroute', msg: { family,
@@ -137,7 +140,7 @@ export function compile(policies, marks, opts) {
push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
for (let fam in [ '4', '6' ]) {
for (let fam in fams) {
let family = AF[fam];
// `ip route replace default [via gw] dev iface table n`: a
+6 -4
View File
@@ -3,10 +3,12 @@
// Sends the operations route.uc renders to the kernel over rtnetlink,
// through ucode-mod-rtnl, where an `ip` process per command used to be.
//
// rtnl.request() answers null on success, false when the kernel refused
// and null again when an attribute did not encode - an `oif` naming a
// device that is not there yet, the boot-time case. So the return value
// is not the verdict; rtnl.error() is, and it is null after a success.
// rtnl.request() answers a bare acknowledgement with null - with true
// from ucode 2026-10-18 on, which 25.12 does not have - false when the
// kernel refused, and null again when an attribute did not encode: an
// `oif` naming a device that is not there yet, the boot-time case. So the
// return value is not the verdict; rtnl.error() is, and it is null after
// a success on every version.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
+1
View File
@@ -8,5 +8,6 @@ ucode -e 'import * as a from "shunt.config"; import * as b from "shunt.nft";
import * as c from "shunt.dns"; import * as d from "shunt.frame";
import * as e from "shunt.match"; import * as f from "shunt.list_file";
import * as g from "shunt.route"; import * as h from "shunt.rt";
import * as i from "shunt.probe";
print("modules-ok\n");' |
grep 'modules-ok'