The posture was inverted. A client using the DHCP-supplied resolver — the router itself — was NOT intercepted: dnsmasq answered and forwarded to the ISP in the clear, so the filter, the blocklists, the per-device rules and BlockDoH were all inert for exactly the clients that did nothing wrong. A client that hardcoded 8.8.8.8 to route around us WAS intercepted, by the catch-all. Meanwhile the docs promised no DNS leaks. The default now matches the promise. Turning it on crosses a threshold that was already dangerous for anyone with two resolvers. Above one transport, a node's domain server address stops being resolved by the transport directly and goes through the client DNS plane instead — so a blocklist entry, a block_doh NXDOMAIN or any dns_rule can answer your own node's hostname, and one sloppy line in an ad list stops being an ad that got through and becomes a tunnel that never comes up. So the fix is gated on having two or more transports, not on the intercept toggle: resolver_default plus resolver_fallback always reached that threshold, long before this change. When no endpoint_resolver is configured the plane now carries a bootstrap server — the default resolver cloned with its detour dropped, keeping its type, so a DoH default stays DoH and only the tunnel hop goes. An explicit endpoint_resolver still wins. This is not a restore of the previous behaviour and the comment says so: at one transport the dialer used the default resolver WITH its detour, so a lone DoH-through-the-tunnel resolver was already a bootstrap loop. It is strictly better than what came before. Existing installs keep whatever they set — the config file is a conffile and is never replaced — and an explicit dns_intercept '0' survives the render-parse round trip, which a default-true bool otherwise makes easy to lose. The no-resolver warning stays, and no default resolver is shipped to silence it: a placeholder would remove the sentence without moving a single query, and the panel would then say a resolver was configured while nothing was filtered. Its wording is corrected instead — .lan keeps working through the built-in local transport, which the old text denied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 KiB
Shater v0.2 — Build & Install
How to build the ship artifact (the SPA-embedded shaterd binary) and install
the signed apk repo onto a router.
1. Build the shaterd binary
scripts/build-shaterd.sh produces the release binary for every router arch:
scripts/build-shaterd.sh [VERSION] [--fast]
What it does:
- Builds the admin SPA —
cd panel && npm ci && npm run build(Vite →panel/dist). - Copies
panel/dist/*intoshater/panel/webroot/, so//go:embed all:webrootbakes the real SPA into the binary (not the "SPA not embedded" placeholder). - Cross-builds, for each of
{amd64, arm64}, with the D9 musl-static router tag set (CGO_ENABLED=0 GOOS=linux), stripped + trimmed, intodist/shaterd-<arch>(uncompressed, kept for debugging). - UPX
--lzma --best(D10) →dist/shaterd-<arch>.upx(~42 MB → ~8–11 MB). - Stages
dist/shaterd-<arch>.upxintoopenwrt/shaterd/files/for the package. - Prints a size table + a per-arch static check (must be
ET_EXEC, noPT_INTERP).
Arg / env:
VERSION— stamped intoconstant.Version. Resolution: positional arg →$SHATER_VERSION→ci/version.sh --binary→v0.2.0-dev.ci/version.shis the same computation the package version comes from (§2.1), so the string the panel shows always matches whatapk list -I shaterdreports.--fast— skipnpm ciwhenpanel/node_modulesalready exists.UPX=/path/to/upx— override the UPX binary (defaultupxonPATH). UPX is cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also reads$UPXas its own options — the scriptunsets it after capturing the path.)
Example (Windows host, Git Bash):
UPX="…/scratchpad/upx-4.2.4-win64/upx.exe" scripts/build-shaterd.sh v0.2.0 --fast
The dist/* and openwrt/shaterd/files/shaterd-*.upx outputs are gitignored —
they are release artifacts, not source.
Tag set (D9/D23) — defined in one place, scripts/router-tags.sh, which
documents every tag and is sourced by the build:
with_gvisor,with_quic,with_wireguard,with_utls,
badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
We drop with_purego,with_naive_outbound: they pull cronet-go, which forces a
glibc PT_INTERP even under CGO_ENABLED=0, making the binary unusable on musl.
We drop with_clash_api: the admin panel is shater's own web server and the
generator never emits a clash_api service, so the Clash server is dead code.
We drop with_dhcp: shater resolver types are udp/tcp/doh/dot/local/fakeip;
a dhcp:// DNS transport is never generated or registered.
with_gvisor was dropped in 2026-07 as "unreachable — we emit no tun inbound"
and put back on 2026-07-25: gVisor is also the netstack of the WireGuard
endpoint, so without it every wg:///awg:// node died at apply time with
"gVisor is not included in this build" while the panel still offered the
feature. It costs ~2.8 MB raw / ~0.65 MB UPX per arch. Full story: DECISIONS.md
D23.
Changing the tag set
Run the guard — it is what stands between a size trim and a silently dead feature, and CI runs it before the artifact is built:
scripts/check-router-tags.sh # from Windows/macOS it re-execs itself in golang:1.26
It (1) fails if a feature declared in FEATURES.md lost a build tag it needs to
run (shater/buildtags, no tags/OS/network required) and (2) constructs one node
of every declared protocol through box.New compiled with the shipped tag
set — nothing may be skipped in that run. Adding a protocol to
shater/parse+shater/generate means adding a row to buildtags.Features and a
probe case in shater/generate/shipped_tags_linux_test.go.
2. Packages
Four OpenWrt packages live under openwrt/:
| Package | Arch | What it ships |
|---|---|---|
shaterd |
per-arch | Prebuilt static shaterd binary → /usr/bin/shaterd (this is the ship artifact from step 1). |
shater-core |
all | procd init (supervises shaterd run), cron, hotplug, sysctl, inert default UCI. DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full. |
luci-app-shater |
all | Thin LuCI launcher: mini dashboard + token-handoff "Open panel" button. DEPENDS:=+shater-core +rpcd. |
byedpi |
per-arch | Optional ByeDPI (ciadpi) local desync SOCKS proxy for a type='byedpi' egress. |
Why shaterd is a prebuilt-binary package
The binary is the product of a toolchain the OpenWrt SDK can't easily reproduce:
an npm/Vite SPA build, embedded via //go:embed at go build time, the D9
musl-static tag set, and a UPX pass. Instead of running node + embed + UPX inside
the SDK, we build out-of-tree with scripts/build-shaterd.sh and package the
arch-matched artifact. The Makefile maps OpenWrt $(ARCH) → the artifact
(x86_64→amd64, aarch64→arm64), verifies it was staged, and $(INSTALL_BIN)s
it. Because the binary is UPX-packed, the package disables the SDK's default strip
(RSTRIP:=:) — stripping a packed executable would corrupt it.
CI order: (1) run scripts/build-shaterd.sh (builds + stages both arches into
openwrt/shaterd/files/); (2) copy openwrt/* into the SDK tree with the luci
feed installed and run make package/shaterd/compile (and the others) per target.
See openwrt-package-build-ci for SDK/feed mechanics.
2.1 Package versions come from the git tag
PKG_VERSION/PKG_RELEASE are not maintained by hand. They used to be, and
nobody bumped them: v0.2.2 … v0.2.6 all shipped as shaterd 0.2.0-r3 with
different binaries inside (v0.2.6's ELF is 5 491 616 B against r2's 5 488 336 B).
apk offers an upgrade only when the feed's version string differs from the
installed one, so apk update saw nothing new and the routers could not be
updated through the normal path at all.
ci/version.sh now derives them from git describe, once per CI job:
| Build | PKG_VERSION |
PKG_RELEASE |
constant.Version |
|---|---|---|---|
tag push v0.2.7 |
0.2.7 |
1 |
v0.2.7-r1 |
dispatch, 3 commits past v0.2.7 |
0.2.7 |
4 |
v0.2.7-r4-g<sha> |
| no reachable tag / no git | 0.0.0 |
1 |
v0.0.0-r1 |
Ordering is what makes this safe (checked with apk version -t on apk-tools
3.0.3): the dotted part decides first, -rN only breaks ties — so
0.2.7-r1 > 0.2.6-r12 > 0.2.6-r1 > 0.2.0-r3. A release therefore always
outranks every rolling build before it, rolling builds between two releases grow
monotonically, and an untagged build (0.0.0) can never masquerade as an
upgrade.
The value travels as SHATER_PKG_VERSION/SHATER_PKG_RELEASE in the SDK build
environment; the Makefiles read it with a literal fallback for manual/offline
builds. ci/sdk-build-apk.sh then asserts the produced .apk really carries
it, so a lost variable fails the build instead of shipping a stale version. The
release job asserts the same version again on the published rolling repo (§5.1).
byedpi is deliberately excluded — PKG_VERSION:=0.17.3 is upstream ByeDPI's
version, which is what PKG_HASH pins and what tells you which ByeDPI is
installed. Stamping our tag on it would also be a downgrade: every comparator
reads 0.2.7 < 0.17.3 (component-wise, 2 < 17). Bump its PKG_RELEASE by hand
when our packaging of it changes.
3. Install on a router
The normal path is the signed apk repo — §5. This section is the manual fallback (a router with no route to the Gitea host, or a hand-carried build).
Install order follows the deps (shaterd → shater-core → luci-app-shater).
apk filenames carry no architecture, so make sure you copied the .apk built for
this router's arch (cat /etc/apk/arch):
# <ver> = the release version, e.g. 0.2.7-r1 (§2.1 — it comes from the git tag)
# --allow-untrusted: our member .apk are unsigned by design — trust lives in the
# signed packages.adb index (§5), which a loose file install does not consult.
apk add --allow-untrusted ./shaterd-<ver>.apk
apk add --allow-untrusted ./shater-core-<ver>.apk
apk add --allow-untrusted ./luci-app-shater-<ver>.apk
apk add --allow-untrusted ./byedpi-0.17.3-r1.apk # optional: ByeDPI egress
From the repo instead (§5 sets it up once), deps pull the rest in:
apk update && apk add luci-app-shater # -> shater-core -> shaterd
4. Enable
Shater ships inert (globals disabled) so it never breaks connectivity on
install. Configure nodes/rules (via the LuCI panel or uci), then enable and apply:
uci set shater.globals.enabled=1
uci commit shater
shaterd apply # apply + arm commit-confirm on the running daemon
shaterd confirm # confirm (cancels the auto-rollback)
/etc/init.d/shater enable && /etc/init.d/shater start brings up the procd-supervised
daemon (shaterd run), which owns the engine, the inet shater data plane, policy
routing, in-process DNS, and the admin panel (default :8088). The LuCI app's
"Open panel" button mints a single-use token and hands the browser off to the panel.
What enabling does to DNS
From the first apply, every LAN plaintext :53 goes into the engine — including
the queries a client sends to the router's own address, which is what DHCP hands out.
That is globals.dns_intercept, and it is on by default (D24); without it those
queries reach dnsmasq and the ISP unfiltered, i.e. the client with default settings
leaks while the one that hard-coded 8.8.8.8 does not. What follows from it:
.lanand private reverse (PTR) lookups still go to dnsmasq — the engine gets a rule for those suffixes. If you renamed dnsmasq's domain away fromlan, add aconfig dns_rulefor the new suffix.- Configure at least one
config resolver. With none, the engine has no resolver plane: intercepted queries fall through to the system resolver (dnsmasq → your ISP, in the clear), blocklists and per-device DNS rules are inert, and the apply says so in its warnings. - While the engine is DOWN, DNS is not blacked out: the fail-closed holding
plane hooks
forwardonly, so dnsmasq keeps answering router-addressed:53(unfiltered, plaintext) while client traffic and DNS to external resolvers stay blocked. "The tunnel is down" is not "DNS is private".
To opt out, on the router:
uci set shater.globals.dns_intercept=0
uci commit shater
shaterd apply
Your 0 is kept: /etc/config/shater is a conffile (upgrades never replace it) and
the daemon always writes the option back explicitly, so it is never re-enabled by a
default.
5. The signed apk repo (the normal install path)
OpenWrt/ImmortalWrt 25.12 packages with Alpine's apk: .apk files, a
binary packages.adb index, EC (prime256v1) keys in /etc/apk/keys/, and
effectively mandatory signatures (unsigned needs --allow-untrusted). This is
the only format shater publishes — the .ipk/opkg lane was removed in 2026-07
(DECISIONS.md D22); every device we serve is on 25.12 with apk-tools 3.
CI (v* tag push or workflow_dispatch) compiles the 4 packages through the
official ImmortalWrt 25.12 SDK (tarballs from
downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/)
and publishes one release per arch: the rolling apk-latest-x86_64 /
apk-latest-aarch64_cortex-a53, plus apk-vX.Y.Z-<arch> on a tag. Per-arch
because apk filenames carry no architecture and packages are fetched relative to
the packages.adb URL, so one flat multi-arch release would collide.
Key: the trust anchor is the EC public key
dist/shater-apk.pem(generated once byci/gen-apk-key.sh; the private half lives ONLY in the Gitea secretKEY_APK). Never regenerate it — that invalidates every deployed router's trust.
5.1 Rolling or pinned — pick the repo URL deliberately
The repo line names an index file, and which one you name is the whole update policy:
| Repo line points at | Behaviour | Cost |
|---|---|---|
apk-latest-<arch>/packages.adb (rolling) |
Every release run REPLACES this release's assets, so apk update && apk upgrade <our packages> always sees the newest build. Install once, never touch the file again. |
You get whatever CI published last; there is no per-router pin. |
apk-vX.Y.Z-<arch>/packages.adb (pinned) |
The router stays on exactly that build. apk update will never offer a newer shater. |
/etc/apk/repositories.d/shater.list must be edited by hand on every upgrade, on every router. |
mini_router is deliberately on a pinned URL — a considered choice, and the
hand-edit per release is its price. Use rolling unless you specifically want to
freeze a device.
The rolling release used to go stale silently: publishing was an either/or, so tag runs wrote only
apk-vX.Y.Z-<arch>andapk-latest-<arch>was last refreshed on 2026-07-24 at0.2.0while v0.2.9/v0.2.10 shipped. A router on the rolling URL kept getting a successfulapk updatewith nothing new. Fixed 2026-07-25:release-apkwrites the rolling pointer on every run and then reads the release back over the Gitea API, asserting it holds our three tag-versioned packages at exactly the version just built and no leftover asset at another version (two versions of one package in one index would let apk choose instead of us).
5.2 One-time setup on the router
BananaWRT 25.12-mtk-vendor on the BPI-R3 mini, OpenWrt 25.12 on the BPI-R4, or
the testbed VM — /etc/apk/arch picks the right per-arch release automatically:
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
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"
# 2) add the repo — the line points at the packages.adb INDEX FILE itself.
# (rolling; for a pinned router put apk-vX.Y.Z-$(cat /etc/apk/arch) here — §5.1)
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) refresh + install (shaterd pulled in as a dependency).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # optional: ByeDPI desync egress
5.3 Updating
Never run a bare apk upgrade. With no arguments apk reconciles every
installed package against every configured repository at once; on a router
whose distfeeds point at a moving snapshot that can pull in — or roll back —
unrelated system packages. Always name ours:
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi
apk-tools 3 documents exactly this behaviour for apk upgrade: "When no
packages are specified, all packages are upgraded if possible. If list of
packages is provided, only those packages are upgraded along with needed
dependencies." The equivalent form, which additionally re-pins the packages in
world, is:
apk add -u shaterd shater-core luci-app-shater byedpi # -u = --upgrade
Drop byedpi from either list if you never installed it. Check what you are on
with apk list -I shaterd shater-core luci-app-shater byedpi — the version reads
0.2.7-r1 (§2.1: PKG_VERSION-rPKG_RELEASE, derived from the git tag by CI, so
every build really is a new version; before that fix v0.2.2…v0.2.6 all published
as 0.2.0-r3 and apk update offered nothing). Rolling vs pinned repo URL —
§5.1.
BananaWRT 25.12-mtk-vendor compatibility
The mtk-vendor channel (base: SuperKali/immortalwrt-mt798x-rebase, branch
25.12-linkup) builds target mediatek/filogic, pkg arch
aarch64_cortex-a53, and its images even point their distfeeds at vanilla
downloads.immortalwrt.org/releases/25.12-SNAPSHOT — so packages built with the
vanilla ImmortalWrt 25.12 filogic SDK install cleanly; no SuperKali-special SDK
is needed. We ship no kmods (shaterd is a static Go binary, byedpi plain C),
so the vendor 6.6 kernel is irrelevant to our packages; the kmod dependencies
of shater-core (kmod-nft-tproxy, kmod-nft-socket, plus ip-full) are
already baked into the BananaWRT mtk-vendor image (verified in its
config.buildinfo). On a self-built 25.12 image, make sure those kmods come
from the image's own kernel build.