mirror of
https://github.com/openwrt/packages.git
synced 2026-10-12 05:57:04 +04:00
shunt: update to 0.1.12
- fallback 'block' now rejects instead of dropping, so clients fail at once rather than on a timeout - tproxy: a proxied flow whose listener is gone is rejected the same way, whatever the fallback - route: the policy table's fallback default is a prohibit route instead of a blackhole - update the README Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Dirk Brenken <dev@brenken.org>
This commit is contained in:
5 files changed
+126
-30
No files matched your search
+2
-2
@@ -5,8 +5,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shunt
|
||||
PKG_VERSION:=0.1.11
|
||||
PKG_RELEASE:=3
|
||||
PKG_VERSION:=0.1.12
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
PKG_LICENSE_FILES:=
|
||||
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
|
||||
|
||||
+64
-6
@@ -51,7 +51,7 @@ shunt check
|
||||
* Resolver independent: works with any DNS backend, and with an encrypted upstream, because it reads the plaintext leg between client and resolver
|
||||
* Wildcard domains (`*.example.com`), learned passively as clients use them
|
||||
* Domain lists from a file - a community-maintained set of thousands of names, without a single UCI entry per name
|
||||
* Per-policy killswitch: hold the traffic when the interface drops, instead of leaking it out of the normal uplink
|
||||
* Per-policy killswitch: reject the traffic when the interface or proxy is gone, instead of leaking it out of the normal uplink - clients fail at once rather than on a timeout
|
||||
* Per-policy action: `route` marks the traffic for the policy interface, `bypass` exempts it from every policy below, `tproxy` hands it to a transparent proxy on the router
|
||||
* Your own networks stay reachable from a policy client, without listing them anywhere
|
||||
* Puts its own table back when something deletes it, `fw4 flush` included
|
||||
@@ -78,7 +78,7 @@ Any of them, and there is no supported-protocols list to check against, because
|
||||
* **Ethernet style interfaces** - OpenVPN `tap*`, a second wired uplink, a mobile connection - use the gateway discovered from netifd, or `gw4`/`gw6` if you set them.
|
||||
* **Interfaces netifd does not manage** work by name too. On OpenWrt this is the exception rather than the rule - wireguard, OpenVPN, L2TP and the rest all have netifd protocols and are managed like any other interface. It applies to a tunnel brought up outside netifd, by `wg-quick` or a script of your own. If such an interface does need a gateway, discovery cannot find one and you have to set `gw4`/`gw6` yourself.
|
||||
|
||||
**WireGuard: `route_allowed_ips` belongs off.** shunt renders `default dev <device>` into the policy's own table, so it needs no route from the tunnel itself. With the option on and `allowed_ips 0.0.0.0/0` netifd installs that prefix as a route in `main` instead, and the whole box follows the tunnel - the situation policy routing exists to avoid. Turning it off is all that is required; nothing else about the peer changes.
|
||||
**WireGuard: `route_allowed_ips` belongs off, unless the tunnel is the default uplink.** shunt renders `default dev <device>` into the policy's own table, so it needs no route from the tunnel itself. With the option on and `allowed_ips 0.0.0.0/0` netifd installs that prefix as a route in `main` instead, and the whole box follows the tunnel - the situation policy routing exists to avoid. Turning it off is all that is required; nothing else about the peer changes. Where the tunnel is meant to carry everything and the policies carve out the exceptions, leave it on - see [Default uplink and exceptions](#default-uplink-and-exceptions).
|
||||
|
||||
**`AllowedIPs` is not a route, and it still applies.** `route_allowed_ips` decides only whether netifd installs routes for those prefixes - they are written to the wireguard configuration either way, and the kernel uses them as its cryptokey filter. A packet whose destination matches no peer's `AllowedIPs` is dropped inside wireguard, however correctly it was routed there. A policy meant to carry general traffic therefore needs `0.0.0.0/0` on the peer, and `::/0` if it carries IPv6; what actually enters the tunnel is decided by the policy's selectors, not by the peer. Narrowing `AllowedIPs` to a few subnets is right only when those subnets really are everything the tunnel should ever carry.
|
||||
|
||||
@@ -318,9 +318,22 @@ The selectors are the usual ones. tproxy carries tcp and udp only, so a tproxy p
|
||||
|
||||
shunt delivers to `127.0.0.1` and `::1`. A proxy listening on loopback or on the wildcard address is found; one bound to the router's LAN address is not. The address is not left to the kernel on purpose: without one it picks the address of the incoming interface, and a proxy bound to loopback would never be found - the statement fails silently and the traffic is not proxied.
|
||||
|
||||
`fallback` decides what a **new** flow does when no listener is found - proxy stopped, crashed, wrong port: `main` (default) lets it take the normal uplink, `block` drops it. A flow that was already proxied is dropped either way once its listener is gone. Its state is in the proxy, and its conntrack entry was never NATed, so forwarding the rest of it would put the client's own address on the uplink - measured, which is why this does not follow `fallback`.
|
||||
Each family needs a listener that accepts it. A proxy on `0.0.0.0` or `127.0.0.1` serves IPv4 only, so the IPv6 traffic of the policy finds no listener and follows `fallback`. Listen on `::` - which takes both families unless the proxy sets `IPV6_V6ONLY` - or on both loopback addresses.
|
||||
|
||||
`interface`, `keep_local` and the gateway overrides are not read, and `keep_local '1'` set explicitly is reported. It would break the policy: main would forward packets the kernel has already handed to the proxy's socket, and the kernel drops those.
|
||||
Destinations on the router itself are never proxied, nor broadcast and multicast: DNS, DHCP, LuCI and SSH of a selected client reach the router as they would without shunt, and stay reachable with `fallback 'block'` while the proxy is down. This is tproxy's counterpart to `keep_local`, built in and not switchable. It covers the router's own addresses only - traffic to your other networks is forwarded unicast and is proxied like any other. A proxy on the router cannot reach those networks on the client's behalf in a useful way, so exempt them with a `bypass` section above the tproxy policy:
|
||||
|
||||
```
|
||||
config policy 'no_proxy'
|
||||
option action 'bypass'
|
||||
list dst '192.168.0.0/16'
|
||||
list dst 'fd00::/8'
|
||||
```
|
||||
|
||||
`fallback` decides what a **new** flow does when no listener is found - proxy stopped, crashed, wrong port: `main` (default) lets it take the normal uplink, `block` rejects it - tcp with a reset, anything else with ICMP admin-prohibited, so the client gives up at once instead of on a timeout. A flow that was already proxied is rejected the same way once its listener is gone, whatever the fallback. Its state is in the proxy, and its conntrack entry was never NATed, so forwarding the rest of it would put the client's own address on the uplink - measured, which is why this does not follow `fallback`.
|
||||
|
||||
Without `proto` or `dport` the policy owns every protocol its clients send, not only what a proxy can take. `fallback 'block'` therefore also rejects ICMP, ESP, GRE and the rest - while the proxy runs too, so `ping` from those clients never gets out. Router addresses, broadcast and multicast stay exempt as above, which keeps neighbour discovery and the router's own services working. With `proto` or `dport` the policy owns only what it names and `block` rejects only that; everything else the client sends - QUIC for a policy narrowed to `proto 'tcp'` - takes the normal uplink.
|
||||
|
||||
`interface`, `keep_local` and the gateway overrides are not read, and `keep_local '1'` set explicitly is reported - the exemption of the router's own addresses above takes its place. It would break the policy: main would forward packets the kernel has already handed to the proxy's socket, and the kernel drops those.
|
||||
|
||||
Only traffic passing through the router is proxied. tproxy exists in the prerouting hook alone, so the router's own traffic is not touched by a tproxy policy - which also keeps the proxy's upstream connections from looping back into it.
|
||||
|
||||
@@ -334,10 +347,29 @@ config rule
|
||||
option target 'ACCEPT'
|
||||
```
|
||||
|
||||
**Example backend: hev-socks5-tproxy.** It turns tproxy traffic into connections to a SOCKS5 server and is packaged for OpenWrt. Its sample `/etc/hev-socks5-tproxy/main.yml` already fits the policy above - tcp and udp on `::` port 1088, the SOCKS5 server expected on `127.0.0.1:1080`:
|
||||
|
||||
```
|
||||
socks5:
|
||||
port: 1080
|
||||
address: 127.0.0.1
|
||||
udp: 'udp'
|
||||
|
||||
tcp:
|
||||
port: 1088
|
||||
address: '::'
|
||||
|
||||
udp:
|
||||
port: 1088
|
||||
address: '::'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<a id="local-traffic-keep_local"></a>
|
||||
### Local traffic: keep_local
|
||||
|
||||
A policy that names only clients marks everything those clients send, and the mark decides the routing table before the destination is looked at. Without `keep_local` that includes traffic to your own networks: a marked packet to another VLAN would find only the policy table's default route and leave through the tunnel, and with `fallback 'block'` it would be dropped.
|
||||
A policy that names only clients marks everything those clients send, and the mark decides the routing table before the destination is looked at. Without `keep_local` that includes traffic to your own networks: a marked packet to another VLAN would find only the policy table's default route and leave through the tunnel, and with `fallback 'block'` it would be rejected.
|
||||
|
||||
`keep_local '1'` (default) renders a second ip rule per family, on the same mark and one priority ahead of the policy rule, that consults `main` with the default route suppressed:
|
||||
|
||||
@@ -360,7 +392,32 @@ Note that domain precedence is resolved *before* this, at the matcher: the most
|
||||
|
||||
`fallback 'main'` (default) renders no default route into the policy table, so when the policy interface is down the table is empty and marked traffic falls through to `main` - the normal uplink. Traffic keeps flowing, unpolicied.
|
||||
|
||||
`fallback 'block'` adds a blackhole default at metric 9999 to the policy table. While the interface is up its own default has the lower metric and wins; when the interface drops, the kernel withdraws that route and the blackhole catches everything. That is the killswitch: traffic belonging to the policy stops rather than leaking out of the wrong interface. It stops at the destinations the policy actually owns - see [keep_local](#local-traffic-keep_local) for what a marked packet to one of your own networks does, and how to make the killswitch hermetic.
|
||||
`fallback 'block'` adds a `prohibit` default at metric 9999 to the policy table. While the interface is up its own default has the lower metric and wins; when the interface drops, the kernel withdraws that route and the prohibit route catches everything, answering it with ICMP admin-prohibited - a connection attempt fails at once instead of on a timeout, measured. That is the killswitch: traffic belonging to the policy stops rather than leaking out of the wrong interface. It stops at the destinations the policy actually owns - see [keep_local](#local-traffic-keep_local) for what a marked packet to one of your own networks does, and how to make the killswitch hermetic.
|
||||
|
||||
<a id="default-uplink-and-exceptions"></a>
|
||||
### Default uplink and exceptions
|
||||
|
||||
shunt routes only what its policies select. Everything else follows `main`, and which uplink `main` uses is decided by the interface metrics, not by shunt. A box with a normal uplink and a tunnel can therefore be set up either way round:
|
||||
|
||||
**ISP as default, selected clients through the tunnel.** The uplink keeps its default route in `main`, the tunnel installs none (`route_allowed_ips '0'`). The policy names the tunnel as its `interface`:
|
||||
|
||||
```
|
||||
config policy 'vpn'
|
||||
option interface 'wg0'
|
||||
list src_mac 'aa:bb:cc:dd:ee:ff'
|
||||
```
|
||||
|
||||
**Tunnel as default, selected clients through the ISP.** The tunnel carries the default route in `main` (`route_allowed_ips '1'` and a lower `metric` than the uplink), and the policy names the uplink as its `interface`:
|
||||
|
||||
```
|
||||
config policy 'isp'
|
||||
option interface 'wan'
|
||||
list src_mac 'aa:bb:cc:dd:ee:ff'
|
||||
```
|
||||
|
||||
In both forms the policy's own table decides for its clients in both address families, so the metrics matter only for traffic no policy selects. Set them explicitly on both sides anyway, including `wan6` where IPv6 comes from an interface of its own: netifd's default `metric 0` stays 0 for IPv4, but the kernel stores an IPv6 route with metric 0 as 1024. An interface left at the default therefore wins the IPv4 default route and loses the IPv6 one against any interface with an explicit metric - unselected clients then leave through different uplinks per family.
|
||||
|
||||
`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 id="how-addresses-are-learned"></a>
|
||||
## How addresses are learned
|
||||
@@ -506,6 +563,7 @@ 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>, for tproxy: local default dev lo
|
||||
with fallback 'block': prohibit default metric 9999
|
||||
/etc/iproute2/rt_tables.d/shunt.conf the table name mapping, for `ip route show` only
|
||||
```
|
||||
|
||||
|
||||
+55
-17
@@ -242,6 +242,11 @@ export function compile(policies, opts) {
|
||||
reject(pname, v, 'invalid proto - only tcp and udp carry ports');
|
||||
}
|
||||
|
||||
// Whether the policy itself names protocols or ports. A blocking
|
||||
// tproxy policy that does not rejects everything it cannot proxy;
|
||||
// one that does keeps to what it names.
|
||||
let narrowed = length(protos) > 0 || length(ports) > 0;
|
||||
|
||||
// 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.
|
||||
@@ -299,17 +304,31 @@ export function compile(policies, opts) {
|
||||
// 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 = '';
|
||||
let l4 = '', pt = '';
|
||||
|
||||
if (length(ports))
|
||||
pt = length(ports) == 1
|
||||
? sprintf('th dport %s ', ports[0])
|
||||
: sprintf('th dport { %s } ', join(', ', ports));
|
||||
|
||||
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));
|
||||
l4 += pt;
|
||||
|
||||
// tproxy has no keep_local, so without this a client selector alone
|
||||
// would hand the proxy everything the client sends - DNS and LuCI on
|
||||
// the router itself, broadcast and multicast included - and with
|
||||
// fallback 'block' reject it while the proxy is down. Not proxied
|
||||
// means untouched: it is delivered or forwarded as it would be
|
||||
// without shunt. A forwarded unicast address still matches, an
|
||||
// unreachable one too, so a router without a route for a family
|
||||
// can still have it proxied. The block rules below carry the same
|
||||
// term, which also keeps neighbour discovery out of their reach.
|
||||
let fib = (action == 'tproxy')
|
||||
? 'fib daddr type != { local, broadcast, multicast } ' : '';
|
||||
|
||||
// bypass keeps whatever mark the packet carries: no shunt rule after
|
||||
// this one is reached, and the bits outside the mask are not ours.
|
||||
@@ -324,27 +343,44 @@ export function compile(policies, opts) {
|
||||
// tproxy names the family of its address, so its statement exists
|
||||
// once per family. When the socket lookup finds no listener the
|
||||
// statement ends the rule unmatched and the packet goes on to the
|
||||
// next one - with fallback 'block' that is the drop rendered right
|
||||
// after it, otherwise the normal uplink.
|
||||
// next one - with fallback 'block' those are the rejects rendered
|
||||
// right after it, otherwise the normal uplink.
|
||||
function stmt(fam) {
|
||||
if (action == 'bypass')
|
||||
return sprintf('%scounter return', l4);
|
||||
if (action == 'tproxy')
|
||||
return sprintf('%stproxy %s to %s:%s %scounter return', l4,
|
||||
return sprintf('%s%stproxy %s to %s:%s %scounter return', l4, fib,
|
||||
fam == '4' ? 'ip' : 'ip6', TPROXY_ADDR[fam], tp_port, setmark);
|
||||
return sprintf('%s%scounter return', l4, setmark);
|
||||
}
|
||||
|
||||
// A rule, and for a blocking tproxy policy the drop that catches it
|
||||
// failing. tproxy exists in prerouting only, so none of it goes to
|
||||
// output: the router's own traffic is not proxied, and the proxy's
|
||||
// own upstream connections cannot loop back into it.
|
||||
// A rule, and for a blocking tproxy policy the rejects that catch
|
||||
// it failing. Rejected, not dropped, so a client gives up at once
|
||||
// rather than on a timeout: tcp is reset, anything else answered
|
||||
// with admin-prohibited. Without its own protocols or ports the
|
||||
// policy owns every protocol the client sends, and what the proxy
|
||||
// cannot take - icmp, esp, gre - is rejected whether it runs or
|
||||
// not; with them, it owns only those. tproxy exists in prerouting
|
||||
// only, so none of it goes to output: the router's own traffic is
|
||||
// not proxied, and the proxy's own upstream connections cannot
|
||||
// loop back into it.
|
||||
function emit(rules, out, pre, fam) {
|
||||
push(rules, { out: out && action != 'tproxy',
|
||||
text: sprintf('\t\t%s%s', pre, stmt(fam)) });
|
||||
if (tp_block)
|
||||
if (!tp_block)
|
||||
return;
|
||||
if (!narrowed || index(protos, 'tcp') >= 0)
|
||||
push(rules, { out: false,
|
||||
text: sprintf('\t\t%s%scounter drop', pre, l4) });
|
||||
text: sprintf('\t\t%smeta l4proto tcp %s%scounter reject with tcp reset',
|
||||
pre, pt, fib) });
|
||||
if (!narrowed)
|
||||
push(rules, { out: false,
|
||||
text: sprintf('\t\t%s%scounter reject with icmpx admin-prohibited',
|
||||
pre, fib) });
|
||||
else if (index(protos, 'udp') >= 0)
|
||||
push(rules, { out: false,
|
||||
text: sprintf('\t\t%smeta l4proto udp %s%scounter reject with icmpx admin-prohibited',
|
||||
pre, pt, fib) });
|
||||
}
|
||||
|
||||
// The decision for a flow is made on its first packet and kept:
|
||||
@@ -375,8 +411,9 @@ export function compile(policies, opts) {
|
||||
// its connected socket either way. Without a listener the flow
|
||||
// cannot go on anywhere: its state is in the proxy, and the conntrack
|
||||
// entry was never NATed, so forwarding it would put the client's
|
||||
// own address on the uplink - measured too. It is dropped whatever
|
||||
// the fallback; fallback decides about new flows only.
|
||||
// own address on the uplink - measured too. It is rejected whatever
|
||||
// the fallback, tcp with a reset so the client sees the end at once;
|
||||
// fallback decides about new flows only.
|
||||
if (action == 'tproxy') {
|
||||
let pl = length(protos) == 1 ? protos[0] : sprintf('{ %s }', join(', ', protos));
|
||||
for (let fam in [ '4', '6' ])
|
||||
@@ -384,7 +421,8 @@ export function compile(policies, opts) {
|
||||
'\t\t%smeta nfproto ipv%s meta l4proto %s tproxy %s to %s:%s %scounter return',
|
||||
ctm, fam, pl, fam == '4' ? 'ip' : 'ip6', TPROXY_ADDR[fam],
|
||||
tp_port, rmark));
|
||||
push(restore, sprintf('\t\t%scounter drop', ctm));
|
||||
push(restore, sprintf('\t\t%smeta l4proto tcp counter reject with tcp reset', ctm));
|
||||
push(restore, sprintf('\t\t%scounter reject with icmpx admin-prohibited', ctm));
|
||||
}
|
||||
|
||||
// Two rule priorities per routing policy, in two bands 500 apart:
|
||||
|
||||
@@ -12,7 +12,7 @@ import { addr_family, DEFAULTS } from 'shunt.nft';
|
||||
|
||||
const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/;
|
||||
|
||||
const BLACKHOLE_METRIC = 9999;
|
||||
const BLOCK_METRIC = 9999;
|
||||
|
||||
// Kernel ABI, from linux/rtnetlink.h and linux/fib_rules.h. Fixed for as
|
||||
// long as netlink exists, so a literal here costs nothing and keeps the
|
||||
@@ -21,7 +21,7 @@ const AF = { '4': 2, '6': 10 };
|
||||
const ANY = { '4': '0.0.0.0/0', '6': '::/0' };
|
||||
const RTN_UNICAST = 1;
|
||||
const RTN_LOCAL = 2;
|
||||
const RTN_BLACKHOLE = 6;
|
||||
const RTN_PROHIBIT = 8;
|
||||
const RTPROT_BOOT = 3;
|
||||
const RT_SCOPE_UNIVERSE = 0;
|
||||
const RT_SCOPE_LINK = 253;
|
||||
@@ -152,9 +152,9 @@ export function compile(policies, marks, opts) {
|
||||
|
||||
if (fb == 'block')
|
||||
push(add, { cmd: 'newroute', msg: { family, table,
|
||||
dst: ANY[fam], type: RTN_BLACKHOLE,
|
||||
dst: ANY[fam], type: RTN_PROHIBIT,
|
||||
protocol: RTPROT_BOOT, scope: RT_SCOPE_UNIVERSE,
|
||||
priority: BLACKHOLE_METRIC } });
|
||||
priority: BLOCK_METRIC } });
|
||||
|
||||
// Ahead of the policy rule and on the same mark: main is
|
||||
// consulted with its default route suppressed, so marked traffic
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export function describe(op) {
|
||||
switch (op?.cmd) {
|
||||
case 'newroute':
|
||||
return sprintf('ip %s route replace %sdefault%s%s%s table %d', v,
|
||||
m.type == 6 ? 'blackhole ' : m.type == 2 ? 'local ' : '',
|
||||
m.type == 8 ? 'prohibit ' : m.type == 2 ? 'local ' : '',
|
||||
m.gateway ? ` via ${m.gateway}` : '',
|
||||
m.oif ? ` dev ${m.oif}` : '',
|
||||
m.priority != null ? ` metric ${m.priority}` : '', m.table);
|
||||
|
||||
Reference in new issue
Block a user