Files
omarandClaude Opus 5 4869d62e02
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m39s
release / apk aarch64_cortex-a53 (push) Failing after 2m54s
release / apk x86_64 (push) Failing after 2m54s
release / release apk (push) Failing after 1m35s
feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness
endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it
is REVERSED there, with the reason, because the reason is the whole point.

D13 adopted an external desync process on an observation: the engine's own
`tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not
get through, so the method was judged too weak for anything past "just fragment
the ClientHello". The method was never tried. `common/tlsfragment` dropped a
number of labels equal to the number of DOTS in the name, and a name always has
one more label than it has dots — so the cut always landed inside the FIRST
label. `www.youtube.com` was split inside `www` and `youtube` went to the wire
in one piece, which is the word the DPI matches on. Of six blocked names exactly
one got through: `youtube.com`, the one whose first label IS the blocked word.
That defect is fixed (815011dfb, efb2177f4). With it fixed the built-in presets
do the job the external process was brought in to do, and the process is 100 KB
of binary, a second procd service, a second UCI file, a port that agreed with
our egress by hand-written comment only, a readiness prober, a five-state
service model and a panel plate — all to work around fifteen lines of ours.

So this is not "ByeDPI turned out to be bad". It is a good tool that turned out
not to be needed, and the reason we thought it was needed was ours.

A CONFIG THAT STILL SAYS `type 'byedpi'` IS THE PART THAT NEEDED WORK. Nothing
is migrated and nothing is rewritten: the kind stays unbuildable, therefore
fail-closed — no outbound, no mark, no `ip rule`, no routing table, so every
node, group and rule bound to it is blocked rather than released onto the plain
WAN. A migration to `direct` was considered and rejected: it is the only rewrite
that leaves the egress routing at all, and it would silently turn a blocked
egress into a live plain-WAN path with the router's real address — by an
upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore not
bumped either: no stored field changes meaning, and a bump would only make this
build's configs unreadable to an older daemon for no gain.

What changes is what the operator is TOLD. `model.RetiredEgressTypes` is a
closed, positive table read by BOTH `ValidateEgresses` and the generator (one
copy of the sentence, because two copies drift). It names the removal, denies
that it is a typo, says nothing is built and that the traffic is blocked rather
than leaked, names the replacement (`direct`/`interface` with `dpi 'record'`),
refuses to promise which preset defeats a given ISP, and says `apk del byedpi`.
The generic "unknown type" is still there and still says something different, on
purpose: "we took this kind away" and "you mistyped something" send an operator
to different places, and a value that was correct on the day it was written must
not be reported as a spelling mistake. The type list stays closed and positive —
`interface`, `direct`, the alias `tunnel` — and `EgressTypeKnown` does NOT admit
the retired kind: being told it was removed and having it work anyway is worse
than either alone.

`Egress.Port` goes with the kind: no surviving egress dials anything, so the
option is no longer parsed and drains out of /etc/config/shater on the next
render, the same way the deleted per-group probe_url/probe_interval did.

Tests, verified by mutation, each failing by name:
  - drop the retired branch in `ValidateEgresses` -> the retired kind is
    reported as "is not one of interface/direct" and
    TestRetiredEgressTypeIsReportedByTheValidator fails on both spellings;
  - drop it in the generator -> "unknown type \"byedpi\"" and
    TestRetiredEgressTypeIsReportedByTheGenerator fails;
  - the FAIL-OPEN mutation, which is the one that matters: let `byedpi` fall
    into the `direct` arm and be a known type -> four tests fail, including the
    two that check no outbound is emitted. A removal that quietly starts routing
    the traffic it used to block, under a reassuring message, is the failure with
    the worst consequence;
  - the panel half: empty RETIRED_EGRESS_TYPES -> two egressEdit tests fail.
Controls beside the claims: `interface`, `direct`, the `tunnel` alias and the
empty synonym must still resolve, warn about nothing and emit an outbound
(TestSupportedEgressTypesAreUntouched), and never-supported values — `proxy`,
`block`, `wireguard`, `byedpi2`, `bye dpi`, `sorcery` — must NOT draw the
removal sentence, which names a replacement for something that never existed.

CI and docs: the feed loses its fourth package everywhere the four were named —
`apk upgrade shaterd shater-core luci-app-shater`, in CLAUDE.md, both READMEs,
INSTALL.md, the release body and `shaterd`'s own diag bundle. The version
exception (byedpi carried upstream's version, ours come from the git tag) is
gone with it, so ci/version.sh and ci/sdk-build-apk.sh no longer have an
exception to remember and the "expected >=4 of OUR .apk" collect check is now 3.
INSTALL.md §5.3 gains the half a feed cannot do: dropping the package from the
feed does not take it off a router it is already on, so `apk del byedpi` is
written down, with what it removes and why it is safe.

Panel: 368 tests -> 339. Deleted with the mechanism they covered:
byedpiReady.test.ts, byedpiAge.test.ts, byedpiRefusal.test.ts (34 tests);
egressEdit.test.ts gains 5 for the retired-type sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:13:50 +03:00

26 KiB
Raw Permalink Blame History

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:

  1. Builds the admin SPA — cd panel && npm ci && npm run build (Vite → panel/dist).
  2. Copies panel/dist/* into shater/panel/webroot/, so //go:embed all:webroot bakes the real SPA into the binary (not the "SPA not embedded" placeholder).
  3. Cross-builds, for each of {amd64, arm64}, with the D9 musl-static router tag set (CGO_ENABLED=0 GOOS=linux), stripped + trimmed, into dist/shaterd-<arch> (uncompressed, kept for debugging).
  4. UPX --lzma --best (D10) → dist/shaterd-<arch>.upx (~42 MB → ~8–11 MB).
  5. Stages dist/shaterd-<arch>.upx into openwrt/shaterd/files/ for the package.
  6. Prints a size table + a per-arch static check (must be ET_EXEC, no PT_INTERP).

Arg / env:

  • VERSION — stamped into constant.Version. Resolution: positional arg → $SHATER_VERSION → ci/version.sh --binary → v0.2.0-dev. ci/version.sh is the same computation the package version comes from (§2.1), so the string the panel shows always matches what apk list -I shaterd reports.
  • --fast — skip npm ci when panel/node_modules already exists.
  • UPX=/path/to/upx — override the UPX binary (default upx on PATH). UPX is cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also reads $UPX as its own options — the script unsets 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

Three 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), the boot armor (§4), cron, hotplug, sysctl, inert default UCI. DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle.
luci-app-shater all Thin LuCI launcher: mini dashboard + token-handoff "Open panel" button. DEPENDS:=+shater-core +rpcd.

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).

All three are versioned this way. There used to be a fourth package carrying its upstream's own version and therefore exempt from the assertion above; it is gone (D29), and with it the exception nobody could be expected to remember.

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

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
# The safety net is NOT on by default — see below. 120 s is a window wide enough
# to re-open SSH/LuCI and decide whether the new config is any good.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply        # apply + arm the auto-rollback for 120 s
shaterd confirm      # confirm inside that window (cancels the auto-rollback)

Commit-confirm ships OFF. model.DefaultGlobals() does not seed ConfirmTimeout, the shipped /etc/config/shater carries option confirm_timeout '0', and apply.ArmRollback returns immediately on a non-positive timeout — so on a stock box shaterd apply arms nothing and an apply that costs you SSH/LuCI access simply stays. The daemon says so rather than implying otherwise: the commit-confirm-off outcome of shaterd apply prints "globals.confirm_timeout is 0, so commit-confirm is switched OFF: this apply armed NO automatic rollback", and the panel's Overview reads confirm: no auto-rollback. Set a window (UCI as above, or Settings in the panel) if you want the net. Non-obvious detail: the option is written back only when non-zero, so an explicit 0 disappears from /etc/config/shater on the first write — absent and 0 mean the same thing.

/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:

  • .lan and private reverse (PTR) lookups still go to dnsmasq — the engine gets a rule for those suffixes. If you renamed dnsmasq's domain away from lan, add a config dns_rule for 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 forward only, 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.

The boot-time fail-closed armor

shater-core installs a third init script, /etc/init.d/shater-armor, and 30_shater-core enables it at install time. It exists because /etc/init.d/shater is START=99: by then fw4 (19) has loaded lan -> wan ACCEPT and netifd (20) has brought the LAN bridge up, so between link-up and the daemon's first apply the router forwards LAN traffic to the WAN in the clear — on router hardware with a UPX-packed binary that is the seconds in which Wi-Fi associates and every client reconnects. kill_switch=closed covered none of it, because the protection lived inside a process that had not started.

How it works. On every apply the daemon persists a copy of its fail-closed holding plane — the same ruleset it installs when the engine is down — to /etc/shater/boot.nft. shater-armor runs at START=21 (after fw4 and netifd), validates that file with nft -c and loads it. When the daemon comes up it replaces the table atomically, so there is never a moment with no table. Its stop() is deliberately a no-op.

LAN forwarding is blocked until the daemon applies — management access is not. The chain hooks forward only, so SSH, LuCI and the admin panel (all input hook, to the router's own addresses) stay reachable on purpose: a kill switch you cannot switch off is a brick. If you see the syslog line

fail-closed plane armed from /etc/shater/boot.nft: LAN->WAN forwarding is BLOCKED
until shaterd applies. SSH, LuCI and the admin panel stay reachable.

that is the mechanism working, not a fault.

When it refuses to arm — each is a state check made at boot, never a record of something that happened on the way down:

Condition Behaviour
/etc/shater/boot.nft absent Nothing to do, silent. The file exists only while the last applied config was both enabled=1 and kill_switch=closed; either being off removes it at the next apply, and an operator-typed /etc/init.d/shater stop removes it there and then. Powering off does not — and neither does the stop a package upgrade issues while the service stays enabled, so being replaced cannot leave the next boot unprotected.
the file is empty, or fails nft -c Refuses, logs an error — the LAN is unprotected until shaterd starts.
/usr/bin/shaterd missing, or no S??shater symlink in /etc/rc.d Refuses: nothing would ever come along to replace the block with a working data plane. This is what makes an uninstalled or disabled product safe regardless of what the file says.
UCI is readable and says globals.enabled is not 1 Removes boot.nft and does not arm. An unreadable UCI is not a refusal — that case is exactly why the armor is a file rather than a query.
nft not installed Refuses, logs an error.

Turning it off. The durable off-states are the two the script itself asks about — uci set shater.globals.enabled=0 && uci commit shater && shaterd apply (the next apply removes boot.nft), or /etc/init.d/shater disable. A bare /etc/init.d/shater stop typed at the shell also removes the file, but it is not durable: S99shater is still linked, so procd starts the daemon again on the next boot. To remove just the armor and keep the stack: /etc/init.d/shater-armor disable.

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 by ci/gen-apk-key.sh; the private half lives ONLY in the Gitea secret KEY_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> and apk-latest-<arch> was last refreshed on 2026-07-24 at 0.2.0 while v0.2.9/v0.2.10 shipped. A router on the rolling URL kept getting a successful apk update with nothing new. Fixed 2026-07-25: release-apk writes 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

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

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     # -u = --upgrade

Check what you are on with apk list -I shaterd shater-core luci-app-shater — 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.

If this router still has byedpi installed

Older releases shipped a fourth, optional package — byedpi (the ciadpi local desync proxy) — behind an egress of type='byedpi'. Both are removed from the product (D29): the desync it provided is done by the engine's own dpi presets, which were failing for a defect of ours that is fixed.

Dropping it from the feed does not take it off a router it is already on — nothing here uninstalls anything. Remove it by hand:

apk del byedpi

That is safe. No shater package depends on it, nothing in shaterd looks for the ciadpi binary any more, and it takes /etc/init.d/byedpi, /usr/bin/ciadpi and (unless you edited it) /etc/config/byedpi with it. Leaving it installed is also harmless — it is then simply a service nothing routes to.

If an egress in /etc/config/shater still says option type 'byedpi', it is blocked, not leaking: the daemon builds no outbound for the kind, so every node, group and rule bound to that egress stops rather than going out over the plain WAN. The panel and the apply warnings name it and say what to change it to — direct (or interface) with dpi 'record'. Nothing is migrated for you on purpose: the only automatic rewrite that would keep the egress routing is the one that would silently put that traffic on the plain WAN.

5.4 Downgrading — going back to an older build

Per-version releases live forever (apk-vX.Y.Z-<arch>, §5.1), so the way back is always open. It is a different command from updating, because apk upgrade never downgrades — that is not a policy of ours, it is what the solver does.

Read this first: an older build refuses to write a newer config. The UCI schema version is stored in globals.schema_version, and a build that finds a schema NEWER than it understands refuses every config write — the panel, the 6-hourly subscription refresh and the profile watcher all stop persisting, with a message naming both versions. That refusal is the recoverable outcome: your /etc/config/shater is untouched, and installing the newer package again brings everything back. The alternative would have been an older build quietly rewriting the file in its own, poorer form. The file as it stood before the first change of any build is also kept at /etc/shater/config.pre-v<schema>.bak.

So: downgrade across a schema bump only as a temporary measure, and expect the box to hold the config it has rather than accept edits.

# 0) note where you are, and keep it.
apk list -I shaterd shater-core luci-app-shater

# 1) repoint the repo file at the PINNED per-version release you want.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-v0.2.9-$(cat /etc/apk/arch)/packages.adb" \
  > /etc/apk/repositories.d/shater.list

# 2) refresh, and read the exact version strings that feed offers.
apk update
apk list shaterd shater-core luci-app-shater

# 3) install them BY NAME with an explicit version. `=` is what downgrades.
apk add shaterd=0.2.9-r1 shater-core=0.2.9-r1 luci-app-shater=0.2.9-r1

Going back up afterwards is two commands, not one — the = form leaves a pinned constraint in /etc/apk/world (shaterd=0.2.9-r1), and a pin outranks an upgrade:

# repoint /etc/apk/repositories.d/shater.list back (rolling, or the newer tag)
apk update
apk add     shaterd shater-core luci-app-shater   # drops the =version pin
apk upgrade shaterd shater-core luci-app-shater   # moves the packages

Measured, not inferred — on the testbed VM (ImmortalWrt 25.12.1 r37978-cd0a06bfd3fd, apk-tools 3.0.5, x86_64), against the real apk-v0.2.9-x86_64 and apk-v0.2.10-x86_64 feeds, in an isolated --root sandbox so nothing on the box moved:

Command, with only v0.2.9 in the shater feed What apk actually did
apk upgrade shaterd nothing — stayed on 0.2.10-r1
apk add shaterd=0.2.9-r1 Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1), and world became shaterd=0.2.9-r1
apk add shaterd (after the pin) pin cleared; the installed version did not move
apk upgrade shaterd (pin cleared, feed back at v0.2.10) Upgrading shaterd (0.2.9-r1 -> 0.2.10-r1)
apk add shaterd=0.2.9-r1 while the feed carries only 0.2.10 ERROR: unable to select packages: shaterd-0.2.10-r1: breaks: world[shaterd=0.2.9-r1] — nothing installed. Repoint the repo FIRST.

apk upgrade -a is not the way to do this, even though it does downgrade. --available reconciles against the repositories rather than against the installed versions, and naming our packages does not keep it to them: the same run on the testbed reported

(22/27) Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1)
(23/27) Downgrading shater-core (0.2.10-r1 -> 0.2.9-r1)
(24/27) Downgrading luci-app-shater (0.2.10-r1 -> 0.2.9-r1)

together with luci-app-attendedsysupgrade, luci-i18n-firewall-zh-cn and two more unrelated packages rolled back to whatever the distfeed snapshot holds. Use the =version form, which touched exactly the three packages named.

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), so the vendor 6.6 kernel is irrelevant to our packages.

What was actually checked in the BananaWRT mtk-vendor config.buildinfo is kmod-nft-tproxy, kmod-nft-socket and ip-full — those three are baked into the image. shater-core also depends on kmod-tun, nftables-json and ca-bundle (added later; see the annotated DEPENDS in openwrt/shater-core/Makefile), and those were not part of that check. They are ordinarily present on a stock image — apk will pull whatever is missing from the distfeeds — but if you install offline or from a slimmed image, verify them yourself. On a self-built 25.12 image, make sure the kmods come from the image's own kernel build.