mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 03:14:12 +08:00
⛄ Sync 2026-11-01 05:43:29
This commit is contained in:
1 parent
fae68afc83
commit
d1e51134a0
187 files changed
+3752
-2114
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user