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
26 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
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 seedConfirmTimeout, the shipped/etc/config/shatercarriesoption confirm_timeout '0', andapply.ArmRollbackreturns immediately on a non-positive timeout — so on a stock boxshaterd applyarms nothing and an apply that costs you SSH/LuCI access simply stays. The daemon says so rather than implying otherwise: thecommit-confirm-offoutcome ofshaterd applyprints "globals.confirm_timeout is 0, so commit-confirm is switched OFF: this apply armed NO automatic rollback", and the panel's Overview readsconfirm: 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 explicit0disappears from/etc/config/shateron the first write — absent and0mean 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:
.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.
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 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
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.