B3, real root cause. On the live BPi-R3 Mini `netstat -lnup` showed shaterd
holding 33 sockets on the router's own LAN address 10.67.0.1:53, next to
dnsmasq's single socket, several with a growing Recv-Q. Reproduced read-only on
the box: 5 host queries to 10.67.0.1 -> 0 answers and total Recv-Q on those
sockets 0 -> 19200 (5 x 3840, one datagram parked in each, never read); 3
control queries to 127.0.0.1 -> all answered.
Where they come from: protocol/redirect/tproxy.go, tproxyPacketWriter.
WritePacket. The TPROXY UDP write-back socket must carry the ORIGINAL
DESTINATION as its source address, so upstream binds it there — but leaves it
UNCONNECTED (net.ListenPacket + WriteToUDPAddrPort) and sets SO_REUSEADDR AND
SO_REUSEPORT (sing's control.ReuseAddr sets both). An unconnected bound socket
is a RECEIVER as far as the kernel is concerned, so each one silently joins the
UDP demultiplex/reuseport set for that address:port. Nothing ever reads them —
this writer only sends.
With dns_intercept the original destination IS the router's LAN address, so
every intercepted DNS session parks another silent receiver on <lan-ip>:53. The
host's own queries to that address take the loopback path, are never diverted by
the nft plane (iifname is scoped to LAN devices), and are therefore spread across
that set by the reuseport 4-tuple hash: they land in a silent socket at random
and time out. Hence "2 restarts of 3 fine, the third dead", and hence a failure
that no ruleset rebuild or reconcile can touch. The stale [UNREPLIED] conntrack
entry seen alongside is a CONSEQUENCE of the unanswered query, not the cause.
Fix (upstream file, lx:tproxy_writeback_connect):
* CONNECT the write-back socket to the one peer it ever talks to. The kernel's
compute_score() rejects a connected socket for any other peer, and a
connected UDP socket (sk_state == TCP_ESTABLISHED) is excluded from
reuseport selection outright — so it can no longer be handed a datagram it
will not read. Nothing about the reply changes: same spoofed source, same
single peer, Write instead of WriteTo. The unconnected path is kept verbatim
for a destination that cannot be bound (domain socksaddr).
* A failed cached write now CLOSES the socket instead of only dropping the
reference (upstream left the fd to the GC finalizer).
* TProxy.Close() purges the UDP NAT cache. Closing the listener stops ingress
but the cache evicts lazily, so after the inbound is gone nothing wakes the
live sessions and each strands its write-back socket. Invisible upstream
(one close at shutdown); on this fork the engine is rebuilt on every apply,
so it was one stranded generation per apply.
Measured on the live box: the socket count is steady-state (22-40, fds 55-66),
i.e. bounded by the udpnat session lifetime rather than an unbounded leak — the
count itself is inherent to per-session write-back sockets and is harmless once
they are connected. The Close() purge removes the per-apply generations on top
of it.
The netplane UDP:53 conntrack flush from 32e8f8ff0 is KEPT, with its comment
corrected: it is hygiene on plane transitions, not the cure for B3.
Regression tests fail on the pre-fix code (verified by reverting each half):
TestWriteBackUsesConnectedSocket / TestWriteBackReusesOneSocket /
TestWriteBackClosesSocketOnWriteFailure ("use of WriteTo with pre-connected
connection") and TestTProxyCloseReleasesNatSessions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shater
A self-hosted internet-control appliance for OpenWrt routers. One box turns a home or office network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker, per-device parental control, and a live traffic dashboard — all local, all configured from a rich built-in web panel.
The primary README is Russian — README.md. This is a condensed English mirror.
What it is
shater is a network proxy stack for OpenWrt / ImmortalWrt / BananaWRT routers (Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and is managed from a built-in web panel.
The engine is a fork of sing-box via
sing-box-lx, compiled into a single Go
binary shaterd together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, AmneziaWG 2.0, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
A thin LuCI launcher (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
(default :8088).
Highlights
- Transparent TPROXY data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks.
- First-match routing by source / destination / list / geo / client → outbound / selector / chain / direct / block; node groups with balancer/observatory; multi-hop chains; per-rule egress.
- Fail-closed kill-switch (dead group → block, never a silent direct leak); own
inet shaternft table; atomic apply withnft -cvalidation and commit-confirm auto-rollback. - DNS filtering & blocklists with flexible sources (inline / file / url /
geosite), compiled
.srsmatcher; Block-DoH/DoT to stop filter bypass. - Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow, schedules) and per-domain/client/device statistics from in-process DNS events.
Full list with MVP/T1/T2 tags — docs-shater/FEATURES.md.
Install
Two signed feeds. Pick by the router's OpenWrt version. Verbatim commands and the
manual .ipk/.apk install are in docs-shater/INSTALL.md.
opkg (OpenWrt 24.10):
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
>> /etc/opkg/customfeeds.conf
opkg update && opkg install luci-app-shater # -> shater-core -> shaterd
apk (OpenWrt / ImmortalWrt / BananaWRT 25.12+):
wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
apk update && apk add luci-app-shater # -> shater-core -> shaterd
shater ships inert (globals off) so install never breaks connectivity. After
configuring nodes/rules: uci set shater.globals.enabled=1 && uci commit shater,
then shaterd apply and shaterd confirm.
Build from source
scripts/build-shaterd.sh [VERSION] [--fast] builds the SPA (Vite), embeds it via
//go:embed, cross-builds musl-static {amd64, arm64} and UPX-packs the artifact
into openwrt/shaterd/files/. Details in
docs-shater/INSTALL.md.
Repository layout
| Path | What |
|---|---|
shater/ |
Go control plane, DNS filter, stats aggregator, engine host |
panel/ |
Admin SPA (Vite + React + TS) and its Go server |
openwrt/ |
Packages: shaterd, shater-core, luci-app-shater, byedpi |
docs-shater/ |
Product documentation |
scripts/, ci/, .gitea/workflows/ |
Build script, feed/release scripts, CI |
SPECS/, docs-lx/ |
Engine-fork constitution/specs and feature-config reference |
docs/, mkdocs.yml |
Upstream sing-box docs (mkdocs) — kept as-is |
adapter/ cmd/ dns/ route/ option/ protocol/ transport/ … |
sing-box-lx engine tree |
CI, upstream & license
CI (.gitea/workflows/release.yml) builds all 4 packages and publishes signed
feeds: opkg (usign, key 5ac4b177689cb8e0) and apk (EC key shater-apk.pem). A
vX.Y.Z tag → versioned release; workflow_dispatch → rolling latest.
The engine is the sing-box-lx fork — a thin downstream of upstream sing-box that
lives by rebase, never merge; its constitution is
SPECS/CONSTITUTION.md. Licensed under
GPL-3.0, like upstream sing-box. Unofficial fork, not affiliated with
SagerNet.