33 Commits
Author SHA1 Message Date
omarandClaude Opus 5 a8ef887c56 feat(routing)!: a rule's destination is a rule-set, and nothing else
`config rule` carried THREE ways to say where traffic is going: `dst_domain`
(an inline domain list), `dst_ip` (an inline CIDR list) and `dst_ruleset` (a
reference to a `config ruleset`). Three mechanisms meant three sets of
semantics to keep straight, and the inline pair was the worse half of the
trade: re-parsed per rule instead of compiled once into a .srs, unshareable
between rules, and — invisibly — already disagreeing with the rule-set
vocabulary about what a bare entry means.

`dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is the only
destination matcher. `Src` (the client side), `dst_port` and `proto` are
untouched: they are not lists of destinations and have no rule-set form.

THE BARE-ENTRY TRAP, and why the migration is not a copy

A bare `example.com` was an EXACT host in a routing rule (classified with
bareIsSuffix=false) and is the host AND its subdomains inside a rule-set
(bareIsSuffix=true). Copying entries across verbatim would silently widen
every such rule to every subdomain, so migrate1to2 rewrites a bare entry as
`full:example.com`. Everything else already means the same on both sides and
is copied byte-for-byte: `full:`, `suffix:`, `keyword:`, `regexp:` and a
leading dot (a synonym of `suffix:`).

`geosite:`/`geoip:` entries are copied UNCHANGED rather than promoted to a
`source=geosite` rule-set. They have been inert since the engine dropped the
route-rule geosite/geoip fields, and an unrecognised marker is equally inert
inside a rule-set — so their meaning is preserved exactly, and a dead matcher
does not start routing traffic because someone upgraded. The text is kept so
the operator can see it and convert it deliberately.

`regexp:` had no rule-set form at all, which would have made the move lossy,
so inline rule-sets learn it: peelDomainRegexes validates each pattern with
regexp.Compile before it reaches DomainRegex, because
route/rule.NewDomainRegexItem errors on an uncompilable one and that aborts
box.New for the whole config. A bare `regexp:` is dropped too — it compiles
fine and matches every host.

THE MIGRATION (schema v1 -> v2, run by `shaterd migrate` on service start and
at package install)

Per rule still carrying a legacy list: create an inline `config ruleset`
named `rule-<rule name>` (domains) and/or `rule-<rule name>-ip` (addresses),
move the entries across with the conversion above, append the new name to
`dst_ruleset`, delete the old option LAST. It is idempotent; it resumes an
interrupted run by reusing a rule-set the rule already references; and it
never overwrites a hand-written list that owns the generated name (it takes
`rule-<name>-2`). The uci sequence — `uci add` capturing the section id, then
set/add_list/delete — was verified against BananaWRT 25.12.1 in a throwaway
package.

Verified against the live router's config (4 rules, 26 entries, all
`suffix:`): every entry lands in its rule-set, every rule gains exactly one
reference, the `default` rule stays condition-less so B1's RuleReachability
still reads it as the catch-all.

ONE DELIBERATE SEMANTIC CHANGE, stated out loud: a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which
is almost never what "these sites and these networks" meant. The two
generated rule-sets are ORed, because `rule_set: [a, b]` matches when either
matches. Only configs that used both fields at once are affected.

Also fixed here, because schema v2 routes EVERY destination list through
inlineRulesetRule and the gap widens accordingly: a marker-only entry (".",
"full:", "keyword:") was dropped by the shared classifier SILENTLY on that
path, where the routing rule used to warn. An empty domain token aborts
box.New and an empty keyword is strings.Contains(host, "") — every host — so
the drop is right and the silence was not.

untunnelable stays honest: buildUntunnelablePlan already resolves `rule_set`
addresses through the running engine (inline sets are LocalRuleSets and
implement ExtractIPSet), and apply runs eng.Apply before building the plan.
A migrated `dst_ip` therefore resolves exactly as before; with the engine
down the walk truncates and denies, which is the conservative direction and
the state in which the netplane is fail-closed anyway.

Tests: migration coverage (real-router fixture, mixed prefixes, CIDRs,
idempotence, interrupted-run resume, name collision, geo markers stay inert,
absent config), and every matcher-classification test that used to live on
`dst_domain`/`dst_ip` moved to the inline rule-set rather than deleted —
including the new `regexp:` path and the inverted bare-entry convention. The
model tests grow a real in-memory uci emulator so a second migration run
actually sees its own writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
omarandClaude Opus 5 c562579ef3 docs(report): correct the B1 diagnosis — last catch-all wins, not the first
The report claimed the order=20 `default` shadowed the order=100 one and sent all
unspecific traffic past the proxy. That is wrong. generate/route.go:buildRoute
does not emit a condition-less rule as a match-all route rule: it sets
route.Final and continues, so the LAST condition-less rule by order wins, and it
can never shadow a rule that has conditions (those are emitted ahead of Final
regardless of order).

For the config on the router this inverts the conclusion: traffic IS going
through the proxy (order=100 -> group:auto is the live default) and the dead knob
is the order=20 `direct` one. Severity downgraded from high to medium
accordingly — a dead setting, not a leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:41:58 +03:00
omarandClaude Opus 5 a8f2b0f068 ci: derive package versions from the git tag (B4)
PKG_VERSION/PKG_RELEASE were hand-written literals nobody bumped, so
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). Both opkg
and apk offer 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 is now the single source of truth. It derives the version
from `git describe`:

    tag `vX.Y.Z`   -> PKG_VERSION=X.Y.Z  PKG_RELEASE=1
    off-tag build  -> nearest tag + PKG_RELEASE=<commits since it> + 1
    no tag/no git  -> 0.0.0-r1 (below everything ever published)

Ordering verified with the real tools, not from memory — apk-tools 3.0.3
(`apk version -t`) and opkg 38eccbb1 (`opkg compare-versions`) agree that
0.2.0-r3 < 0.2.6-r2 < 0.2.6-r10 < 0.2.6-r12 < 0.2.7-r1 < 0.3.0-r1, so a
release always outranks the rolling builds that preceded it and rolling
builds grow monotonically between releases.

The value travels as SHATER_PKG_VERSION/SHATER_PKG_RELEASE in the SDK
build environment of BOTH lanes; the Makefiles keep a literal fallback so
a manual/offline build still works with no CI and no git. Because the
hand-off crosses docker, `su` and make's env import, ci/sdk-build.sh and
ci/sdk-build-apk.sh now ASSERT that the produced .ipk/.apk really carries
that version — the B4 failure mode was a stale version shipping silently,
and that can no longer happen quietly.

The binary agrees with the package: scripts/build-shaterd.sh takes
constant.Version from the same ci/version.sh (vX.Y.Z-rR[-g<sha>]) instead
of its own `git describe`, and the workflow computes it once per job.
Both build jobs now check out with fetch-depth: 0 — `git describe` needs
tags and ancestry, which the default shallow checkout has neither of.

byedpi is deliberately left alone: PKG_VERSION:=0.17.3 is upstream
ByeDPI's own version, what PKG_HASH pins and what tells an operator 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), verified.

Docs: INSTALL.md gains §2.1 (the scheme + the ordering evidence), and the
update sections of §5/§6 now explicitly warn against a bare `opkg upgrade`
/ `apk upgrade` and give the targeted form instead, quoting apk-tools 3:
"If list of packages is provided, only those packages are upgraded along
with needed dependencies". README.md and the release bodies match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:32:38 +03:00
omarandClaude Opus 5 02c266188f docs: live test report for v0.2.6 on mini_router (79 checks, 5 findings)
Full cycle on real hardware (BPi-R3 Mini, ImmortalWrt 25.12-linkup): purge the
previous install, install from the signed apk feed, verify the default state,
restore a working config with 315 subscription nodes, then exercise the data
plane, panel API, config lifecycle, resilience and DNS.

74 PASS. Findings (detailed separately): two catch-all `default` rules where the
first sends all unspecific traffic direct and makes the second unreachable;
`shaterd nodes` is a stub returning [] while usage promises the node list; DNS to
the router LAN address dies after `service shater restart` (stop+pause+start is
fine); PKG_RELEASE unchanged since v0.2.1 so v0.2.2..v0.2.6 all ship as r3; ANSI
colour codes reach syslog.

Also records the four-iteration CI hunt that ended in the green apk lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:09:52 +03:00
omarandClaude Fable 5 c257d6c5cc docs(readme): rewrite root README as Russian shater product face
- README.md: new Russian product README (what/features/architecture
  mermaid/install both feeds/build/repo layout/CI/upstream/docs/license)
- README.en.md: concise English mirror (root readme was previously English)
- README.ru.md: demoted to a pointer stub (was the sing-box-lx fork readme,
  a competing Russian README) -> points to README.md + engine-fork docs
- docs-shater/README.md: folder index

Install commands copied verbatim from docs-shater/INSTALL.md; all links
verified against existing files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:31:06 +03:00
omar a57717dabb health plan S7: docs, contract comments, SPEC 019 update
release / aarch64_cortex-a53 (push) Successful in 3m43s
release / x86_64 (push) Successful in 3m30s
release / apk aarch64_cortex-a53 (push) Successful in 5m11s
release / apk x86_64 (push) Failing after 5m8s
release / release apk (push) Has been skipped
release / release (push) Successful in 12s
- lx-changelog: health board + observatory + global probe + sub cache entry
- DECISIONS.md: D18 board vs delete-and-overlay, D19 observatory vs sweep, D20 global probe settings
- contract comments: urltest.go CheckOutbounds (fast circuit) + observatory.go loop (background circuit + freshness gate) document the two-circuit split
- SPEC 019: dial-error section updated - slots still not moved, but board verdict demotes dead slot on next pick + retry (§5.B); sticky/replace-in-slot/never-shrink invariants preserved
2026-07-24 18:30:48 +03:00
omarandClaude Opus 4.8 cd598b0fe2 feat(ci): add apk (ImmortalWrt/BananaWRT 25.12) release lane
Additive next to the opkg/24.10 lane — nothing existing changed. The same 4
packages (shaterd, shater-core, luci-app-shater, byedpi) are built through the
official ImmortalWrt 25.12 apk-SDK and published as per-arch rolling releases
apk-latest-<arch> / apk-<tag>-<arch> (x86_64, aarch64_cortex-a53).

- ci/sdk-build-apk.sh: drives the 25.12 SDK inside debian:bookworm, compiles
  .apk, then `apk mkndx --root T --keys-dir T/keys --allow-untrusted
  --sign KEY --output packages.adb *.apk` — the exact form the OpenWrt 25.12
  buildsystem uses (unsigned members, signed index).
- ci/build-feed-apk.sh: per-arch runner entrypoint (same --volumes-from and
  artifact-order contract as ci/build-feed.sh).
- ci/gen-apk-key.sh: one-shot EC (prime256v1) keypair generator; private half
  -> Gitea secret KEY_APK, public dist/shater-apk.pem committed.
- release.yml: additive build-apk / release-apk jobs; `on:` triggers untouched
  (v* tags + workflow_dispatch); apk release tags deliberately non-`v*`.
- docs-shater/INSTALL.md section 6, .gitignore (out-apk/), dist/shater-apk.pem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:11:23 +03:00
omar 53cbdc75f1 build(shaterd): drop with_dhcp from the router tag set
shater resolver types are udp/tcp/doh/dot/local/fakeip; a dhcp:// DNS
transport is never generated, and the slim shater/registry never
registers the transport, so the tag gated nothing in this binary.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 10:36:59 +03:00
omar 0e5b1afb80 build(shaterd): drop with_clash_api from the router tag set
The admin panel is shater's own web server and generate never emits a
clash_api service (shater/engine/engine.go pre-registers its own
dnstrack.Manager precisely because no api/clash_api observer exists on
the router). With include.Context gone the Clash server was already out
of the link; dropping the tag records the decision. Desktop/CLI LX_TAGS
keeps with_clash_api for external dashboards.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 09:31:42 +03:00
omar d291c90cab build(shaterd): drop with_gvisor from the router tag set
The shater data plane is tproxy/redirect (netplane); generate never emits
a tun inbound, so the userspace gvisor netstack is unreachable code. With
the slim registry it was already dead-code eliminated by the linker —
dropping the tag makes the intent explicit and stops compiling ~3.6 MB of
gvisor sources into the build at all. A future tun inbound would fall
back to the system stack; re-add the tag if that ever lands.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 09:31:14 +03:00
omarandClaude Fable 5 693db39642 fix(schedule+profiles): evaluate windows at a captured UTC offset; stop claiming schedules are ignored
The schedule evaluator called time.LoadLocation, but the router binary
embeds no tzdata and OpenWrt ships none — so LoadLocation always failed
and windows silently ran in UTC while the panel promised local time.

- Windows now anchor to SchedUTCOffset (minutes east of UTC), which the
  panel captures from the editing browser on every schedule save; the
  daemon evaluates now.UTC()+offset with no location database. This
  sidesteps the weekly-recurring day-shift that a full local<->UTC
  conversion cannot express in one window. SchedTZ is deleted (documented
  in the removed-options list; old configs parse and drain it). DST is a
  stated limitation (followed on re-save). generate/schedule.go collapses
  from a second copy of the evaluator to a thin adapter over the model one.
- The iface-profile schedule was honored by the WAN watcher since
  08d5d6cc, but generate warned "the watcher does not look at the schedule"
  and the panel muted the editor with "the router ignores the schedule" —
  both false. Warning and lie removed; the editor is live and labelled
  "applies together with the uplink match".
- Stale fictions: FEATURES.md nftset/FakeIP-mode MVP line and the shipped
  conffile's dead `option dns_mode 'nftset'` corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:30:21 +03:00
omarandClaude Opus 4.8 ecb9d8e6ec docs(decisions): correct D17 — url blocklists were already fixed, not left open
I wrote the gap up as open while reviewing an agent report I had not yet seen;
the hosts/plain/AdBlock parse-and-compile path had in fact landed in the same
commit. Records the measurement that settles the disk question: StevenBlack's
2.4 MB of text compiles to 80873 domains in a 491 KB .srs, so it ships in the
production posture instead of being traded away for the 8 KB geosite list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 17:24:37 +03:00
omarandClaude Opus 4.8 1b4ed3e3da docs(decisions): D17 — audit outcomes that constrain future work
Records the positions the audit changed: fail-closed must cover "engine never
started" (holding plane), engine start must not depend on the network (remote
rule-set preflight, with the deferred cache-seed fix noted), BlockDoH needs no
route-plane upstream exclusion (engine dials bypass route rules), DNSMode is
unimplementable and its control was removed, TPROXY's inability to carry
ICMP/IGMP/ESP/GRE is now an explicit 3-way policy, and fail-open degradations
must surface in the panel rather than only in logread.

Also flags the contradiction left open: D15 promises seeding StevenBlack/OISD/
AdGuard while blocklist source=url accepts only compiled .srs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 16:59:49 +03:00
omarandClaude Opus 4.8 0e899a5170 audit(v0.2): full-stack hardening pass — release blockers, silent failures, dead knobs
A ground-up audit of the whole v0.2 stack by 8 parallel agents (DNS generate,
routing generate, model/parse/subscribe, netplane/apply/engine/alert, stats +
panel API, panel frontend, OpenWrt packaging), with every finding reproduced or
verified on the OpenWrt QEMU testbed. ~60 defects fixed, each with a regression
test that was checked to FAIL against the old behaviour.

RELEASE BLOCKERS
* Engine-start failure left the data plane ABSENT: with kill_switch=closed the
  router silently degraded to a plain OpenWrt box — no tunnel, no filtering, no
  kill-switch — while the panel looked healthy. Reproduced live. Now any
  engine-start failure installs a fail-closed holding plane (forward blocked,
  LAN-to-LAN and management preserved) and reports plane=hold/none.
* An unreachable remote rule-set aborted engine start entirely, so a router that
  booted before its ISP link came up ended with a dead LAN and no way to recover.
  Remote lists are now preflighted and skipped with a loud warning instead.
* `geosite:` in a routing rule hard-errored box.New — one legacy rule took the
  whole LAN down. Same class: unvalidated CIDR / port / regexp, and marker-only
  list entries ("." / "keyword:"). A lone `keyword:` also silently NXDOMAINed
  the entire internet.
* Fail-closed drop only covered tproxy inbounds, not interfaces diverted by rule
  sources — engine down leaked those networks to WAN in plaintext (4f618140 redux).
* UCI injection: a newline in a subscription-supplied node name broke out of the
  line-oriented config and wrote attacker-controlled sections.
* Bootstrap deadlock: the daemon refused to start while disabled, but the panel
  IS the daemon — a fresh install could never be configured from the UI.

SILENT FAILURES (the audit's main theme)
* per-device DNS block ignored the `suffix:` prefix — parental control that
  quietly didn't block. Unknown `word:` prefixes now warn instead of vanishing.
* sqlite reused `seq` after retention wiped rows, stalling the live log forever.
* `after=` cursor returned the NEWEST rows, permanently skipping bursts.
* Stats emitted null arrays on a freshly booted router, blanking Overview.
* Alerts fired twice per incident; new_device alerts swallowed all but the first
  device in a 60s window.
* Disabled subscription nodes were silently re-enabled on every refresh.
* Invalid Include/Exclude regexes failed OPEN, disabling the whole filter.

DEAD KNOBS — wired or honestly removed
  ru-bypass preset (emitted an unsupported geoip: matcher) -> real geoip rule-set
  Globals.ResolverFallback  -> implemented via evaluate + match_response chain
  Globals.DNSMode           -> unimplementable by design; control removed, fake-IP
                               documented via a type=fakeip resolver instead
  Rule.Kill                 -> implemented (default | closed | open)
  Rule.Egress               -> was read by nobody; multi-WAN binding silently no-op
  ExpireAlertDays + quota   -> subscription-userinfo parsed, persisted, alerted
  StatsBackend hot-switch   -> store is re-created on change
  Inbound.Sniff             -> documented as vestigial (sniffing is a route action)

NEW
* Globals.Untunnelable (block | icmp | direct): TPROXY can only carry TCP/UDP, so
  ICMP/IGMP/ESP/GRE were dropped with no explanation — ping simply didn't work.
  Now an explicit policy, defaulting to the previous behaviour, and explained in
  the UI by consequence rather than by protocol.
* apply now surfaces its warnings through /api/status (severity/section/name), so
  fail-open degradations are visible in the panel instead of only in logread.
* Panel: Networks page (which LAN networks are intercepted + inbound editor),
  DNS-rules editor, subscription quota/expiry, plane banner and findings list.
* Control-socket client got per-verb timeouts — a wedged daemon used to pile up
  one stuck `shaterd status` per minute until OOM.
* cache.db is now bounded (8 MiB, tmpfs fallback below 24 MiB free): on a 98 MB
  rootfs with ~33 MB free it could otherwise grow past what an upgrade needs.
* OpenWrt packaging: nftables-json + ca-bundle deps, postinst restart on binary
  upgrade, idempotent rt_tables seeding, cron gated correctly.

VERIFIED ON THE TESTBED
  fail-closed holds with the engine frozen; offline boot now starts the engine;
  RU destinations go direct while the rest goes through a node (per-connection
  proof); ads NXDOMAIN with allowlist override; DoH blocked while the configured
  upstream still resolves; sqlite history survives a daemon restart; a failed
  apply restores the previous config without dropping the engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 16:43:56 +03:00
omarandClaude Opus 4.8 7258922fa0 docs(roadmap): Phase 8 (ship) DONE — v0.2 feature-complete through the roadmap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:29:48 +03:00
omarandClaude Opus 4.8 be07e3ba57 ci(shater): Gitea feed build + usign-signed opkg release (Phase 8 ship — complete)
Ports the v0.1 Gitea release flow to the v0.2 single-binary + 4-package
layout, so a tag publishes a signed opkg feed the routers install from.

- .gitea/workflows/release.yml: on tag v* (+ dispatch), matrix over
  {x86_64, aarch64_cortex-a53}. Per arch: setup Go 1.24/Node 20/UPX ->
  scripts/build-shaterd.sh (SPA-embedded shaterd, stages the .upx) ->
  ci/build-feed.sh (OpenWrt SDK container builds all 4 packages ->
  usign-signed Packages index). A release job merges both arches into one
  signed feed + publishes the rolling 'latest'/tag release via the Gitea API.
- ci/sdk-build.sh: in-SDK build — add openwrt/ as the 'shater' feed, feeds
  update/install, make package/{shaterd,shater-core,byedpi,luci-app-shater}/
  compile (shaterd validates+installs the staged prebuilt; byedpi cross-
  compiles from source). ci/make-index.sh: opkg Packages(.gz) + usign sign
  with KEY_BUILD (keyfile umask 077, no secret hardcoded), verifiable by
  dist/shater-feed.pub. ci/install-usign.sh + ci/gitea-release.sh ported.
- INSTALL.md: add the signed feed src/gz line + import dist/shater-feed.pub
  to /etc/opkg/keys; apk (25.12) path noted.

Key kept: usign feed key 5ac4b177689cb8e0 (public dist/shater-feed.pub,
secret Gitea repo secret KEY_BUILD). Decision: opkg (24.10 uses opkg; apk
is 25.12) — matches the existing usign trust anchor.

Verified structurally (no live runner here): release.yml is valid YAML, all
ci/*.sh are bash -n clean, no hardcoded secrets, and every package name/
path/arch/artifact/secret reference cross-checks against openwrt/, scripts/
build-shaterd.sh, and dist/shater-feed.pub. Live-runner unknowns (full SDK
compile of the 4 packages, router-side signature verify) flagged in-agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:29:35 +03:00
omarandClaude Opus 4.8 a5c74209e4 feat(openwrt/shaterd): release build + prebuilt shaterd package (Phase 8 ship)
Makes the whole product installable — shater-core DEPENDS +shaterd, and
this is what resolves it.

- scripts/build-shaterd.sh: the release build. Builds the panel SPA
  (npm ci && npm run build), copies panel/dist -> shater/panel/webroot
  (the go:embed dir), cross-builds shaterd for amd64 + arm64 with the D9
  router tag set (CGO_ENABLED=0, -checklinkname=0 -s -w, static ET_EXEC no
  PT_INTERP), then UPX --lzma --best (D10) and stages the .upx into
  openwrt/shaterd/files. Version from arg/SHATER_VERSION/git-describe.
  Measured: amd64 40.3MB->10.4MB, arm64 37.6MB->8.5MB.
- openwrt/shaterd: prebuilt-binary package (npm+embed+UPX don't reproduce
  cleanly in the SDK, so CI stages the artifact). Maps OpenWrt ARCH
  (x86_64->amd64, aarch64->arm64 = both BPI routers) to files/shaterd-<a>.upx,
  installs /usr/bin/shaterd. RSTRIP/STRIP disabled (the SDK strip would
  corrupt the UPX binary); DEPENDS empty (static); errors clearly when no
  artifact is staged. GPL-3.0-or-later.
- docs-shater/INSTALL.md: build + install order (shaterd -> shater-core ->
  luci-app-shater, optional byedpi) + enable/apply.
- gitignore: dist/shaterd-*, openwrt/shaterd/files/*.upx, panel webroot.

Verified on the OpenWrt musl VM: dist/shaterd-amd64.upx (10.4MB) decompresses
into RAM + runs (shaterd status OK), serves the REAL embedded Faceplate SPA
at :8088 ('SPA embedded=true', real Vite index.html + assets — not the
placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:18:39 +03:00
omarandClaude Opus 4.8 c18298f74c docs(roadmap): Phases 6 (per-device) + 7 (schedules/alerts) DONE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:04:10 +03:00
omarandClaude Opus 4.8 5c931db538 docs(roadmap): Phase 4 DNS filter DONE (gate PASSED)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 19:33:02 +03:00
omarandClaude Opus 4.8 66f9225189 feat(shater): DNS-filter caching + blocklist update verb + close-first for cache_file (Phase 4 complete, D16)
Completes Phase 4 and passes its gate.

- generate: emit experimental.cache_file (enabled, /etc/shater/cache.db,
  /tmp fallback) so remote rule-sets persist + auto-update and megalists
  stay RAM-sane. Daemon + uci-defaults create /etc/shater.
- cmd/shaterd: real 'blocklist update' verb — SIGHUP-reconcile the running
  daemon so url/file rule-sets re-fetch (cache_file updates); no-op when
  down. shater-cron fires it on the blocklist interval.
- engine (D16): cache_file's bbolt EXCLUSIVE lock broke the apply-swap —
  the new box couldn't take the lock the old held, so every live reconcile
  stalled ~10s then failed 'cache-file timeout' (edits silently ignored).
  Apply now treats the cache-lock timeout as a swap conflict AND proactively
  goes close-old-then-start-new when the incoming config shares the running
  cache_file (sharesCacheFileLock), no stall. Regression test added.

Phase-4 gate PASSED on the OpenWrt VM (netns client, dns_filter on):
blocked-ad.example + doubleclick.net -> blocked (reject, no answer);
example.com -> resolves via the engine resolver; enabling an allowlist
entry + 'blocklist update' -> doubleclick.net resolves (allow overrides
block); a real geosite ads megalist (.srs) loaded and its domains blocked
with shaterd RSS ~39 MB (sane); 'blocklist update' reconciles cleanly.

Follow-ups (flagged, not blocking): sing-box reject returns REFUSED not
NXDOMAIN (comment says NXDOMAIN — a predefined-NXDOMAIN action would match
the usual ad-block convention); remote rule-set download_detour is
deprecated in sing-box 1.14 (works, rename before 1.16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 19:13:58 +03:00
omarandClaude Opus 4.8 b94305912a docs(roadmap): Phase 3 DONE; Phase 4 DNS-filter foundation + panel page landed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:46:55 +03:00
omarandClaude Opus 4.8 b4376fe8c9 docs(decisions): D15 — DNS filter via sing-box rule-sets + reject rules, not a custom matcher
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:24:45 +03:00
omarandClaude Opus 4.8 8298fe7d2a feat(openwrt/byedpi): ciadpi desync-proxy package for byedpi egresses (D13 Phase 2b-ii)
The process behind a shater egress of type='byedpi': an optional, separate
OpenWrt package that ships ByeDPI (ciadpi) + a procd supervisor. shaterd's
generate emits a SOCKS5 outbound egress-<name> -> 127.0.0.1:<port> (Phase
2b-i); a ciadpi instance from this package listens on that port, applies
TCP/TLS desync, and goes DIRECT (no tunnel).

- Makefile: package byedpi, pinned upstream v0.17.3 (real PKG_HASH), MIT,
  per-target (compiled C via SDK toolchain calling ciadpi's own make).
- init.d/byedpi: procd multi-instance (one ciadpi per enabled config
  instance, 127.0.0.1:<port> + desync args), inert by default, respawn,
  config-change reload, validation. sh -n clean.
- config/byedpi: default instance disabled, port 1080, a documented desync
  preset. uci-defaults/40_byedpi enables the init.
- Kept SEPARATE from shater-core (byedpi egress is opt-in).

Verified E2E on the OpenWrt VM: musl-static ciadpi (146 KB, no PT_INTERP,
Alpine-built) proxies + desyncs (log: DESYNC_DISORDER); a netns LAN client
routed through a type='byedpi' egress reaches the internet direct via
ciadpi; kill-switch stays honest (SIGKILL shaterd -> client blocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 16:28:21 +03:00
omarandClaude Opus 4.8 ab4e864864 feat(shater): native DPI-bypass presets on egress (D13 Tier 1)
An egress gains an optional 'dpi' preset that surfaces sing-box's already-
compiled route-action desync fields, so a ruleset can go DIRECT + desynced
with no tunnel and no extra binary (the DPI-blocked-but-not-IP-blocked case):
  fragment -> tls_fragment        (split the TLS ClientHello record)
  record   -> tls_record_fragment (alternative; mutually exclusive w/ fragment)
  spoof    -> tls_spoof           (decoy ClientHello; wrong-sequence default)

- model: Egress gains DPI string; uci.go parses option dpi.
- generate: a type 'direct' egress now emits a real 'egress-<name>' direct
  outbound (loop-guard mark) so it resolves as a rule target at all — before
  this a direct egress target referenced a non-existent outbound (latent bug).
  buildRoute's applyDPI stamps the matching route-action flag on every rule
  routed to a DPI egress; fragment<->record mutual exclusion enforced; a DPI
  preset on a default/catch-all egress warns (route Final carries no action).
  byedpi reserved for Phase-2b (external SOCKS egress, D13); unknown -> warn+off.
- openwrt example config + ROADMAP updated.

Verified: unit tests (fragment/record/spoof/none/unknown) + box.New validation
of fragment and spoof configs on the OpenWrt VM (router tag set) all PASS.
tls_spoof validates at box.New time on the linux/router build (raw sockets are
only touched at dial time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 15:42:53 +03:00
omarandClaude Opus 4.8 be15820266 docs(roadmap): Phase 2 control-plane port DONE — E2E gate PASSED on VM
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 01:00:57 +03:00
omarandClaude Opus 4.8 2550f745d3 docs(decisions): D14 — LAN DNS anti-leak via hijack-dns route action, not a :53 listener
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 00:33:17 +03:00
omarandClaude Opus 4.8 03706893ce docs(decisions): D13 — pick ByeDPI over zapret as the DPI-bypass egress
DPI-bypass stays a per-ruleset egress choice, never a global toggle.
Evaluated zapret (NFQUEUE packet plane) vs ByeDPI (local SOCKS desync
proxy); chose ByeDPI because it *is* an egress and composes with our
routing model with zero conflict against the verified inet-shater TPROXY
plane. zapret explicitly rejected. Native tls_fragment/spoof (already
compiled in) stay as free complementary egress presets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 21:47:20 +03:00
omar 95cac3ff41 docs(porting): add Wave 3 daemon contract (shaterd run + shater/apply + shater-core)
Defines the in-process daemon model: 'shaterd run' owns the box, CLI verbs signal
it (SIGHUP=reconcile, SIGTERM=teardown); shater/apply orchestrates generate+engine+
netplane under flock; openwrt/shater-core supervises shaterd. MVP scope for the gate.
2026-07-14 16:25:52 +03:00
omar 481eebc8de docs(porting): Phase 2 porting spec — v0.1 control-plane -> sing-box options
Full map of the v0.1 xrayctl/shater-core internals + the sing-box option surface,
what ports verbatim (nft/routing plane, UCI model, parsers, subsystems) vs. what is
rewritten (generator, DNS, box lifecycle, stats), the v0.2 package layout, and the
wave plan. Authoritative reference for all Phase 2 agents.
2026-07-14 15:57:25 +03:00
omar 676e87cbf4 docs(decisions): D11 (in-process box.New engine) + D12 (shaterd entrypoint)
Records the Phase 2 architecture: embed the engine in-process (apply = atomic
instance swap), rewrite only the generator (xray JSON -> sing-box options),
port parsers + nft/routing near-verbatim. Ship one binary shaterd.
2026-07-14 15:48:24 +03:00
omar 876fe392f9 docs(roadmap): Phase 1 gate PASSED — fork+embed+AWG2 E2E+size all green
AmneziaWG 2.0 proven E2E vs live Cloudflare WARP (warp=off->on through tunnel);
embedding via box.New verified on VM; router musl build ~9-11MB UPX. Next: Phase 2.
2026-07-14 15:46:06 +03:00
omar 0cbf8931a9 docs(decisions): add D9 (router musl-static tag set) + D10 (UPX ship)
Phase 1 VM findings: canonical LX_TAGS links glibc (naive/cronet/purego dlopen)
and won't run on musl OpenWrt; router build drops with_naive_outbound,with_purego
for a fully-static binary. Ship UPX-lzma (~9-11MB from ~40MB raw).
2026-07-14 14:38:47 +03:00
omar d41a685d9b chore: relocate shater meta-docs to docs-shater/ (avoid upstream docs/ collision)
Upstream sing-box-lx already ships a docs/ mkdocs site; keep our project docs
separate and unambiguous in docs-shater/ (parallels upstream's docs-lx/).
Updated all references in README.md, CLAUDE.md, CONTEXT.md, ARCHITECTURE.md.
2026-07-14 14:19:34 +03:00