Compare commits

...
155 Commits
Author SHA1 Message Date
omarandClaude Opus 5 8c0ea55054 feat(dns,fetch): resolvers and list fetches follow the uplink; the node cache survives the reboot it exists for
test / go + panel tests (push) Successful in 1m42s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 2m55s
release / apk x86_64 (push) Successful in 2m56s
release / release apk (push) Successful in 9s
The carrier behind this router's SIM refuses TCP/443 to 9.9.9.9 and 1.1.1.1 while
carrying everything else — measured with a positive control (ya.ru:443 and
77.88.8.8:53 connect, every sim-bypass node connects, those two are refused). The
configured resolvers go out DIRECT, not through the tunnel, so on that uplink DNS
resolved nothing: the vless server names did not resolve, the hop in front of
awgout never came up, and the whole chain died with it. One pair of global scalars
cannot be right for two uplinks; the object that knows which uplink is live is the
profile.

  * config profile gains resolver_default, resolver_fallback and fetch_detour
    beside endpoint_resolver. Empty = inherit, PER FIELD.
  * globals.fetch_detour replaces `const filterFetchDetour = tagDirect`. Behind a
    carrier whitelist `direct` is not the safe path, it is the path where the
    source is refused forever and the list never loads.
  * A subscription's fetch_via becomes an OVERRIDE, which gives it a third state.
    ReadUCI used to parse an absent option as the literal "direct", so "chose
    clear-text" and "never touched this row" were the same value. migrate2to3
    performs the reinterpretation ONCE, in the open. Schema 2 -> 3.
  * An unusable override falls back (resolvers to globals, fetch_detour to direct)
    and says so at critical, naming profile, field, value and what is in force.

The panel was displaying globals while the engine used the profile's value; the
owner caught it. The field now keeps the STORED value with a separate line naming
what is in force, and the rule that answers "what is in force" moved to the daemon
(GET /api/config/effective) so it stops existing in two languages.

Cold start, by owner's requirement: rule-sets are read from the cache when the
source is unreachable instead of being dropped, and the subscription cache reader
is fixed. Its first fix was wrong and only Linux said so — mtime ties to the digit
because the kernel caches the stamp per tick, and this board has no RTC, so the
ordering can invert across a reboot. Replaced by a generation counter in the file.

Woke and closed a LAN-dark defect: wgdedup read only the deprecated, always-empty
DownloadDetour, never HTTPClient.Detour, so fetch_detour=node:<awg> made a node
used, the dedup pass did not know, merged it away, and left the rule-set pointing
at a tag box.Start could not resolve. Reproduced through a real box.New.

Also: ValidateProfiles had no caller; "applied from the cache" graded critical
though the list is in force; the auth matrix never walked /api/log or
/api/rules/reachability; the CLI and daemon disagreed about where a subscription
is fetched.

NOT fixed, stated rather than implied: the R5 preflight still probes direct, so a
list never yet fetched cannot bootstrap over the detour alone; the router's own
DNS on the SIM stays dead (dnscrypt-proxy bootstraps via blocked addresses).

Gate: bash scripts/run-tests.sh green, 7/7, privileged tests really ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:40:58 +03:00
omarandClaude Opus 5 625942834b fix(gate): [5/7] hid the runner's exit code exactly when it explained everything
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 5m25s
release / apk x86_64 (push) Successful in 3m16s
release / release apk (push) Successful in 8s
The line naming a nonzero `go test` status was printed only when every
privileged test had produced a verdict — on the reasoning that a named FAILED
already explains the status. The case that actually happens is the opposite
one: the run dies at package level, so it names no test, so the loop above
prints MISSING for all of them, and the one line pointing at the real cause was
the one suppressed. A reader then goes hunting for three vanished tests instead
of at the build error above.

To be exact about what was and was not broken, because the framing matters: the
exit status was never SWALLOWED. priv_bad is set by the MISSING branch, so
FAILED is set and the gate fails either way — this was a diagnosis bug, not a
correctness one. What changes is whether the log says why.

Verified on the branch a green run never reaches, by driving the edited block
with all four (priv_rc, priv_bad) combinations: the new message appears only for
(1,1), the old one only for (1,0), and priv_bad/FAILED come out 1 in both. The
full gate is green with the change in, which covers the (0,0) path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-28 08:43:08 +03:00
omarandClaude Opus 5 4bf4ad8aa2 test(engine): the observatory tests were racing their own engine's loop
CI's `[4/7] go test -race -shuffle=on` failed the v0.2.23 gate on
TestRefreshObservatoryForcesOnePass ("force flag survived the forced pass").
The product is NOT at fault, and this was established rather than assumed.

WHAT ACTUALLY BROKE. ConfigureObservatory starts the ticker goroutine and its
first tick fires immediately — by contract, so an applied config gets its first
verdicts in seconds — and a plan change additionally nudges the loop into a
pass on purpose. That tick advances the cursor and consumes the force flag.
Two tests then read exactly those fields straight after a Configure, i.e. read
values another goroutine is entitled to rewrite in the same instant. Four
assertions, all racy:

  observatory_test.go:78   identical-plan reconfigure reset the cursor to 2
  observatory_test.go:86   changed-plan reconfigure kept the cursor at 2
  observatory_test.go:176  after refresh: cursor=2 force=false
  observatory_test.go:186  force flag survived the forced pass

The last one is the busy guard: with the loop's first tick still in flight the
test's hand-driven observatoryTickOnce is a silent no-op, so nothing clears the
flag it just raised.

NOT a cross-test dependency, and not a leaked goroutine — the direction was
measured, not guessed. Each test reproduces ALONE in the CI container at
`-count=3000`: 16/3000 and 7/3000, with all four messages. The earlier
`-count=80` in isolation was simply too few iterations; a loaded `-shuffle=on`
package run widens the window, which is why CI saw it and a laptop did not.

THE FIX is isolation, not a weakened assertion. detachObservatoryLoop stops the
goroutine and leaves a PLACEHOLDER stop channel behind, so the reconfigures
these tests make still run the whole state machine — plan rebuild, cursor
policy, nudge — with no second writer (ConfigureObservatory starts a loop only
when e.obs.stop is nil; e.obs.nudge is left nil and every send to it has a
default). quiesceObservatoryLoop, which four chain tests already used for the
same reason, is now that plus a cursor rewind.

Mutation-checked: with detachObservatoryLoop neutered the flake returns at
18/3000 and 6/3000 with the same four messages; restored, 20 consecutive
`-race -count=1 -shuffle=on` runs of the package are clean, as is the full
`scripts/run-tests.sh`.

TWO TESTS GAINED THE ABILITY TO FAIL. TestObservatoryTickStoppedEngine and
TestObservatoryTicksDuringManualRun assert `cursor != 0` after a hand-driven
tick — which the loop's own first pass had already satisfied for them, so they
held whether or not the tick under test did anything. The second one is the
worse case: it exists to forbid the tick deferring to a manual run, and the
busy guard could make the tick do nothing while its assertion still passed.
Both now quiesce first.

TWO NEW TESTS, for the contract the flake kept stumbling into without ever
asserting it — a refresh raised while a tick is in flight:

  - TestRefreshDuringInFlightTickRunsAFullForcedPass parks the loop's first
    pass inside a stub probe, so "in flight" is a fact rather than a hope,
    raises force there, and requires a second full pass over jobs the polite
    freshness gate would skip. TWO independent wakeups carry the request across
    — the buffered nudge and the tick's deferred re-nudge — and that is
    measured: disabling EITHER leaves the test green, disabling BOTH makes it
    fail with "force is still raised" and 2 attempts instead of 4. So it
    asserts the observable contract, not a mechanism, and says so.
  - TestForcedPassChainsItsBatchesWithoutWaitingForTheTick pins what
    observatoryTickOnce's defer claims and nothing held: a forced pass chains
    its batches instead of spending a 10s tick each. THREE batches, because two
    prove nothing — the loop's unconditional first tick pays for one and the
    refresh's still-unconsumed nudge pays for the second, so a two-batch plan
    finishes even with the chaining removed. Measured that way round first;
    at three, removing the defer leaves 48 of 54 targets undialled.

obsSelectorFixture/obsWideSelectorFixture exist because obsFixture's urltest
members are SelfChecked and the observatory does not dial them at all — a stub
waiting on that plan would hang, not fail.

No product file is touched: shater/engine/observatory.go is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-28 08:42:50 +03:00
omarandClaude Opus 5 be1cdbfc63 feat(netplane): an explicitly named private subnet is routed, not silently swallowed
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Failing after 1m38s
release / apk aarch64_cortex-a53 (push) Has been skipped
release / apk x86_64 (push) Has been skipped
release / release apk (push) Has been skipped
Measured on the production router: a `config ruleset` of type=ipcidr holding
10.10.10.0/24, a rule pointing it at node:awghome, config_applied=true,
tunnel_rules=1, engine_running=true, ZERO warnings — and from a LAN client,
100% packet loss and no TCP. The rule was accepted, applied, reported healthy,
and could not fire.

The cause is one line of ordering. `ip daddr { 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ... } accept` sits ABOVE every
divert line in the prerouting chain, so the packet is accepted and handed to
plain routing before the engine — which holds the rule — ever sees it. That
default is right and stays: LAN-to-LAN, the router's own services and every
local plane must not be dragged through a tunnel, and a catch-all rule must
never quietly acquire them. What was wrong is that naming a subnet OUTRIGHT
could not override it, and that nothing said so.

So the divert for NAMED private destinations is emitted one line higher, and
"named" is deliberately narrow (netplane/coverage.go, privateRoutedPlan):

  - the CIDR must be an ENTRY of an INLINE type=ipcidr rule-set — the only
    destination list this stage can read;
  - it must be CONTAINED in 10/8, 172.16/12 or 192.168/16. A prefix that merely
    overlaps one (0.0.0.0/0, 10.0.0.0/7) is a catch-all that happens to include
    private space, and does not acquire it;
  - the referencing rule must be enabled and target node:/group:/chain:/egress:
    or block. `direct` is not an override: it asks for what the bypass already
    does, and diverting into the engine to reach the same verdict would be
    strictly worse, because the engine's direct outbound follows the DEFAULT
    route and LAN-to-LAN could be pushed out the WAN;
  - it must not overlap a network this router itself carries;
  - 127/8, 169.254/16, 224/4 and 255.255.255.255 are never taken.

THE SELF-AMPUTATION GUARD DISTINGUISHES A LAN FROM AN UPLINK, and that
distinction is the difference between a safety device and an obstacle. A
collision with one of our OWN networks (any zone that is not a WAN zone, plus
any interface whose zone is unknown) is refused by name — diverting it takes
the LAN away from the LAN and the operator finds out over the console. A
collision with an UPLINK subnet routes and discloses: ISPs hand out RFC1918
WANs routinely — this router's own gateway is 10.0.0.1 — and on a /8 uplink
every private subnet on earth "collides", so refusing there would disable the
feature on precisely the routers that want it, for a reason that would read as
a bug. Nothing of ours lives on the uplink subnet: `fib daddr type local`
already accepts the router's own addresses above these lines, and every divert
line is scoped to LAN ingress, so router-originated traffic never meets them.

PING IS HOW ANYONE CHECKS A ROUTE, and a TPROXY divert carries TCP and UDP
only — the kernel needs a socket and ICMP has not got one. Stopping there would
rebuild this same defect one protocol down: TCP succeeds, ping reports 100%
loss, and the operator concludes the route is broken. So with l3_tunnel on, the
L3 mark is stamped on ICMP bound for these destinations (again above the
bypass, which is the only reason it was not already happening) and the existing
`ip rule` delivers it into the engine's TUN, where the SAME route rules pick
the outbound and a WireGuard/AmneziaWG one carries it. The forward chain's
fail-closed drop excludes that mark, because unlike the tproxy legs the LAN-to-TUN
leg really does traverse forward and the `oifname "shater-l3*"` accept that
would rescue it sits four steps lower. With l3_tunnel OFF nothing is emitted,
nothing is claimed, and the rule is told so by name.

THE DOUBT ALWAYS FALLS BACK TO THE BYPASS. Failing to route a named subnet
costs a feature and shows up the moment it is tested; routing one we should not
have touched can take the router's own management network into a tunnel that
may not even be up. So an unreadable list, an inventory we could not enumerate,
and an address family we cannot check the router's own addresses in (IPv6 —
`ubus call network.interface dump` reports IPv4 only) all resolve to "leave it
on the bypass", and every one of them says so. Seven distinct sentences now
exist where there was silence: refused-for-our-own-network, refused-for-no-
inventory, reserved space, catch-all-does-not-acquire, IPv6-not-checkable,
uplink-overlap-disclosed, and ping-does-not-reach-with-l3_tunnel-off. The
eighth is the blind spot itself: an address list this plan never reads
(url/file type=ipcidr, or geoip whose category is not an ISO country code)
might contain private destinations, and that is disclosed unconditionally —
"warn on suspicion" is not available, because suspicion would mean reading the
list. It is graded `warning` rather than critical through a named marker in
apply/warnings.go: it describes a maybe, and a red that means "probably fine"
is how the next red stops being read.

generate.ruleSetTypeIsIPCIDR now delegates to netplane.IsIPCIDRRulesetType.
Two packages asking the same question of the same field must not each carry
their own list of spellings.

VERIFIED
  - `bash scripts/run-tests.sh` green in full ("OK: the shipped tag set, on
    linux, passes every test we own", exit 0), with the three privileged
    ^TestIntegration tests RAN by name.
  - Every new test mutation-checked: 17 reverts, each failing the test that
    covers it, by name.
  - BOTH CONTROLS. Without an explicit naming, private space is still bypassed
    (TestPrivateDestinationBypassIsStillTheDefault) and a catch-all still does
    not take it; with it, the divert appears above the bypass. A test green in
    both states would prove nothing.
  - BYTE-FOR-BYTE. Two goldens, plain and L3, captured from a git worktree at
    the PARENT commit — not from this code, which would only prove
    self-consistency. A config that names no private subnet renders the
    identical text, so the applier's idempotence check still sees no work.
  - REAL NFTABLES. The rendered plane (both the tproxy and the ICMP/L3 shapes)
    loads with `nft -f` on nftables 1.0.9 and the kernel holds the lines as
    written; the instrument was shown able to REJECT a deliberately broken copy
    of the same file.

NOT VERIFIED
  - Nothing here has been run on the testbed or the router. Whether the packet
    that now reaches the engine actually comes out of awghome is the owner's
    acceptance test, not this commit's claim.
  - Whether a named IPv6 ULA could be handled safely was not investigated
    beyond establishing that the inventory cannot check it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 22:44:43 +03:00
omarandClaude Opus 5 bbb493ea91 fix(ci): the package-count assertion lives in two scripts and only one was updated
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 8m55s
release / apk x86_64 (push) Successful in 2m51s
release / release apk (push) Successful in 8s
D29 removed byedpi, so the feed carries three packages. sdk-build-apk.sh was
changed to >=3; build-feed-apk.sh still demanded >=4 and killed both arch lanes
of v0.2.22 with `expected >=4 .apk … found 3`. Nothing was published from that
run. The comment now says the count is duplicated, because reading one script
was what made this look done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:31:35 +03:00
omarandClaude Opus 5 4869d62e02 feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
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
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
omarandClaude Opus 5 efb2177f43 fix(tlsfragment): one cut, in the label a blocklist keys on — and a budget for it
Follow-up to 815011dfb, which fixed WHICH label is cut but left "a cut in every
candidate label" as an unconditional rule. Measured on this tree, loopback peer,
product default fallbackDelay, one ClientHello per row:

    cuts   tls_fragment (*net.TCPConn)   tls_fragment (proxy conn)   tls_record_fragment
       1                        502 ms                      500 ms                 <1 ms
       2                       1.004 s                     1.001 s                 <1 ms
       4                       2.008 s                     2.002 s                 <1 ms
       8                       4.015 s                     4.003 s                  539 us
      21                      10.540 s                    10.509 s                  525 us

So a cut in the PACKET modes costs half a second of connection setup, and it
costs that on BOTH branches — not only on the sleep path. writeAndWaitAck sleeps
the whole fallbackDelay whenever the ACK returns inside 20 ms (its "under
transparent proxy" case), and N.UnwrapReader reaches the *net.TCPConn only when
nothing in the chain transforms the stream, which a proxy protocol conn always
does. A proxied egress — every subscription node — therefore takes the flat
500 ms branch regardless of RTT. The number of labels is chosen by whoever picked
the hostname, and a 253-byte SNI is 85 of them: ~42 s of one connection's setup,
bought from the LAN.

In tls_record_fragment nothing waits: the ClientHello leaves in ONE write, split
into more records. 21 cuts cost 525 us and 105 bytes of record headers, and
1.1.1.1 completed the handshake with the ClientHello in 22 records in the same
77 ms it took with 2. That is the mode the field measurement was taken in, and
the mode where cutting every label was always affordable.

Hence two budgets rather than one rule: 1 cut for the packet modes, 4 for
record-only — the latter not a cost limit but a shape limit, since real names
carry one to three labels outside the public suffix and a hostile one must not
turn a ClientHello into 85 records no ordinary client emits.

One cut is enough because of WHERE it goes. Candidates are now ordered, most
worth cutting first, and first is the REGISTRABLE label — the one immediately
left of the public suffix. That is what a name-based blocklist keys on
("youtube" of youtube.com, www.youtube.com and studio.youtube.com alike,
"ytimg" of i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also
breaks any match on the whole FQDN, so one cut covers both matchers. It is
chosen by STRUCTURE, from the public suffix list — not by length, which is the
same trap from the other side: in cdn-static-assets.youtube.com the longest
label is not the blocked one. The rest follow longest-first, on the argument
that among labels with no structural ranking a long one is likelier to be a
distinctive token than "www", "m" or "tv"; they are reached only when the budget
allows more, or when the registrable label is too short to cut.

The offset now comes from the label's MIDDLE THIRD. Every interior offset severs
the label, but one byte in leaves "outube" of "youtube" and a matcher keyed on a
substring still reads it. The draw stays random inside that third: a fixed point
would be a constant a middlebox vendor can special-case in one line, and this
whole family of tricks lives on making reassembly the only counter.

Also in this commit, and the reason it is not merely a tuning change: the panic
that shipped in v0.2.21 now has an instrument of its own.
TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN drives real ClientHellos
carrying ".youtube.com", "youtube.com." (a legitimate FQDN with the root dot,
which curl and every browser will send), "..", an IP literal and non-ASCII bytes
through all three modes, and FuzzCutOffsets does the open half — 25.7 million
executions found nothing, and the fuzzer is shown able to find a planted defect
its seed corpus cannot reach, in one second. A hand-built ClientHello reaches
the shapes crypto/tls refuses to emit: a zero-length name, a 253-byte name, and
a server_name_list with a SECOND entry, which is why planning runs on
MyServerName.Length rather than on everything left in the extension.

Nine mutations, each failing by name with the numbers: the old dot arithmetic,
the old rand.Intn offset, the exact original expression (panic: invalid argument
to Intn, conn.go:208 <- Write conn.go:67), the empty-plan guard, the budget, the
priority order, the sort back into wire order, the first-entry truncation, the
middle third, and a one-byte corruption of a segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:02:06 +03:00
omarandClaude Opus 5 815011dfb0 fix(tlsfragment): the SNI was cut in exactly one label — always the first one
`splits[:len(splits)-strings.Count(serverName.ServerName, ".")]` is identically
`splits[:1]`: labels are always one more than dots, so the subtraction cancels
for EVERY name in existence. One label was ever cut, and it was the leftmost
one. On the provider measured from this router — which blocks by the name in
the handshake, proved by the same address answering for SNI www.google.com and
going silent for www.youtube.com — that is the whole observed table:

    youtube.com     cut inside "youtube"  -> 301
    m.youtube.com   cut inside "m"        -> blocked
    tv.youtube.com  cut inside "tv"       -> blocked
    www.youtube.com cut inside "www"      -> blocked
    music/studio.*  cut inside the label in front -> blocked

The one name that worked is the one whose first label IS the blocked word. The
count subtracted must be the labels of the PUBLIC SUFFIX, not the dots of the
whole name: "com" is one, "co.uk" and "com.br" and "pp.ru" are two.

Second half of the same defect, and the reason the table above shows a cut
"inside m" at all: the offset was `rand.Intn(len(label))`, whose 0 is the
label's own boundary — the label goes out whole in the next segment, which is
not a cut, it is a segment boundary that happens to touch a label. For a
one-byte label 0 is the ONLY value it can take. Offsets are now drawn from
[1, len-1], so a cut always leaves a non-empty piece of the label on both
sides, and a label too short to have an interior offset carries no cut instead
of a fake one. That also closes the 1-in-7 hole in the case that WAS working:
youtube.com drew offset 0 once every seven connections and handed the name over
intact.

Two panics went with it, both reachable from the LAN, because route/conn.go
wraps the outbound with this and the ClientHello it fragments is the client's:
an empty label (SNI ".youtube.com" or the perfectly ordinary FQDN
"youtube.com.", where the suffix list declines to answer and the trailing empty
label survives) reached rand.Intn(0) — "panic: invalid argument to Intn", the
daemon and with it the router's proxying. And a plan with no cuts at all would
have indexed b[:splitIndexes[0]] on an empty slice; Write now writes the
ClientHello unchanged in that case, which is the only honest thing to do for a
name of one byte.

The classification is closed and errs toward MORE cutting: narrowing the label
set needs proof (a public suffix that really is a tail of the name), widening
needs none, so a trailing dot, an unmanaged TLD, a name that IS a public suffix
("com", "co.uk", "localhost") and an IP literal all keep every label rather
than fall silently into "cut nothing". When no label is long enough to cut, the
name itself is cut once — a matcher looking for the whole FQDN still fails
across that split.

Dropped with it: `splits[0] == "..."`, unreachable since strings.Split on "."
cannot produce a token containing a dot. And the plan now runs over the FIRST
entry of the server_name_list (MyServerName.Length) instead of everything left
in the extension, so a second entry cannot be fed to the public suffix list as
if it were part of the name.

Tests (cutplan_test.go, package-internal so the plan itself is visible) are
verified by mutation five ways: the old dot arithmetic, the old rand.Intn
offset, the removed empty-label guard, the removed empty-plan guard, and a
one-byte corruption of a segment. Each fails by name and with the numbers. The
controls: youtube.com — the case that already worked — must still be severed;
the reassembled segments must be byte-identical to the ClientHello in all three
modes (tls_fragment, tls_record_fragment, both), with the record framing
re-parsed rather than assumed; and Write must report len(b).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 16:33:04 +03:00
omarandClaude Opus 5 0a34e64c2c fix(panel): byedpi off the status poll, and disabled stops speaking for two situations
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m36s
release / apk aarch64_cortex-a53 (push) Successful in 6m0s
release / apk x86_64 (push) Successful in 2m52s
release / release apk (push) Successful in 8s
GET /api/status no longer carries the readiness report — the daemon dropped it
with the cache behind it, after one probe was measured at 6.4 s on 16 enabled
instances behind a black hole while the panel polled that endpoint every 5 s
from every open tab and read the field NOWHERE. The Status type, the mock
fixture and every comment describing a cache, a background refresh or a 20 s
staleness rule now say what the daemon does: one endpoint, and it connects when
a human asks.

`disabled` covers two situations with opposite next actions: no instance is
enabled — how the package ships — and an instance that IS written and looks
enabled while /etc/init.d/byedpi refuses it (`port 'auto'`, `port '99999'`,
`enabled ' 1'`, `enabled 'TRUE'` — all four measured on the 25.12.1 testbed
against validate_data). The editor's fixed sentence said "that is how the
package ships" about a section the operator had typed themselves. The daemon
keeps its `problems` list off the wire, so `detail` is the ONLY carrier: the
refusal now shows that sentence verbatim plus a tail that says only what is
true of both — the consequence, never the fix.

byedpiRefusal moves to byedpiReady.ts beside the gate it explains, and its
table now EXCLUDES `disabled` from the type, so re-adding a fixed sentence for
it does not compile. `?mock&byedpi=rejected` reaches the second case in a
browser; `?mock&byedpi=noanswer` reaches "nothing has been measured", which is
now only a failed fetch — the fabricated cold-cache body is gone.

Also: two comments about `config_applied` that the daemon's pointer+omitempty
change made false — the removed "positively phrased so a naive client falls the
alarming way" rationale, and "absent means a daemon too old", which now also
means the offline `shaterd status` stub.

Tests (byedpiRefusal.test.ts, +10) verified by mutation both ways: a fixed
"that is how it ships" and a fixed "your typo" each fail, and the control
asserts the factory state still reads as the factory state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:54:21 +03:00
omarandClaude Opus 5 1708159ecf fix(apply,shaterd): the offline stub alarmed about an apply nobody attempted
`config_applied: false` means "/etc/config/shater was read and REFUSED — what is
running is the PREVIOUS configuration, your edit is not in effect", and the panel
draws a critical band saying exactly that. The field was a plain bool, so that
alarm was the ZERO VALUE OF THE TYPE — and `shaterd status`'s offline stub, built
by a process that never applied anything, over a data plane that may have been
installed and enforcing for weeks, published it by simply never mentioning the
field. It is the config_readable defect returning in a new field, with the one
difference that decides the fix: config_readable can be MEASURED by the stub and
now is, while this one cannot be measured at all without a daemon.

So the field says nothing when nobody measured it. ConfigApplied becomes a *bool
with omitempty; the live Applier.Status() assigns a verdict on BOTH arms, so an
absent key can only come from something that is not a live status. That is the
same closed-set-plus-unknown shape `plane`, `traffic` and `daemon_answered`
already have, and the one panel/src/appliedConfig.ts already implements
(=== true / === false / else unknown). The Go doc claiming absence should read as
false is gone: it contradicted the only consumer, and the consumer was right.

The four fields around it (apply_error, apply_error_stage, apply_attempts,
apply_failed_since_unix) stay plain: they are qualified by config_applied the way
enabled/kill_switch/panel_port are qualified by config_readable, and their zero
values point at "nothing was refused" — the quiet side, not the alarm.

Also: the stub shipped `warnings: null` on its happy path while apply.Status
documents Warnings as always non-nil so a consumer can map over it
unconditionally.

Three states, distinguishable ON THE WIRE through one `shaterd status`, with the
control that would catch the opposite break (a build that omitted the key for a
real refusal, deleting the alarm from the product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:43:49 +03:00
omarandClaude Opus 5 9d7f0dc92f fix(panel): the byedpi report had a five-second timer and no reader, and its parser could forge "listening"
Two defects found by looking at both sides of the byedpi readiness check at once.

1. GET /api/status carried the whole readiness report from a cache that a poll
   refreshed in the background once the copy passed byedpiRefreshAfter = 3 s.
   The panel shell polls that endpoint every 5 s, so EVERY poll started a
   refresh: a PATH lookup, a read of /etc/config/byedpi, and one connect per
   enabled instance, forever, per open tab, hidden ones included. The design
   note rejected a background ticker because "a closed panel costs nothing" —
   true, and silent about the open one it had become.

   Measured, one enabled instance, twelve polls five seconds apart:
     before  12 connects, 13 ciadpi PATH lookups per minute per tab
     after    0 connects, 12 PATH lookups (one per poll, for byedpi_installed)

   And nothing read it: `grep -rn '\.byedpi\b' panel/src` finds no consumer —
   the readiness plate, the per-egress cross-check and the egress-type gate all
   come from GET /api/byedpi. So the field is gone from the status response, and
   with its only cached reader gone the cache went too, together with the
   background goroutine, the staleness rules, the negative-age contract and
   Server.Close's duty to wait for a probe. GET /api/byedpi still connects, on
   the goroutine of the request that asked.

2. readByeDPIInstances claimed to mirror /etc/init.d/byedpi "exactly" and did
   not. The init script validates each section with
   'enabled:bool:0' 'port:port:1080' and refuses to start one whose validation
   failed. Go read the port with strconv.Atoi and, on failure, KEPT the 1080
   default — so `option port 'auto'` on an enabled instance became "an enabled
   instance on 1080", and anything else accepting there produced state
   "listening": the one state that unlocks the byedpi egress type, handed out
   for a proxy that does not exist. `port '99999'` produced the second half:
   "unknown" with a sentence asserting a connection attempt that never happened.

   The same shape lived in `enabled`: strings.ToLower+TrimSpace read ' 1' and
   'TRUE' as on, while the router starts neither (measured — the first is
   refused by validation, the second normalises to an empty value so
   `[ "$enabled" -eq 1 ]` never fires).

   The parse is now a closed positive list, and its expectations were MEASURED
   on the 25.12.1 testbed against /sbin/validate_data with the init script's own
   spec rather than inferred from libvalidate's source:

     enabled: absent/"" -> off; exactly 1|on|true|yes|enabled -> starts;
              exactly 0|off|false|no|disabled -> off; anything else -> does not
              start, and is REPORTED by section, option and value.
     port:    absent/"" -> 1080; plain decimal digits 1..65535 -> that port;
              anything else -> NO port is assumed, the section is not counted as
              a listener and nothing is dialled for it.

   Deliberately narrower than libvalidate's `port` (which also takes a sign,
   leading whitespace and, through an overflow, twenty digits): narrow declines
   to call a working instance a listener and prints why, wide hands out a green
   apply onto a port nothing is on.

Two further sentences that asserted actions that never happened, found while
fixing the above and not reported by the review: instances past
byedpiMaxInstances were never dialled yet fell into the "the connection attempt
neither succeeded nor was refused" clause, and that clause listed their ports
alongside genuinely inconclusive ones. "Not dialled" is now its own tally with
its own sentence, and each sentence names only the ports its own claim covers.

Every test here was checked by mutation, and each carries its control:
byedpi_initparity_test.go proves the instrument BOTH accepts a valid section
(state listening, against a real socket, in a world where every connect is
accepted) AND refuses every value the init script would not start, dialling
nothing for them; byedpi_pollcost_test.go measures the poll cost with a meter
shown counting a real probe in the same test, and keeps the probe-cost control
(16 black-holed ports = 6.4 s) that explains why it is off the poll path.

Gate: bash scripts/run-tests.sh green, including -race; ok shater/panel by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:32:26 +03:00
omarandClaude Opus 5 c407771cf2 fix(panel): four cards, one log row and a status the daemon knew and nobody saw
The board is not one row per name. carryForward supersedes by (name, KIND) and
appends carried rows LAST, so a chain `x` and a node `x` both live on it — and
`new Map(results.map(r => [r.group, r]))` kept the last. The chain card showed
the node's milliseconds, exit address and verdict as its own end-to-end
measurement, unmarked. Attribution is now by kind (targetResult.ts), with
kind:'' and a missing kind as ordered last resorts.

A connection routed to the engine's `block` outbound was drawn as plain mono
text, indistinguishable from `nl-reality-1` — on the page where a DNS row about
the same host gets a crit rail and a BLOCK mark. It is the kill-switch's own
Final and a legitimate rule target, so the connection log now carries the same
outcome axis the DNS log has: killed / carried / no exit recorded, a crit rail
and a mark that survives the width where the exit column is dropped.

Insights.tsx held a raw NUL at byte 36359 — a template separator written as the
byte instead of the escape. `file` called the source binary and ripgrep, git grep
and every tree-wide search skipped it in silence. It is the escape now, and the
whole of panel/src is free of control bytes.

Three contract texts had drifted from the daemon: the searched-field list did not
mention `error` (fixed on the Go side, and there were two copies), the connection
hint named neither `proto` nor the chain hops, and rowMatches folded case with
toLowerCase() — Unicode-aware, where the daemon folds ASCII only, so a needle
could find rows in the panel that the router would never return.

And the four status fields the daemon started publishing: config_applied,
apply_error, apply_error_stage, apply_attempts, apply_failed_since_unix. A
refused configuration retried on a widening interval while `engine_running` was
true, the hash was the OLD config's and every warning described the OLD config.
engine_running is TRUE there and is not contradicted — the band says WHICH
configuration is running, and the hash row, the traffic default and the findings
list each say they are about that older one. Absent is not false: a daemon
without the field is `unknown` and raises nothing, because there is no evidence
its hash is stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:28:40 +03:00
omarandClaude Opus 5 26e1d38924 test(bridge): make the fragment sweep test assert the property it names
TestBridgeFragmentSweepIsPerCall claimed its probe used "an EXISTING key, not a
new one: the sweep must still run". It did not: the stale datagram carried IPv4
id 61 and the probe id 62, and fragKey includes the identification, so the probe
opened a NEW key — the one arrangement in which the sweep runs even when it runs
only on new keys. Moving r.sweep(now) inside the `entry == nil` branch left the
test green.

The probe is now the SECOND fragment of a datagram whose first fragment is
already cached, with the two entries opened half a fragTimeout apart so the
stale one is past its deadline and the live one is not (deadlines are set at
creation and never refreshed). Two assertions before the probe pin the setup:
the stale entry must still be there, and the live key must already exist — if a
later edit breaks either, the test says so instead of quietly proving nothing.
The released bytes are checked too, which is the half of the timeout this test
is about (the correctness half is already caught by TestBridgeFragmentTimeout).

Same sweep of TestBridgeFragmentMalformed, which had the same shape of hole: a
FIRST fragment carries MF=1 and can never complete a datagram, so `got != nil`
is unreachable whether the packet was refused or accepted, and "truncated
header" asserted only that. Every subtest now asserts on the cache, and a case
for the classic overread — a header claiming TotalLength 276 in a 28-byte
buffer — is added; its control is the aligned subtest already at the bottom.

Mutations (linux, -race): sweep moved into the new-key branch fails
SweepIsPerCall by name; clamping TotalLength to the buffer instead of refusing
fails the new malformed subtest — and, as predicted, leaves its `got != nil`
assertion silent. Control: moving the sweep after the entry lookup while keeping
it unconditional keeps every test green, so the test discriminates "per call",
not "the line moved". No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:25:35 +03:00
omarandClaude Opus 5 42d84ac74c fix(model): a failed backup may stop a config write only when the filesystem is the reason
backupBeforeChange was added with "any failure aborts the write", justified by
"the uci commit that follows writes the same filesystem, so whatever stops one
stops the other". That holds for a full or read-only /overlay and for nothing
else — and the existence probe is a stat, which also returns ENOTDIR (something
dropped a file where /etc/shater should be), EACCES, ELOOP. In that state
PUT /api/config answered 500, `sub update` exited non-zero and the profile
watcher stopped saving, PERMANENTLY: none of those causes clears itself. A
convenience added this wave must not be able to take the product away.

Two changes, both about not inferring what can be measured:

- The probe is not evidence. stat(dest) answers "is this transition already
  captured?"; when it cannot answer, the copy is now ATTEMPTED and the attempt
  is the measurement. Only "the filesystem will not take bytes" short-circuits
  it.

- The failure is classified. filesystemRefusesWrites is a positive, CLOSED list
  — ENOSPC, EROFS, EDQUOT, EIO — each a condition under which the uci commit
  would fail too, so aborting only changes which error the operator reads and
  ours names the cause. Everything else is about the backup's PATH and falls to
  the recoverable side: the config is saved, and the missing undo is NAMED
  through reportBackupProblem (same shape as subCacheLogf; model cannot import
  logsink, which imports model) rather than skipped in silence.

TestWriteAbortsWhenTheBackupCannotBeWritten used a FILE where the backup
directory should be — that is ENOTDIR, the exact case that must no longer veto —
so it now injects ENOSPC at the copy, and the ENOTDIR case moved to
TestBackupPathFailureDoesNotVetoTheWrite. statBackup/writeBackupFile are seams
because the two deciding failures are the two a temp directory cannot produce.

Mutation-checked (linux, -race), each with the other half green: restoring "any
failure aborts" fails only the two carry-on tests; "nothing aborts" fails only
the two abort tests; restoring the old stat handling fails only the test that
pins "attempt the copy"; dropping ENOSPC from the list or adding ENOTDIR to it
fails the classifier test and the end-to-end tests that depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:25:20 +03:00
omarandClaude Opus 5 a05ad21b39 fix(apply,shaterd): four states the daemon was in and could not say
1. A SWITCHED-OFF SUBSCRIPTION CAN STILL GO OUT ON THE PLAIN WAN (blocker).
   Three places had to agree about `enabled=0` and did not: UpdateSubscription
   resolves by name and never reads it; cmdSubUpdate reads it only when no name
   was given; warnings.go skipped disabled subscriptions entirely on the stated
   premise that one "is never fetched". The premise was the false one, and the
   per-row Fetch-now button added this wave posts exactly the named request.

   Kept the behaviour, dropped the premise. Enabled means "include in the
   automatic refresh" everywhere else in the system — MergeSubCaches loads a
   disabled subscription's cached nodes unconditionally and they route traffic —
   and a refusal here is worked around by enable/fetch/disable, which enrols the
   sub in the 6-hourly sweep and is strictly worse. The automatic paths still
   honour it (the nameless sweep, and shater-cron's own `en = 1` check). The
   named path now says so on stderr and in the daemon log, and the leak finding
   fires for disabled subscriptions with the WHEN clause corrected — "every
   scheduled refresh" is false of a subscription no schedule touches.

2. refreshBootArmor DISARMED THE NEXT BOOT FROM A CONFIG THE DAEMON REFUSES.
   Every call site is gated on readErr == nil and nothing else; ParseUCIExport
   drops unknown options silently, so a config written by a newer build reads
   clean, and with the divert set emptied by the parse RenderHoldNft returns ""
   and the armor was REMOVED — with no log line at all, unlike the disarm one
   branch above it. Measured: with the new gate removed, the armor really is
   deleted. Now gated on the schema, and both removal paths are announced.

3. THE FIRST-BOOT DEADLOCK IS NAMED. Every subscription pulled through the
   tunnel, the tunnel built from nodes only a fetch supplies, the caches gone:
   the fetch waits for the tunnel and the tunnel waits for the fetch, forever,
   with the LAN dark. The CLI refusal goes to /dev/null (shater-cron) and the
   daemon line to a syslog `log_syslog='0'` switches off. It is now a critical
   finding in /api/status, which survives both, with the state named and two
   escapes — the free one first, the costly one priced.

4. A REJECTED CONFIGURATION WAS INVISIBLE, AND THE ENGINE CHURNED. Measured on
   the stand: with a config the engine cannot accept on disk, cron retries every
   60s and every attempt is a full engine swap, while status showed
   engine_running=true, the OLD hash, the OLD warnings, and `grep -ci` for the
   broken element returned 0. Invisible by construction: everything published
   about a config is published by a SUCCESSFUL apply, and engineDownCause is
   gated on the engine being down — here it is up.

   Status gains config_applied / apply_error / apply_error_stage /
   apply_attempts / apply_failed_since_unix, and a critical finding that says
   the running configuration is a DIFFERENT one and names the reason. Reconcile
   paces an identical retry (three free attempts, then doubling to a 15m cap);
   any change to the configuration cancels the wait, and POST /api/apply is
   deliberately not paced. The post-swap abort is deliberately NOT recorded —
   it is already loud and its retry costs no swap.

Also, from review-by-seams:

 - The netplane channel was graded critical wholesale over three distinguishable
   states. `udp '0'` + closed is the kill switch doing what it was told and may
   be exactly what was asked for; the leak and the total cut-off are not. The
   first is now `warning` (not `info`: attentionFindings drops info, and the
   blast radius is wider than the switch's name). Default stays critical, the
   exception is a closed list, and netplaneprotoseverity_test.go pins it against
   the REAL renderer so a rewording fails by name instead of drifting.

 - devicefilter_severity_test.go carried a FOURTH unlinked copy of
   DEVICE-FILTER-NOT-APPLIED and compared it with itself — the same shape as the
   noGatewayFinding fixture this wave removed. apply's copies are one constant
   now, and the real coupling is a test that runs generate and grades what comes
   back. Mutation: renaming the tag in generate fails it by name; the two old
   fixture tests survive that untouched, which is the whole point.

 - The history-write failure was logged ABOVE the deduplication gate its own
   call site documents eight lines below. At one cron reconcile a minute a
   standing cause (full /overlay, an entry over the 128 KiB ceiling) wrote 1440
   identical lines a day, and under log_persist=1 that many appends to flash —
   the exact wear the history ring's own dedup exists to prevent. Now gated on
   the message changing, cleared by a success. The Warning is still returned
   every time; only the log had a repetition problem.

Every fix mutation-checked with the failure text recorded, and every one has a
control showing the instrument can still give the opposite answer: an enabled
subscription still fetches and keeps the scheduled wording; a legitimate disarm
still happens and is still logged; a healthy box raises no rejected state; an
ordinary netplane finding is still critical; a DIFFERENT history failure still
prints. One mutation (the history-dedup latch) SURVIVED its first test — the
counter matched the success path's Info line too — and the test was fixed.

shater/apply is green. shater/cmd/shaterd was green when run 20 minutes ago and
now fails to BUILD on shater/panel/byedpi.go, a neighbour's in-flight refactor;
the full gate run for the same reason cannot be completed on this tree right now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:08:57 +03:00
omarandClaude Opus 5 43bb8913ea fix(diag): the bundle printed a DNS account id, and scrubbed the wrong file
Two holes, both in the direction the verb cannot afford: `shaterd diag` produces
the one text block a person SENDS somewhere.

1. resolver.address was on the allow-list, printed verbatim. For a DoH resolver
   that field is a URL, and generate/dns.go's parseDoHAddress keeps and USES
   u.Path — which is exactly where NextDNS, AdGuard and Control D carry the
   account identifier. Whoever holds it reads and rewrites this household's DNS,
   so it is a credential. The panel had always read it that way (DNS.tsx's
   resolverAddr shows u.host and flags the rest); the disagreement was resolved
   in favour of the side whose output goes to a stranger. Now: scheme and host
   survive, userinfo/path/query/fragment do not, and a BARE address
   ("1.1.1.1", "dns.adguard.com:853") is still printed in full because it is
   host and port and it is what the fault is read from.

   The fix could not be "delete the key from the list": TestDiagMasking-
   IsClosedOverTheWholeModel asserted the allow-listed fields come out
   UNMASKED, so it actively pinned the leak. The transform lives in a second
   closed table (diagMaskedForm), and the sweep now compares the masked render
   against the raw one line by line, expecting either the plain mask or exactly
   what that table declares.

2. The second layer collected its literals from `uci export shater` alone, and
   that file does not hold this router's credentials. model/render.go never
   writes a FromSub node; the several hundred subscription nodes live in
   /etc/shater/subs/*.json, which keep.d/shater-core describes in its own words
   as carrying "every node's credentials". The reachable path is not
   hypothetical: parse/sharelink.go quotes a rejected node's USERINFO into its
   error, generate/outbound.go warns it, apply/warnings.go logs it, and the last
   32 KiB of that log is section six of the bundle — with LogToFile on by
   default. diagSubCacheSecrets now reads those files by the same closed
   positive-list rule (unknown JSON key => collected, so a field added to
   model.Node tomorrow is covered), and a file it cannot read is NAMED in the
   bundle instead of silently reducing the scrub.

   Fixing the first half exposed the second: the log carried the userinfo, not
   the whole URI, so a literal scrub of the URI walked past it. diagSecretParts
   expands every refused value into its userinfo, username, password, query
   values (encoded and decoded) and path. Not the fragment — in a share link
   that is the node's display name, which is on the printable side.

The banner no longer says secrets are masked "throughout". It says what is
masked, and then names what is still in there: values under 8 characters (masked
in the config, not scrubbed elsewhere), list/ruleset URLs, and the limits of a
literal scrub.

Mutation-checked, each with the control that the instrument SEES the planted
secret in the unfixed output:
  resolver.address back on the allow-list      -> resolver test fails on the id
  diagMaskAddress made the identity function   -> transform test names the field
  sub-cache literals withheld from the scrub   -> log-scrub test fails
  diagSecretParts reduced to the whole value   -> log-scrub test fails
  sub-cache safe list turned into a blocklist  -> closure test fails
  unreadable cache file swallowed              -> honesty test fails
  nil collector seam read as "nothing to do"   -> honesty test fails
  transform applied per section, not per key   -> new-field test fails
  one section given an open default            -> sweep fails, by name
  an allow-listed value over-masked            -> sweep fails, by name
  masked lines dropped entirely                -> sweep's vacuity guard fires

scripts/run-tests.sh green (all 7 steps, -race included).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:49:08 +03:00
omarandClaude Opus 5 89571bcdb1 fix(netplane,generate): a network with option udp '0' had its UDP dropped in silence
Two defects of the same family: a state the plane produces and nobody names.

1. The divert is written per PROTOCOL, the fail-closed drop per INTERFACE.
   A tproxy inbound with `option udp '0'` puts its device in the drop scope
   (nftDivertRefs does not look at the flags, and must not: the drop is the
   backstop for ESP/GRE/SCTP too) while emitting no UDP TPROXY line for it.
   With kill_switch=closed every outbound UDP packet from that network is
   dropped; with kill_switch=open the same packets leave the WAN in the clear.
   TCP works, DNS works (dnsmasq answers it past the fib-local bypass), so it
   presents as "some sites do not load", not as a firewall. Verified by
   rendering: no second LAN is required, the shipped one-inbound shape does it.

   The drop is NOT narrowed to match the divert. Doing so would turn
   `option udp '0'` — which is how you kill QUIC so the engine can route by SNI
   — into "UDP now bypasses the proxy", i.e. it would convert a QUIC-blocking
   config into a QUIC-leaking one, and it would open a per-protocol hole in the
   kill switch through a knob whose name says nothing about leaking. The plane
   already takes the other decision one field over: with ipv6 off no v6 divert
   is emitted and closed mode drops v6 anyway, deliberately and in writing.
   So the state stays and is named instead, in three shapes (protocol dropped /
   protocol leaked / both flags off), each naming the network, the option, the
   kill-switch state and the concrete traffic that dies.

   coverage.go could not have caught this: it skips covered[i.Device], and the
   device IS covered. The new check is derived from the model alone and so runs
   outside that file's Interfaces() gate.

2. networkList's open `default:` sent (tcp=0, udp=0) to "" — which the engine
   reads as BOTH — so an inbound the plane feeds nothing acquired a listener for
   everything. The four cases are now named and closed, "neither" is a second
   return value rather than a synonym for "both", and a tproxy inbound that
   carries no protocol is refused with a warning that also names the netplane
   half: switching both flags off does not remove the network from the plane, it
   removes the way out of it, so closed mode cuts that network off completely.

generate_test.go: the three linux fixtures that built a tproxy inbound with
model.Inbound's zero-value flags now spell TCP/UDP out. UCI defaults both to
true; only a Go-built model gets false, and only that fixture relied on it.

Gate green (bash scripts/run-tests.sh, exit 0). Every new test mutation-checked
in both directions: suppressing the warning fails 6 tests by name, and making it
fire unconditionally fails the controls. Rendered ruleset text is byte-identical
for all nine shapes dumped before/after — the only diff is added warning lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:48:54 +03:00
omarandClaude Opus 5 6ced96fafa fix(panel): a switched-off subscription is not "never fetched", and an inline list is not "empty"
Two things the panel asserted that the code does not do.

1. THE OFF SWITCH IS NOT A GATE ON FETCHING. The row badge for a disabled
subscription with `fetch_via=proxy` and no detour was drawn quiet and said
"Nothing is disclosed yet — this subscription is switched off … so it is never
fetched". False in all three places that could have contradicted it:
Applier.UpdateSubscription resolves a subscription BY NAME and has never read
Enabled; `shaterd sub update` consults Enabled only when no name is given; and
this panel's own per-row "Fetch now" — new in this wave, previously buried in the
collapsed Options panel — is disabled on `busy || fetching` and nothing else. One
click sent the router's real address to the feed host under a badge saying
nothing was disclosed.

The two halves of the old condition are not alike, so they stopped being one
state. NO URL is real and refused at the bottom (subscribe/fetch.go rejects an
empty URL before it builds a request) — that branch keeps its quiet badge. OFF is
amber, and its sentence says what the switch actually does: it stops the
scheduled refresh, and the button on the row asks for a fetch whatever the switch
says.

The daemon reached the same conclusion from its side in this wave — the fetch is
deliberately allowed and logged, and its finding now varies on Enabled — so the
badge's own summary over that finding varies the same way. "On every scheduled
refresh" printed over a switched-off row is the same lie inverted: it sends the
reader hunting a cron job that is not running.

2. AN INLINE LIST HAS NO ENTRY COUNT, AND "NOT PUBLISHED" IS NOT "EMPTY".
engine.go fills RuleSetStat.RuleCount from (*rule.RemoteRuleSet).RuleCount(), and
LocalRuleSet.RuleCount does not exist in the tree at all, so an inline list always
arrives with rule_count 0. The chip called a working parental-control list
"empty — nothing matches". It reads "size unknown" now: unlit, never green and
never the amber that says something is wrong. A mixed group is counted as a floor
("1,284+") instead of presenting a partial sum as the whole.

BOTH INSTRUMENTS WERE HOLDING THE LIE UP. subFetch.test.ts pinned the sentence
verbatim, and deviceLists.test.ts fixed `{remote:false, rule_count:3}` — a record
no router can produce, so its green light was wired to nothing. The mock carried
the same impossible state on three local rule-sets and fabricated a count on
update. All of them now match what the daemon sends.

Verified in ?mock (new `?subleak=paused|pausedapplied|nourl`), 390 and 1280, both
themes. Each fix reverted in turn with the failure text; controls both ways — a
remote list that really is empty still says "empty", and the two leaking states
are still told apart from the one that is genuinely quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:35:53 +03:00
omarandClaude Opus 5 42536675e2 fix(panel): read the age, the scope and the hop — three fields the panel was ignoring
The daemon changed under the panel in three places, and in each one the panel
kept drawing a screen that was right only by accident.

byedpi readiness carries `age_seconds` now, because GET /api/status stopped
probing: sixteen enabled instances on ports that neither accept nor refuse cost
6.41 s per poll, measured, and the Apply page polls up to 27 times a minute. The
report is served from a cache and every sentence in it is present tense, so the
panel stamps it. Negative is not an age — zero is the common answer (a loopback
connect finishes in microseconds) — so "not a measurement" is its own reading,
and a daemon too old to send the field is a third one: the reading is real, its
age is not reported. The cold first poll after a start says "not measured yet"
rather than "not determined": nobody has looked is a normal state of a router
that booted ten seconds ago, and it calls for a different sentence than an
instrument that looked and failed. Both keep the unlit lamp and both keep the
egress type locked.

A test run no longer wipes the board, so a card can show a reading from twenty
minutes ago beside one from a second ago. Which is which comes from `scope`, not
from comparing timestamps — the router has no RTC and a computed "n minutes ago"
would be fiction. A carried row says "earlier run" and is drawn as a qualifier;
an empty scope is "cannot attribute", never "everything is carried", because a
real run always covers at least one target.

A chain blocked at a hop was kept red by matching a fragment of the daemon's
error sentence — the last place prose decided anything here. It arrives as
`blocked_by` now, so the match is gone and the row names the hop.

The mock carried the old contract: it emptied the board on every run while a
comment claimed the daemon did too. It carries forward now, by (name, kind),
capped at 64, and `?mock&board=carried` lands on a finished board holding both
kinds of row. `?mock&byedpi=cold` and `?mock&byedpiage=N` reach the two states
the freshness rendering exists for.

Verified in ?mock at 390 and 1280, both themes, no horizontal scroll. Each of the
three fixes was reverted in turn and the tests named the failure; the controls
run the other way too — a helper that marked every row carried, or reddened every
row, fails just as loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:35:25 +03:00
omarandClaude Opus 5 a4ea5dba44 fix(tests): the last two packages that wrote to the router's own /etc/shater
17843be5a measured six packages damaging the machine that runs the suite and
fixed four; model and generate were left because another agent held those trees.
Measured again on 2026-07-27 with the same instrument, scoped to the two
packages, and the diagnosis held EXACTLY:

    CREATED   /etc/shater/config.pre-unreadable.bak
    CREATED   /etc/shater/config.pre-v0.bak
    CREATED   /etc/shater/config.pre-v1.bak
    CREATED   /etc/shater/config.pre-v2.bak
    MODIFIED  /etc/shater/cache.db

model. Every test that reaches writeUCIWith or migrateWith goes through a
fakeUCI, and that seam is what makes the config they read and write a fake.
backupBeforeChange is the one part of the package that does NOT use it: it
os.ReadFile's liveConfigPath and writes into configBackupDir directly. On a dev
box neither exists and the function returns "nothing to copy"; on the testbed and
the router both exist, so the suite planted four bogus copies in the product's
state directory. Worse than litter: the function is create-ONCE per schema and
never overwrites, so a copy planted by a test SILENTLY PREVENTS the real
pre-migration copy that box was going to take.

generate. generate.go emits experimental.cache_file with Path: cacheFilePath(),
and every *_linux_test.go that hands a generated config to engine.Apply/box.New
opens that bbolt DB for writing. Per cache.go's own file comment that DB is a
SAFETY device, not an optimisation: with it, RemoteRuleSet.StartContext skips the
start-time fetch, so the daemon can come up before the WAN does. Rewriting it
from a test is rewriting the thing that keeps a reboot from taking the LAN down.

THE FIX is the one the other four packages already use, not a third one: a
TestMain per package pointing the product paths at a private os.MkdirTemp, plus a
test that still pins the SHIPPED value — because an isolation that leaves the
real decision untested has only moved the defect.

  model:    liveConfigPath/configBackupDir -> a private dir; liveConfigPath is
            pointed at a path that does NOT exist, which is exactly the dev-box
            case the function already documents, so every test that does not opt
            into backupSandbox behaves precisely as before.
            New TestConfigBackupPathsAreTheShippedOnes.
  generate: cacheDirPersistent/cacheFilePersistent/cacheFileFallback -> a private
            dir, and the persistent one is CREATED so the package keeps
            exercising the branch the ROUTER takes. The fallback had to move too:
            on a host without /etc/shater the decision lands on
            /tmp/shater-cache.db, which is just as hardcoded and just as much the
            product's. New TestCachePathsAreTheShippedOnes, which also pins that
            the DB lives inside the directory the free-space checks measure —
            path.Dir, not filepath.Dir, since the gate also runs on Windows.

No waiver was needed at shater/testguard: it follows
`cacheDirPersistent = filepath.Join(dir, ...)` back to os.MkdirTemp on its own.

Verified:
  - the sweep, scoped to the two packages: the five paths above BEFORE, "CLEAN"
    AFTER. Then the FULL scripts/check-test-fs-isolation.sh: 48 package
    verdicts, "CLEAN: the whole suite ran and not one path under /etc /var /usr
    /root /home /opt /srv /run /tmp changed."
  - positive control: a planted test in shater/model that restores the real
    paths and calls backupBeforeChange -> the sweep names
    "CREATED /etc/shater/config.pre-v9.bak", then bisects to "PACKAGE
    .../shater/model" and "TEST ....TestPlantedViolatorWritesTheRealBackup".
    shater/testguard stayed GREEN with the violator in the tree, which is the
    documented blind spot and the reason the dynamic half exists.
    NOTE, learned from the first attempt: a create-ONCE violator is named by the
    verdict but NOT by the bisect — seed_canaries only creates what is missing,
    so the file the whole-suite run left behind makes the per-package re-run a
    no-op ("no single package reproduced it"). The bisect can only name defects
    that repeat.
  - mutation, model: liveConfigPath -> /tmp/shater-live and configBackupDir ->
    /tmp each fail the new test by name; dropping the "keep the older copy"
    return fails TestBackupBeforeChangeKeepsTheFirstCopy ("the first copy was
    overwritten by a later write"); removing the ErrNotExist early return fails
    TestBackupBeforeChangeSkipsWhenThereIsNothingToCopy; removing the
    backupBeforeChange call from writeUCIWith fails
    TestWriteTakesTheBackupBeforeReplacingTheConfig ("the write took no backup").
  - mutation, generate: cacheDirPersistent -> /tmp/shater and cacheFilePersistent
    -> /etc/shater-cache/cache.db each fail the new test, the second one also on
    the dir/file mismatch; cacheFilePath forced to tmpfs fails
    TestCacheFallsBackWhenDirMissing's CONTROL, forced to persistent fails its
    first half; cache_file Enabled=false fails TestCacheFileEmittedAndEnabled.
    Green again after every revert.
  - counts, declared vs executed (go test -list vs top-level verdicts, shipped
    tags, linux): model 160/160, generate 395/395, 0 failures. generate's 3 skips
    are the pre-existing CAP_NET_ADMIN TestIntegrationL3* trio, which [5/7] runs
    and passes.
  - scripts/run-tests.sh: GREEN end to end, exit 0, including [4/7] under -race
    ("OK [race] in 43s") and [5/7] RAN all three privileged tests. The four
    TestByeDPI* races reported earlier no longer fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:56:30 +03:00
omarandClaude Opus 5 ea34e744bd fix(panel): the status poll stops dialling — a readiness cache that carries its age and has an owner
GET /api/status called byedpiProbe on every request. On a healthy loopback that
is nothing, but the probe's cost lives in exactly the state it was written to
report honestly: a port that neither accepts nor refuses burns the full
byedpiDialTimeout, and byedpiMaxInstances of them burn 6.4 s. Measured, on this
tree:

  1 enabled instance, live listener   0.45 ms
  1 enabled instance, refused         0.33 ms
  16 enabled, refused                 3.6  ms
  1 enabled, black-holed            400    ms
  16 enabled, black-holed             6.41 s

The panel shell polls /api/status every 5 s on every page and the Apply page
adds its own 4 s poll, so the pathological state hung the panel for seconds at a
time precisely while an operator was trying to find out what was wrong. A check
that gets slow exactly when it matters is worse than one that is always slow.

The poll now reads a cache (byedpiReadiness.cached), refreshed asynchronously off
the same path: 15 ns per call, primed, and 20 back-to-back polls against 16
black-holed ports cost less than one probe. A background ticker was rejected —
it would dial on a router whose panel nobody has open — and so was blocking the
first poll to fill a cold cache, since that is the same 6.4 s hang, just rarer.

The cache is not allowed to lie:

  - every served report carries age_seconds. Detail is written in the present
    tense, and a present-tense sentence about a measurement taken some seconds
    ago is a claim nobody checked;
  - a report older than byedpiCacheMaxAge is NOT SERVED. It is replaced by an
    explicit unknown with a negative age, so a panel that ignores the age fails
    to an unlit lamp rather than to a stale "listening" unlocking an egress type
    onto a port nothing is on;
  - GET /api/byedpi still really connects. A re-check button answered from a copy
    is a button that does nothing.

And it has an OWNER. The refresh runs a goroutine that dials; left as a package
variable it belonged to nobody, could not be awaited, and — as the race detector
showed — went on reading byedpiConfigPath / byedpiInstalled / byedpiDial after
whatever started it believed it was finished. The cache is now per-Server, with
stop() that forbids further refreshes and does not return while one is dialling,
called from Server.Close. The daemon already defers that Close, so the probe
cannot outlive the server.

byedpiDial became a seam alongside byedpiInstalled and byedpiConfigPath: the
timeout branch is the expensive one and the one a real loopback cannot be
provoked into, so without it neither the cost nor its removal could be shown.

Ten mutations, each killed by a named test: the probe back on the request path;
the cached copy claiming age 0; an over-age reading quoted anyway; a cold cache
returning a blank instead of an explicit unknown; a refresh that is not
single-flight; a late older probe overwriting a newer one; /api/byedpi answering
from the cache; the unmeasured report claiming the binary is absent; stop() not
waiting; Server.Close not stopping. The last one survived its first test — which
asserted a poll straight after Close did not dial, and passed with the stop
removed entirely because the reading was fresh and no poll was due — so the test
now advances the clock to make it due.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:55:34 +03:00
omarandClaude Opus 5 e5dfd74b61 fix(engine): testing one node stops wiping the group board; the blocked hop becomes a field
Two separate honesty defects in the shared test board, both surfaced while
closing the byedpi readiness fix.

1. startTestRun replaced the results slice outright, so pressing Test on one
   NODE blanked every group and chain card on the Targets screen, and testing a
   group blanked the nodes. Nothing on screen explained it, because nothing had
   happened to those targets — the daemon had thrown their readings away.

   Earlier results are now carried forward for every target the new run does not
   itself re-measure. The alternative, one board wiped per run, is simpler and
   has no staleness question at all; it was rejected because it destroys
   information the daemon still has. These are the OBSERVATORY's numbers, taken
   by a prober that never stopped, and a group's reading does not become false
   because somebody tested a node afterwards.

   The staleness question it does raise was already answered: every result
   carries tested_unix, the instant the OBSERVATION was taken, and GroupTestStatus
   publishes this run's scope — so a carried row is identifiable as carried
   without comparing timestamps, and drawn with its age. The board is capped at
   groupTestCarryMax, evicting the oldest first; that cap is the only way a row
   can leave without a newer one taking its place, and it is documented as such.
   done/total still describe this run's targets only.

2. A chain whose exit was never dialled, because an earlier hop was probed and
   did not answer, shipped that fact as prose only: source="" (correct — nothing
   measured the exit) plus a sentence naming the hop. A client reading source
   strictly filed it under "nobody looked", which is the wrong colour, so the
   panel kept the row loud by matching a fragment of our error message — the
   last place it read our prose to decide anything.

   GroupTestResult now carries blocked_by: the 1-based hop index, 0 everywhere
   else. It does NOT set source; nothing measured this target's own path, and
   stamping an instrument on a measurement that never happened is exactly the lie
   source was added to prevent. blocked_by>0 beside source="" is the complete
   statement. Field and sentence are produced together in chainBlockedResult so
   they cannot come to disagree.

Tests (grouptest_board_test.go), each verified by mutation:
  - carrying forward is asserted WITH its control, that a run does replace the
    rows it covers — "nothing disappeared" alone is also satisfied by a board
    that stopped updating;
  - target identity is (name, kind), so a group and a node of one name do not
    evict each other, with the empty-kind wildcard pinned both ways;
  - the cap drops the oldest end;
  - blocked_by carries the hop, keeps source empty, and every other result
    carries 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:55:11 +03:00
omarandClaude Opus 5 17843be5ad fix(tests): running the suite deleted the router's own state — four packages did it
shater/stats/store_test.go ended with

    _ = os.Remove(statsFilePath())

and statsFilePath() is not a test path. It is THE product path: /etc/shater/stats.db
on every host where that directory exists, which is the testbed and the router. So
`go test ./shater/...` deleted the accumulated query and connection log of whatever
machine ran it. The test passed. It had always passed — damage done by a test is a
side effect, not a wrong answer, and no instrument in this tree could see one.

A filesystem sweep (the new scripts/check-test-fs-isolation.sh: seed a router-shaped
canary tree in a container, run the whole gated suite, diff) found it was not alone.
Six packages, by measurement, not by reading:

  shater/stats    DELETED  /etc/shater/stats.db        (the line above; also
                           TestComboBackendSwitchSequence opened and pruned the
                           live DB, which the delete had been hiding)
  shater/logsink  DELETED  /etc/shater/shaterd.log and /var/log/shaterd.log —
                           New()/Reconfigure() purge BOTH product locations when
                           the file toggle is off, so Config.Path (which every test
                           here already set) never protected them. The daemon's own
                           log, the one an operator reads after an outage.
  shater/apply    DELETED  /var/run/shater.active — the ONE token hotplug and cron
                           check before touching the data plane. Clearing it on a
                           live router makes both stand down on a box that is up.
                           holdstate_test.go's `t.Cleanup(os.Remove(ActiveFlag))`
                           was not a cleanup; it was the delete.
  shater/panel    REWROTE  /etc/shater/stats.db — stats.NewStore("sqlite") from
                           TestStatsEndpointsAcrossBackends resolves the product
                           path too.
  shater/model    CREATED  /etc/shater/config.pre-v{0,1,2}.bak, config.pre-unreadable.bak
  shater/generate REWROTE  /etc/shater/cache.db

The last two are NOT fixed here — another agent is working in those trees. Both are
one TestMain away: model already has liveConfigPath/configBackupDir as vars, and
generate already has cacheFilePersistent; what leaks is product code (backupBeforeChange,
the engine's cache_file) called from tests that do not redirect them.

THE FIX is the seam generate/cache.go and generate/ruleset.go already use — the path
becomes a package-level var that only tests assign — plus, in each case, a test that
still pins the SHIPPED value, because an isolation that leaves the real decision
untested has only moved the defect:

  stats:   statsDirPersistent/statsFilePersistent/statsFileFallback + the exported
           SetPathsForTest (exported because shater/panel needs it from outside).
           New TestStatsFilePathPrefersPersistentDir covers both branches.
  logsink: PersistPath/TmpfsPath + a TestMain, since the hazard is in New(), which
           every test calls. New TestLogPathsAreTheShippedOnes.
  apply:   ActiveFlag + the existing TestMain. New TestActiveFlagIsTheShippedPath,
           which also records WHY /var/run: tmpfs, so a reboot clears it.

TestNewStoreSelection got stronger rather than weaker. Its "sqlite" case used to
accept "sqlite" OR "memory" because the real path might not open on this host — an
expected value that depended on the machine. At a private path there is no excuse:
a writable directory MUST report "sqlite", and a new control at an unopenable path
MUST report "memory" (the honest "persistence is not active" signal) without a crash.

TWO GUARDS, because one of them cannot see half of it:

  shater/testguard/fsisolation_test.go — parses every _test.go under shater/ and
  fails BY NAME when a filesystem-mutating call gets a path that is not PROVABLY
  temp-rooted. Positive and closed: what it cannot prove is a failure, not a
  default, which is the only rule that catches a path built by a function call.
  It follows local vars, closures, filepath.Join/Sprintf/+, helper parameters via
  their call sites, helper return values, and the save/override/restore idiom.
  Four waivers, each keyed on file+function+callee, each with the reason printed on
  every run, each a struct field traced by hand; a waiver that stops matching fails
  the test as STALE. Runs inside [2/7] and [4/7] — no new gate step, no new minute.
  Blind spot, stated: damage done by PRODUCT code a test merely calls (which is
  exactly logsink, model and generate above).

  scripts/check-test-fs-isolation.sh — the dynamic half, for that blind spot. It
  refuses to run outside a container unless told twice, because its method is to
  let the damage happen and then look, and it seeds/unseeds only what was missing.

Verified:
  - mutation, task 1: statsFilePath forced to the fallback -> the new path test
    fails ("with ... present = .../fallback-stats.db, want the persistent ...");
    newPersistent forced to memory -> "Backend = \"memory\", want \"sqlite\"";
    the fallback made to report "sqlite" -> "Backend = \"sqlite\", want \"memory\"".
    Green again after each revert.
  - mutation, the guard: the original os.Remove(statsFilePath()) put back -> named
    at store_test.go:154 with "the path comes out of statsFilePath(), which this
    check cannot follow"; a planted test writing "/etc/config/network" -> named as
    a literal path; the walk pointed at one package -> its own <150-file control
    fires ("reading a blank page"); a waiver matching nothing -> STALE WAIVER.
  - control, the sweep: with a planted violator it reports DELETED /etc/shater/stats.db
    and MODIFIED /etc/config/network; without it, those are gone and only the two
    foreign packages remain. Its bisect named shater/stats.TestComboBackendSwitchSequence
    on its own.
  - counts, declared vs executed (go test -list against top-level verdicts):
    stats 112/112, panel 121/121, apply 122/122, logsink 26/26, testguard 1/1,
    0 skips, 0 failures.
  - scripts/run-tests.sh: [1/7][2/7][3/7][5/7][6/7][7/7] green. [4/7] -race fails on
    four TestByeDPI* in shater/panel — a data race between byedpi.go's background
    probe and byedpi_test.go's forceByeDPIBinary cleanup, in another agent's
    uncommitted work (shater/panel/byedpi_cache_test.go is untracked). Proven not
    ours: a pristine HEAD tree carrying ONLY this commit's files passes -race over
    all 35 packages, and the same run with -skip ^TestByeDPI is green on the live
    tree too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:33:45 +03:00
omarandClaude Opus 5 dac2f85c84 fix(insights): a failed DNS lookup stops being the healthiest row in the log
The DNS log drew every row from `action`, which answers WHICH WAY the lookup
went — so a query that left through a detour and then timed out came back as an
accent-coloured `proxy` tag, blocked=false, nothing else said. The row that
describes the exact moment the tunnel broke was the most reassuring line on the
page. `actionTag()` also fell open (`return 'pass'`), so every value the panel
did not recognise — including every value a future daemon might add — rendered
green.

The daemon now carries the outcome as its own axis (LogEntry.Status/Error,
a794fbe37). This brings it to the screen.

Two axes, and the outcome leads. logRoute.dnsRowMark decides both in one place:

  status  → answered | failed | '' (not recorded), POSITIVE and CLOSED, with the
            fallback on the recoverable side. `blocked` refines a recorded answer
            into the fourth situation and is never allowed to invent one on a row
            whose outcome was never written.
  action  → block | proxy | pass | unknown, the same discipline. The path stays
            VISIBLE on a failed row and muted, because "it failed" and "it failed
            in the tunnel" are different reports and the second one closes tickets.

Four situations, four looks: a plain answer has no rail; a filter block keeps its
crit rail and BLOCK tag; a failure takes an amber rail, an amber wash, a filled
FAILED chip, and its cause verbatim beside the rcode reading (-1 renders "no
response", anything else the code the server really sent); a not-recorded outcome
is dashed and faint and claims nothing. A failure with no recorded cause says
"cause not recorded" rather than showing an empty cell that reads as fine.

`error` joins the searched fields (the daemon searches it — q=timeout works) and
the hint under the box now names it. `status` stays out: q=failed must not sweep
up every failure while somebody is looking for a domain by that name.

Verified in ?mock at 390 and 1280, both themes, no horizontal scroll: the four
states are pairwise distinct in computed border/background/colour, and the two
chips take their own line on a narrow screen so the domain keeps 92px instead of
being pinned at its 30px minimum. 19 new tests, each shown to fail under 16
mutations of the code it covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:25:09 +03:00
omarandClaude Opus 5 e065786a32 feat(panel): unlock byedpi on a listener, test one node, and stop two badges arguing
Three things the panel was saying that it had not established.

BYEDPI. The egress type unlocked on `status.byedpi_installed`, which is
LookPath("ciadpi") — "is the package installed", while the operator is asking
"will traffic sent here go anywhere". They come apart on the SHIPPED config: the
packaged /etc/config/byedpi is inert, so installing the package unlocked the
type, the egress went on 127.0.0.1:1080, the apply was green and nobody was
listening. The gate is now `byedpi.state === 'listening'` and nothing else
(byedpiReady.ts, the only place that decides it). GET /api/byedpi also carries
the per-egress port cross-check, so a mismatch is drawn on the row that has it,
naming both ports, in crit — the state where every other signal reads healthy.

`unknown` is neither answer. It keeps the type locked (a control that opens on
nothing established is the same defect wearing a new word) and it is never
painted as a refusal: dashed border, unlit lamp, "not determined", plus a
Re-check button so a dropped request is not a dead end.

ONE NODE. A freshly pasted node had no instrument — the group test reads the
observatory's board and the observatory only probes what the rules route
through, so the first question anyone asks answered "not routed by any enabled
rule". Every node row now has Test, over the same singleton run and the same
GET poll the Targets page uses.

The reading is classified on `source`, not on prose: measured-and-failed is red,
`source:""` is an unlit lamp and the faintest text on the row, because a
negative result that cannot be told from a check that never ran answers nothing.
One escalation survives, documented and narrow: a chain whose exit was never
reached because a hop it runs through WAS probed and failed. Targets keeps its
exact previous appearance while its instrument changes underneath.

The three refusals stay three facts — 404 the node is not in the saved config,
503 the config could not be read (an unknown, never a verdict about the node),
400 no name — with three tones and three sentences.

SUBSCRIPTION FETCH ROUTE. The panel's draft predicate drew amber "proxy · no
route" while the daemon now grades the same fact critical on the same row, so a
saved leaking subscription wore both, at two severities, about one thing.
subFetch.ts reconciles them: where the daemon has spoken it outranks the
prediction, in BOTH directions — including the dangerous one, where the
predicate is content ("via group:auto") and the daemon reports the leak anyway.
The prediction still speaks for a draft nothing has applied yet, and a
subscription that is switched off is not accused of a disclosure the daemon
deliberately does not report for it.

Fixtures reach every state: ?byedpi=<five states>|mismatch, ?nodetest=ok|dead|
unmeasured|400|404|503, ?subleak=draft|applied|divergent. The group-test fixture
also stopped being kinder than the daemon — engine.startTestRun replaces the
whole board, so refreshing one target really does blank the others.

42 tests, 8 mutations each killed by name, and both controls: the gate is shown
to open on `listening` and to stay shut on the other four, and "not checked" is
shown to be drawn differently from "did not answer" — the assertions fail if
either pair is ever drawn alike. Verified in the browser at 390 and 1280, both
themes, no horizontal scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:55:42 +03:00
omarandClaude Opus 5 46a2d4aaad test(model): pin the wire contract the panel's PUT actually sends
handleConfigPut decodes model.Model with DisallowUnknownFields, so this is the
layer the missing fields bit at: not "the attachment is ignored" but "the whole
save is rejected with json: unknown field \"Blocklists\"", losing every
unrelated edit batched into the same PUT. Mutating the JSON name reproduces
that message exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:40:32 +03:00
omarandClaude Opus 5 67290ec2b6 feat(devices): attach named block/allow lists to a device, without a dangling tag
The panel half already ships Device.Blocklists/Device.Allowlists and PUT
/api/config decodes with DisallowUnknownFields, so until now the first
attachment rejected the WHOLE save with `json: unknown field "Blocklists"`,
losing every other edit in it. This is the engine half.

A device now references `config blocklist` / `config allowlist` sections by
name (UCI: `list blocklist` / `list allowlist`, since `block`/`allow` already
mean the typed domains), which brings geosite categories and url-sourced lists
to parental control for free.

Two things here are constructions, not checks.

The tag a device's rule references comes from the accumulator that emitted the
rule-set, never from the list name. Rule-set tags resolve at engine START
(RuleSetItem.Start), so a name-derived tag passes box.New and fails box.Start —
and because both configs share one cache_file path, every apply on a live
engine takes the close-old-then-start-new branch, so the old box is already
gone when the new one refuses. That is no engine, a closed kill switch and a
dark LAN, from one mistyped list name. A reference that yields no tag emits no
rule at all; the emptiness is warned, tagged DEVICE-FILTER-NOT-APPLIED so the
panel grades it critical rather than guessing from prose.

Materialisation is a single memoised point shared by both consumers. Devices
are built before the network-wide filter, so materialising a shared list twice
would hand dedupeRuleSetTags an already-claimed tag — which it DROPS, silently
switching the network-wide filter off for that list. The mutation test for this
reproduces exactly that: DNS-FILTER-NOT-APPLIED, filtering nothing.

Order is the feature: typed allow, typed block, attached allow, attached block,
then the network filter. Otherwise a parent who types youtube.com into a
child's Block loses to whatever an attached geosite category permits, and the
panel draws a "Blocked" chip over a rule that does nothing. Typed and attached
matchers stay SEPARATE rules — rule_set AND-gates over the domain matchers, so
merging them would mean "the domain AND the list".

Attaching a list is itself the switch for that device: Enabled=0 means "does
not participate in the network-wide filter", not "dead", so the list still
loads and filters here — and generate says so instead of leaving it to be
discovered. A blocked name's reply comes from the LIST (Blocklist.Response),
so tier 4 is up to two rules; the typed tier keeps NXDOMAIN, having no owning
object to say otherwise.

Purely additive: an old config has neither list, parses to nil, and the
generated engine config is byte-for-byte what it was. No schema bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:37:33 +03:00
omarandClaude Opus 5 9339e8e70d fix(generate): the cache fallback test ran or skipped depending on its neighbours
TestCacheFallsBackWhenDirMissing guarded itself with

    if fi, err := os.Stat(cacheDirPersistent); err == nil && fi.IsDir() {
        t.Skipf("%s exists on this machine; ...")
    }

i.e. its subject was the machine it happened to run on. The gate caught it as an
UNDECLARED SKIP in one container run and not in the next, with no change to the
code — and BOTH outcomes were green. Only the undeclared-skip check saw it at
all; every other instrument here reports `ok shater/generate` either way.

An order-dependent test proves nothing on the runs where it does run either,
because nobody can tell afterwards which runs those were.

The three cache paths become vars (production never assigns them, same seam
generate/ruleset.go already uses for listsDirOverride) and the test points them
at a temp tree. It now covers BOTH branches with no skip: an absent dir must
choose tmpfs, and — the control — a present one must choose the persistent
path. Without that second half the test is satisfied by a cacheFilePath that
returns the fallback unconditionally, which is exactly the regression the
persistent branch exists to prevent (a cache that never survives a reboot, so a
reboot before the WAN is up fails to start the engine and takes the LAN with it).

Verified:
  - both halves killed by mutation (force persistent -> the first assertion
    fails; force fallback -> the control fails), green again after revert;
  - 5 x `go test -shuffle=on ./shater/generate/`: 474 verdicts and 3 skips
    every time, TestCacheFallsBackWhenDirMissing PASS on all five, never SKIP;
  - shater/generate: 385 declared func Test*, 385 top-level verdicts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:40 +03:00
omarandClaude Opus 5 a67f51c22c docs(panel): the q= field list did not mention error, which the filter searches
shater/stats/filter.go:156 searches ConnLogEntry.Error along with the six
fields the doc names, so `q=timeout` works and the contract said it did
not. Verified against the predicate, not against a report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:36 +03:00
omarandClaude Opus 5 56c9ea56e6 fix(gate): [4/7] reported a failure that did not exist — 664 s of pure sleep
The -race step failed with

    FAIL shater/netplane 600.019s
    panic: test timed out after 10m0s
      running tests: TestApplyIfaceSysctlsCoversRuleDivertedIface

over code that was neither hung nor wrong. Measured (golang:1.26, 32 cores):
shater/netplane is 1.971 s without -race and 663.762 s with it. A 337x factor
is not "-race is slower".

Nine of netplane's test files intercept nft/ip/ubus/uci/sysctl by re-exec'ing
the test binary as a no-op helper — the standard os/exec trick. Under -race
that child is ThreadSanitizer-instrumented, and TSan's atexit_sleep_ms DEFAULTS
TO 1000: every -race process sleeps a flat second before exiting, on no CPU.
~660 intercepted commands, one second each. The per-test times said so out
loud — 12.17 / 13.18 / 14.17 / 129.62 s — they were counting, not measuring.

Isolated, five runs each, of a `func main() {}` with nothing in it:

    built plain                       0.0014 s/run
    built with -race                  1.010  s/run
    built with -race, sleep disabled   0.008  s/run

So the children now run with GORACE=atexit_sleep_ms=0, set once in a package
TestMain rather than in each of the nine fakes (they all build the child env as
append(os.Environ(), ...), so one assignment covers the ones written later too).
TSan reads GORACE at process init, long before TestMain, so the detector of the
test process itself is untouched; only the children see it, and they do nothing
but write a canned string and exit. Proven, not assumed: a deliberate data race
in netplane is still reported under -race with this in place.

    shater/netplane   663.762 s -> 10.625 s   (203 === RUN and 128 top-level
                                               verdicts on both sides)
    shater/devices     28.412 s ->  0.358 s   (same disease, same cure)
    gate [4/7] end to end: was a 600 s timeout, now 56 s

WHAT THE GATE ITSELF WAS MISSING. A deadline and a failed assertion both exit
non-zero, and this script printed the same "FAILED [race]: go test exited 1"
for both — so the reader could not tell "the product is wrong" from "nobody
knows yet". [2/7]/[4/7] now name a timeout as a TIMED OUT, list the tests that
were still running, print only the goroutine dump instead of a quarter megabyte
of PASS lines, and spell out the two opposite fixes (a block, or slowness that
must be MEASURED first). Verified both ways: a sleeping test reads TIMED OUT, a
t.Fatal still reads FAILED.

The deadline stays at go test's own 10m, now written down with the measurement
beside it, and stays there as the hang detector — the slowest package under
-race is 18.9 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:23 +03:00
omarandClaude Opus 5 cae5655dbe fix(panel): the hazard band said DNS leaves and that nothing leaves
Measured on the stand, real LAN clients in netns behind veth, counters in a
separate nft table: in the state this band predicts, 0 packets left the WAN
across the whole run, against 27 in the control that differs only by one added
catch-all rule. Except for exactly 2 — both plaintext UDP/53. So the band's
detail ("nothing reaches the internet") was wrong by those two packets, and its
own DNS step, which calls that lookup the one thing that still leaves, was
right. One word: nothing ELSE reaches the internet.

The DNS step was also behind reality. It named only the lookups devices send to
the ROUTER, but the shipped dns_intercept='1' pulls a query aimed at a resolver
the device picked for itself into the engine too, answers it there, and it
leaves in the same clear UDP/53 — measured both ways, each producing its own
plaintext packet on the WAN. Encrypted DNS is not the way out either: :853 out
of the LAN measured connects=0, because the plan rejects it. The generator's own
critical warning (generate/dns.go) has said all of this for as long as it has
existed; only the panel had fallen behind it.

"takes ... and drops it" is untouched, and measured: the engine accepts on the
local tproxy socket in ~100 us even for an unreachable address and then closes,
so the client gets an immediate ECONNRESET rather than a hang. "Blocks" and
"ignores" would both be less accurate. Nothing is added about ping: the stand's
ICMP probe was 100% loss in BOTH states, so it proved nothing either way.

The test is the point. The two halves live fifteen lines apart and each reads
fine alone, so a wording fix does not survive the next editor. The new test
checks the INVARIANT instead: the band is flattened to clauses and no clause may
claim that nothing leaves while another names something that does. Its detector
is proved on a fabricated band first (a prior that cannot fire measures
nothing), and it asserts the no-resolver band really does contain a clause
admitting the leak, so the check cannot pass by finding neither half.

Mutations, all caught: detail back to "nothing reaches" -> the invariant fails
and prints both clauses verbatim; DNS step back to the router-only wording ->
the resolver test fails; DNS step stops admitting the leak -> two tests fail.
Control: with one resolver configured the DNS step is absent and no clause
claims anything leaves; emitting the step unconditionally fails that control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:23:52 +03:00
omarandClaude Opus 5 fbcf211d19 test(stats): pin that the log filter does NOT search status
matchLog's field list is documented as positive and closed, and the new
`status` is deliberately outside it for the same reason `outbound_kind` is: it
is a fixed vocabulary word, so q=failed would silently match every failed row
while the operator was looking for text. The test row now carries a Status, so
the assertion is not vacuous — a filter block IS an answer, which is also the
Status/Error invariant this row models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:18:10 +03:00
omarandClaude Opus 5 419eaf7bbe docs(porting): the schema number on line 146 was v0.1's, read as v0.2's
PART A is the frozen v0.1 survey, so `CurrentSchemaVersion=1` was archaeology
that happened to be correct about the branch it describes — and directly
contradicted the live schema subsection thirty lines below, which says
`shaterd migrate` writes 2. Anyone skimming the file map for "what is the schema
version" got 1. Say whose number it is, name v0.2's (2, steps {0->1, 1->2}), and
name what migrate1to2 did, since that is what the reader is usually after.

Also documents the `shaterd migrate` reporting contract in PART B: the closed
classification, the two non-syslog channels a failure reaches the operator on
with globals.log_syslog=0, and why 30_shater-core still exits 0 after one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:13:13 +03:00
omarandClaude Opus 5 a794fbe374 fix(stats): a failed DNS lookup no longer reaches the log as a healthy row
dnstrack.QueryEvent carries Failed and Error; stats.LogEntry carried neither.
A SERVFAIL, a timeout, a loopback or a rejected-cached lookup was therefore
written into the query log with action "pass" — or, when the resolver that
timed out had a detour, with the flow-coloured "proxy" — blocked=false, and
nothing anywhere saying no answer was produced. The daemon already knew, one
event at a time: TotalStats.Failed is counted from that very fact in the same
function. The row threw it away, so the aggregate said "N failed" while every
row said everything was fine.

LogEntry gains two fields:

  Status — closed vocabulary, "answered" | "failed" | "" (NOT RECORDED), same
    discipline as OutboundKind/RuleKind. It is a separate axis rather than a
    fourth Action value because Action says WHICH PATH the lookup took: a query
    that went out through a detour and then timed out is action=proxy AND
    status=failed, and folding the two would erase the one fact that says
    whether the tunnel is what broke. It is also what an old panel would have
    silently mapped back onto "pass" through its own open fallback.
  Error — the producer's own cause text, verbatim, meaningful only when
    Status=="failed". No grading is invented on top: three of the four causes
    are fixed literals ("loopback", "rejected (cached)", "rejected") and the
    fourth is the transport's err.Error(), which cannot be classified without
    guessing. "failed" with an empty Error is honest and reachable — the lookup
    failed and the cause was not recorded. What IS derivable stays derivable:
    Rcode separates "no response at all" (-1) from "the server refused".

queryStatus is a closed POSITIVE list over the sources a producer emits; an
unlisted or zero Source falls to "" (not recorded), never to "answered". The
aggregate is untouched: blocked/failed are computed once in handleEvent and the
row is labelled from those same two values, so the counter and the row can
never disagree and nothing is counted twice.

Cost: LogEntry 152 -> 184 B on 64-bit (+6.4 KB at the default 200-row ring).
Status is a package constant, so its body costs nothing; Error is interned in
its OWN table (maxErrKeys=128, clamped to 160 B) rather than the rule table,
because the transport's error text embeds the queried name and a flood of
distinct causes would otherwise keep clearing the routing-text table.

Tests: every assertion mutation-checked, and the control is three-state — the
same instrument separates answered from blocked from failed, with the
aggregate pinned to identical totals across the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:12:59 +03:00
omarandClaude Opus 5 735aa5428f fix(shater-core): name which of the four ways shaterd migrate ended
Both call sites swallowed the result. /etc/uci-defaults/30_shater-core ran
`shaterd migrate >/dev/null 2>&1` — stdout, stderr AND the exit status gone, so
a refusal was indistinguishable from a success on the one screen the operator
who caused it was reading. /etc/init.d/shater logged, but with a single sentence
that described only one of the outcomes: "routing rules that still carry the
removed dst_domain/dst_ip options stay DISABLED until this succeeds. Free space
on /overlay and re-run". On a DOWNGRADE every clause of that is false — nothing
is disabled, /overlay is not the problem, and re-running never helps, because
the fix is to put the newer package back. A confident wrong diagnosis costs more
than no diagnosis.

The outcome is now classified with a CLOSED positive list — ok / downgrade /
unreadable / failed — and the last rung is the point of it: an unrecognised
failure says it is unrecognised and quotes the binary verbatim instead of being
reported as one of the causes we can name. `downgrade` is recognised by the
substring "newer than this build", which both model.migrateWith's refusal and
model.ErrSchemaTooNew contain; that seam is a contract and is now pinned.

log_syslog=0 is honoured, not worked around. It is a statement about the syslog
stream, not a request to be left uninformed, so failures go to two channels that
are not syslog: the script's own stderr (the operator's terminal on a hand-typed
restart; the package manager's output inside `apk add`), and
/etc/shater/migrate-failed on flash — written on failure, REMOVED on the first
success, so its absence is the honest all-clear. syslog gets the same line when
log_syslog allows it. A migration that SUCCEEDED stays routine.

uci-defaults still exits 0, deliberately: a uci-defaults script that does not is
kept and re-run at every boot, and this one re-runs a detached enable+restart of
shater/shater-cron plus a firewall reload — one recoverable failure would become
permanent boot-time churn, to carry a status nothing reads. The retry that
matters already exists in start_service, which runs the migration every start.

Found by mutation while writing the tests: reverting start_service's call site
left every other test green, because they all call shater_migrate directly. The
reporter would have been perfect and unreachable. TestInitScriptStartServiceUses-
TheReporter closes that.

Verified: sh -n and busybox ash -n on the target (ImmortalWrt 25.12.1 r37978),
the classifier exercised there under busybox ash against the real uci; six
mutations rolled back one at a time, each caught by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:12:54 +03:00
omarandClaude Opus 5 d1f43dbbe6 fix(shaterd): diag printed constant.Version instead of the version it was handed
renderDiag took a version through its seam and then ignored it, reading
constant.Version directly — so the one field the dead-daemon test could have
pinned was the one field it could not see change. The bundle now prints what it
was given, and the test asserts the value and not just the heading.

Also names the cost the schema gate adds: model.readDiskState's own comment says
"this runs once per write", and it now also runs once per apply, i.e. once a
minute from shater-cron — one `uci export shater` fork and two parses of a few
kilobytes. It reads the DISK rather than m.Globals.SchemaVersion deliberately:
m need not have come from disk (rollbackTo hands in an in-memory snapshot), and
the question is about the file this build would have to live with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:11:06 +03:00
omarandClaude Opus 5 67c4829f03 fix(apply): name the fetch_via=proxy that is not going through anything
`fetch_via=proxy` with an empty `fetch_detour` is not "proxy, details to
follow". Applier.HTTPClient hands "" to resolveVia, which passes it through
(it is not a `chain:` selector), engine.ViaToTag maps "" to the tag `direct`,
and the feed is dialled through the box's direct outbound — over the ordinary
WAN, with the router's real address, merely from inside the daemon process
rather than from the CLI. Nothing fails. The subscription provider, the party
`fetch_via=proxy` is chosen to hide from, sees that address on every
scheduled refresh.

The picker exists and defaults to Direct, so the state is not "unconfigured";
it is "configured, and silently equal to direct". The message opens on that.

Critical, by this file's own rule at the top — protection the operator
CONFIGURED is not in effect — and by consistency: criticalMarkers already
grades the identical disclosure critical when generate says it about DNS
("in the clear", "your provider sees", "leaves over the plain WAN with your
real IP address").

A detour that names nothing is a SEPARATE finding at `warning`, because it
has the opposite consequence: resolveVia or the engine refuse by name and
UpdateSubscription returns the error rather than falling back, so nothing is
disclosed — what breaks is the refresh, loudly. One sentence for both would
send the operator to fix the wrong thing. A bare name that is really an
egress or a chain gets its own text giving the spelling that resolves, rather
than a false "nothing answers to that name".

Filed under section `subscription` + the sub's own name, which the panel
already routes to that row (Nodes.tsx entityFindings/findingsByName) and to
Overview. The severity is part of that binding, not just the volume: `info`
is filtered out of entity routing on purpose, so it would never reach the
row — recorded at the constant.

Also completes the FetchDetour contract in model.go, which listed neither
`chain:X` — the form apply.resolveVia has a dedicated branch for — nor what
"" actually does.

Verified: 9 mutations, each reverting one part, each caught by a named test;
controls show the same instrument silent for a resolved detour, for
fetch_via=direct, and for a subscription that is disabled or has no URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:09:30 +03:00
omarandClaude Opus 5 b43f673ad2 fix(apply,shaterd): a downgraded build may not RUN a config it can only half-read
model.WriteUCI already refuses to write a config whose schema is newer than the
build, so a downgrade can no longer eat the file. What was still open was
RUNNING one. ParseUCIExport ignores options it does not recognise — silently —
so a v3 config read by a v2 build yields a Model with the v3 settings simply
absent. The engine starts perfectly happily and routes traffic by a policy
nobody wrote. Nothing said so: cmdRun never called Migrate(), /etc/init.d/shater
calls it, logs one daemon.err line on failure and starts us anyway, and that log
defaults to a tmpfs file globals.log_syslog='0' can switch off entirely.

REFUSE OR START — and why refuse. Both sides, weighed by "the default falls to
the recoverable side":

  * REFUSE. With kill_switch=closed the fail-closed plane goes up and LAN->WAN
    forwarding stops. Loud, immediate, impossible to miss. SSH, LuCI and the
    panel stay reachable, nothing on disk changes, and reinstalling the build
    the router ran ten minutes ago puts everything back exactly as it was. The
    damage is an outage the operator caused themselves and can undo.
  * START ANYWAY. Traffic the missing rules were meant to tunnel leaves through
    the plain WAN with the router's real address on it, and nothing announces
    it. That is not recoverable in the sense that matters — the disclosure has
    already happened. It is the same choice `sub update` made when it was given
    FAIL over a silent direct fetch.

So: refuse. But the daemon does NOT exit and does not crash-loop — a refusal
nobody can see would be the third bad option. It stays up, keeps serving the
panel and the control socket, and says why in three places:

  1. apply.schemaDowngradeGate refuses every apply (step 0 of applyLocked), with
     the engine-swap failure policy of step 2: a previous engine that IS running
     a config this build understood is left alone; with no engine, holdLocked
     installs the fail-closed plane — and honours kill_switch=open, which is the
     operator's documented fail-open choice and may not be quietly overridden.
     This is in applyLocked and not only in cmdRun on purpose: cron reconciles
     once a minute, so a gate that only ran at startup would be bypassed sixty
     seconds later.
  2. Status carries the PAIR: schema_version (disk) and schema_supported
     (model.CurrentSchemaVersion). Either alone is unreadable — the panel
     already showed the disk version, and "v3" next to a build that understands
     v2 looks entirely normal. The difference IS the fault. schema_supported is
     a compile-time constant and is therefore set even on the offline stub, i.e.
     on the daemon most likely not to be answering. A critical warning naming
     the downgrade is computed at READ time, because in this state no apply can
     succeed and "the warnings of the last successful apply" would be empty.
  3. cmdRun consults model.Migrate() before reading the config (so a bare
     `shaterd run` gets the gate too) and classifies the outcome with
     CheckConfigWritable: ErrSchemaTooNew is the downgrade, anything else is an
     ordinary migration failure and is NOT reported as one.

Only ErrSchemaTooNew blocks. ErrUnmigratedConfig — schema-v1 dst_domain/dst_ip
leftovers — must not: the init script documents starting anyway with those rules
disabled, and turning that into a blackout would be a regression.

Also in this pass, reported by the coordinator: standing_state_test.go's
noGatewayFinding claimed to be quoted from netplane "so the test breaks if that
warning is ever reworded". It cannot — the string never leaves this package and
netplane.noGatewayWarning is never called — and the claim was already false when
it was read: netplane's text has since gained "over IPv4" and an IPv6 clause
while every test here stayed green. A fixture that advertises a guarantee it
does not provide is worse than one that advertises nothing. The comment now says
what it is, and netplanechannel_test.go pins the two couplings that are real:
the severity comes from the CHANNEL (warningFromText(t, "interface",
SeverityCritical), no classify pass), so no rewording can demote it — with the
control that the same texts on the generate channel are NOT critical — while
Section/Name DO come from the `kind "name": ` prefix, asserted in both
directions. A genuine text link is one exported helper in netplane away and is
left to whoever owns that file.

Verified: 13 seeded mutations. Twelve killed by named assertions; the
thirteenth SURVIVED — the guard in schemaWriteVerdict could not be seen, because
on a build host model.CheckConfigWritable answers nil for everything, so the
test reported success whether the guard was there or not. The checker is now
injected and both directions of that guard are killed. Every schema assertion is
walked over all three relations (disk newer / equal / older), so nothing here
passes by always answering the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:05:10 +03:00
omarandClaude Opus 5 da7411e29a feat(panel): attach named block/allow lists to a device, and show whether they loaded
A device could only carry hand-typed domains. It can now also reference the
`config blocklist` / `config allowlist` sections by name, which brings geosite
categories and url lists to parental control for free (Device.Blocklists /
Device.Allowlists — the Go half is landing separately).

The composition problem was the order. The engine decides a name in five steps —
allow typed, block typed, allow attached, block attached, network filter — so the
typed lane and the attached lane of ONE control are two steps apart, with the
other control's lane in between. Two controls therefore cannot show the order by
position. The card draws it instead: a five-stop rail, lit per step where this
device actually has something, and the same step number stamped on each lane
inside the two pickers.

ListPicker is a new component rather than a generalised SrcPicker: that one is
welded to useSrcOptions(), to CIDR validation, and to an empty state reading
"everyone · all LAN clients", which on a block list means the opposite of the
truth. It reuses SrcPicker.css and its whole interaction language.

Honesty, in three places it would otherwise have lied:

  - a list chip reports what /api/ruleset/status says, not that someone attached
    it. Never fetched reads "not loaded" in crit, an empty one "empty", one the
    engine has not mentioned "load unknown" — dim, never green. A name the config
    no longer has reads "no such list".
  - attaching a list is itself the switch for that device, so a list with
    Enabled=0 is NOT drawn as dead, and the DNS page's "configured but inactive"
    is replaced by a sentence naming the devices still running it. A row for such
    a list now reads "N devices only" instead of "off"/"inactive".
  - an attached allow list is terminal, so it lifts the network blocklists off
    everything it covers. Said in the picker at the moment of choosing and again
    on the card.

cleanDomain demanded /^[a-z0-9.-]+$/, so a colon could not be typed and the
engine's own full: / suffix: / keyword: vocabulary was unreachable from the
panel. parseDomainEntry accepts them from a closed positive list and refuses, by
name, the three shapes the engine silently discards: an unknown `word:` prefix, a
marker with no value, and an IP. The keyword case gets its own message — an empty
keyword is strings.Contains(host, "") and would take the device off the internet.

The logic lives in src/deviceLists.ts with tests, since `node --test` cannot load
a .tsx. Each test was mutation-checked, and the load reading is shown giving both
a positive and a negative result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:01:56 +03:00
omarandClaude Opus 5 73bd02dd71 feat(byedpi,nodetest): check the listener, not the file; and let one node be tested
A2 — the panel unlocked the byedpi egress type on LookPath("ciadpi"), which
answers "is the package installed" while the operator is asking "will traffic
sent here go anywhere". Those come apart on the SHIPPED configuration: the
packaged /etc/config/byedpi is inert (enabled='0'), and the port is coordinated
between the two packages by comment only — nothing in the daemon had ever read
that file. Result: type unlocked, egress on 127.0.0.1:1080, apply green, nobody
listening.

shater/panel/byedpi.go now decides on three separate facts (binary, enabled
instances + their ports read from the conffile, a TCP connect to each) and
reports a CLOSED state: unknown | not_installed | disabled | not_listening |
listening. Only "listening" may gate the egress type. GET /api/byedpi adds the
per-egress port reconciliation, so a mismatch is NAMED with both numbers instead
of going quiet. Nothing overclaims: the check is a connect, not a SOCKS5
handshake, and every sentence says so. A connect that is neither accepted nor
refused is "unknown", never "no".

C4 — a just-added node had no instrument: the group test reads the observatory's
board, and the observatory only probes what the rules route through, so the one
question a fresh node exists to ask ("is it alive?") answered "no rule routes
through it". POST /api/groups/test now takes {"kind":"node"} and runs the SAME
instrument — same singleton, same runner, same result type, same status poll,
same exit_ip through the target's own outbound with the same refusal to answer
from `direct`. The only addition is one fallback: a node the observatory does not
cover is measured once, here, through probeOneInto (the observatory's own
dialler), recorded under its own tag alone. A node whose base tag is a plan STORE
ALIAS — the egress-bound-group case — is NOT dialled: the board already holds its
egress-path number, and a bare-WAN measurement filed there would be the same
poisoning one layer down.

Results gained kind (group|chain|node|"") and source (observatory|on-demand|""),
so "nobody measured this" is distinguishable from "measured and dead".

Also: PUT /api/config maps model.ErrSchemaTooNew to 409 beside ErrUnmigratedConfig.
A downgrade refusal is the guard working, fixed by the operator, not by us; 500
sent the reader to the daemon log.

Every new test was verified by mutation (14 mutations, each killed by name), and
each detector has a control: the byedpi probe is shown seeing a real loopback
listener AND its absence with nothing else changed, and the node test is shown
telling a live node from a dead one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:37:48 +03:00
omarandClaude Opus 5 ffe4d8726f feat(apply,shaterd): a configuration history on disk, and a support bundle that outlives the daemon
Two holes from the operations audit, and I can confirm both of its readings.

CONFIGURATION HISTORY. Applier.lastGood and Applier.snapshot are fields in
this process. A daemon restart or a reboot loses both, and Rollback with no
snapshot goes to rollbackEngineAndPlane, which re-reads the CURRENT
/etc/config/shater — that is, it re-asserts the config that broke. With
confirm_timeout at 0 by the owner's choice there is no auto-rollback either,
so "what did the working config look like?" had no answer at all once the
daemon had restarted. Nothing on this router kept one.

Every successful apply now files RenderUCIExport(m) — the existing pure
function, not a second serializer — into /etc/shater/history/<unix>-<version>.uci.

  * DEDUPLICATED against the newest entry. shater-cron reconciles once a
    minute and every reconcile runs applyLocked to completion, change or no
    change, so a file per apply would be ~1440 identical writes a day onto
    overlay flash and would fill the ring with twenty copies of one config
    twenty minutes after the last real edit. One file is now one change.
  * 20 files / 512 KiB total / 128 KiB per entry, hard ceilings, not defaults.
    The shipped /etc/config/shater is 12.6 KB of which 459 bytes are actual
    configuration; a loaded one renders to a few KiB up to low tens of KiB, so
    twenty entries normally cost 50-200 KiB and the byte cap binds only for
    inline entry lists. Against what this product already grants itself on the
    same overlay — 4 MiB of compiled lists, an 8 MiB rule-set cache, a stats.db
    defaulting to 64 MB — 512 KiB is a rounding error. An entry over the
    per-entry ceiling is REFUSED rather than allowed to evict the whole ring,
    and the refusal is reported.
  * 0700 dir / 0600 files. Checked, not assumed: the Makefile installs
    /etc/config/shater with INSTALL_CONF, i.e. 0600 root:root, and these files
    carry the same node credentials and subscription URLs.
  * A failure NEVER fails the apply, and is never swallowed: it becomes a
    Warning folded into the set Status publishes (gather + append + finalize,
    the seam abortAfterSwap already uses), so the panel says the history has
    stopped instead of the directory quietly going stale.
  * NOT kept across sysupgrade. The audit's premise that /etc/shater is in
    keep.d is wrong — keep.d/shater-core lists four specific paths, not the
    directory. Excluding it follows model.backupBeforeChange's existing
    precedent for config.pre-v*.bak: the archive is held in RAM across the
    flash and routinely ends up in cloud storage, and this is a local undo for
    changes made on THIS box.

`shaterd diag`. The only thing this product could hand over was
GET /api/log?range=, served by the daemon — so in a crash loop the one channel
that does not need ssh dies with the process. `shaterd diag` prints version,
our packages from `apk list -I`, status, `nft list table inet shater`,
`ip rule`, the log tail and the configuration, as one block, collected entirely
by the short-lived process.

  * It works with a DEAD daemon, which is the case it exists for. The status
    section falls back to the same offline stub `shaterd status` prints and
    LEADS with the fact that no daemon answered, so an empty-looking section
    can never read as a healthy one. No section is ever silently absent: a
    missing nft/ip/apk produces "NOT COLLECTED: <reason>", and `uci export`
    failing falls back to the raw file and says so.
  * Masking is a POSITIVE, CLOSED list of the fields that may be PRINTED
    (diagSafeUCI), keyed by section type. Everything it does not name is
    masked — an unknown option, an unknown section, and every field added to
    model.Model after this build. That is the direction the open `default:`
    lesson demands: the recoverable side is "hidden", not "shown".
    TestDiagMaskingIsClosedOverTheWholeModel proves it by reflection over every
    string the model can render, with the control that the same instrument sees
    those values in the unmasked text.
  * A second layer scrubs the refused literals from the WHOLE document, because
    masking the config alone would only move the leak: the daemon prints a
    subscription URL into its own log on a fetch failure.
  * node.uri keeps its scheme and nothing else — "is this node vless or
    wireguard" is most of the diagnosis and a protocol name is not a secret.

Verified: 14 seeded mutations, every one killed by a named assertion (dedupe
removed, prune removed, ceiling removed, 0600->0644, 0700->0755, failure
swallowed, warning not folded, version not sanitized; allow-list defaulting to
ALLOWED, uri scheme dropped, scrub removed, failed sections made absent, stub
banner removed, masking removed). Every check is paired with its control — the
ring tests assert the newest entry is present and correct, so "the old one is
gone" cannot be satisfied by a ring that silently stopped writing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:34:39 +03:00
omarandClaude Opus 5 7f84ea9496 feat(panel): the logs answer why it went there, where it went out, and where the search stopped
Four fixes on one path — the one a person actually walks when a site does not
open: Insights -> DNS log -> Connections. Three of them were fields the daemon
already put on the wire and the panel dropped on the floor.

1. Connections shows the routing record. ConnLogEntry gains rule_kind/rule/chain,
   with the discipline stats.go wrote them under: "" is NOT RECORDED and can
   never be drawn as "no rule matched". Three states, three readings, and a
   CONTROL test that fails if any two of them render alike. The outbound path is
   printed rule-named-tag first, dialling-outbound last (the wire order is the
   reverse).

2. The DNS log says where the lookup left. outbound_kind is a closed four:
   detour (tag named) / default (the resolver names no detour -> the query went
   out the plain WAN, past the tunnel; marked amber) / local (cache, optimistic
   answer, filter block: nothing egressed) / "" (not recorded). An unrecognised
   value falls to "not recorded", the recoverable side, not to one of the answers.

3. Both logs take q=. The daemon filters inside the store on the same walk as the
   cursor, so limit counts MATCHING rows. The searched fields are named under the
   box, because a POSITIVE CLOSED list is also a statement about what is NOT
   searched: no ports, no rule_kind, no outbound_kind — q=default matching every
   default-egress row would be a trap wearing a filter costume. logRoute mirrors
   filter.go exactly so the ?mock backend finds and misses what hardware does.

4. A TRUNCATED page is not the end of the log. A filtered walk is budgeted
   (MaxFilterScan); a page that ended on that budget is short for a reason that
   has nothing to do with how much data exists. X-Stats-Log-Truncated is now read
   and the state is NAMED — an amber "Scan stopped" plate, the empty text saying
   "not the end of the log" instead of "nothing found", the count line refusing
   to say "all loaded", and the daemon resume cursor behind a button. The cursor
   matters twice: a truncated page can have ZERO rows, so there is no row seq to
   page from, and the live tail now advances on rows EXAMINED rather than rows
   matched — a filtered after= poll that matched nothing used to rescan the same
   window every tick forever.

Also: .fp-select gets max-width:100% + min-width:0. A <select> shrink-wraps to
its widest option and, as a flex item, refuses to shrink below it: the geo
provider label measured 501px in a 375px viewport and gave the PAGE a horizontal
scrollbar (scrollWidth 559 vs clientWidth 375, measured). Settings.css and
Networks.css each carried a narrow copy of this fix; the component is the right
place. Verified on an isolated harness with no page-local CSS: bare select
overflows a 320px row at 438px, adding the class alone brings it to 320/320.

Tests: 34 new, every one mutation-verified — unrecorded folded into default /
into local, rowMatches returning true unconditionally, outbound_kind added to the
searched fields, historyExhausted ignoring truncated, logEndNote drawing both
situations with one sentence, logCountLabel saying "all loaded" on an incomplete
scan. Each revert reproduced its own failure text. Both search directions are
covered (finds / does not find), which is what catches a filter that matches
everything. Browser-checked at 390 and 1280 in both themes, no horizontal scroll;
the six-click resume walk from "scan stopped" to "Nothing in the log matches" was
exercised live in ?mock.

NOT verified: no hardware or VM run — the truncated state was exercised against
the mock backend, whose scan budget is 60 rows where the daemon uses 20000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:33:20 +03:00
omarandClaude Opus 5 fd8b424d5a fix(netplane): a missing IPv6 gateway is not an outage, and it never was one for IPv4
Measured on the production BPI-R3: egress `ewan` was carrying the entire
household's traffic (chain default, plane full, verdict tunnel, 15 hours up)
while the panel showed, at CRITICAL, "this egress CANNOT REACH ANYTHING outside
its own subnet — every node, group and rule bound to it will fail to connect".

table 8208 held `default via 10.0.0.1 dev eth1`; the IPv4 half was perfect. eth1
holds one address, fe80::.../64, and the ISP publishes no IPv6, so
`ip -6 route show default` is empty router-wide. The -6 pass found no nexthop
and one family-agnostic text declared the whole egress dead.

Two defects in one line. A per-family fact was stated as an absolute, and the
absence of an optional ISP feature was graded as an outage — in the loudest
register this codebase has, on a channel apply grades critical wholesale. Red
that stands for fifteen hours over a healthy router is not a warning.

IPv4 stays loud and unchanged in substance: an uplink with no IPv4 nexthop
carries nothing. It now scopes its consequence to IPv4 and says outright that
it is not describing IPv6.

IPv6 splits on one piece of evidence — does the device hold a global IPv6
address? If it does not, IPv6 is simply not provisioned on this link: nothing
is broken, nothing leaks (the v6 mark keeps its own table and its unreachable
floor, so it cannot fall through to main), and there is nothing the operator
can do because the missing thing is upstream. Silent. If it does, IPv6 is
configured and the nexthop is missing anyway — a real fault, still critical,
now scoped to IPv6. Silence requires positive evidence: a failed address read
makes us louder, never quieter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:27:50 +03:00
omarandClaude Opus 5 a9ec36e053 fix(netplane): a second LAN's inbound options meant nothing, and rule counters were structurally blind
Three things the divert plane got wrong once more than one tproxy inbound
exists, and one it got wrong all along.

Per-rule diverts read `tcp`/`udp`/`tproxy_port` off the FIRST enabled tproxy
inbound and applied them to every device the plan touches. With one LAN — the
shipped shape — first and owner are the same section and nothing showed. With
two, `option udp '0'` on the second inbound was ignored (UDP diverted anyway,
into another section's listener), `option udp '1'` was ignored the other way
(no per-rule UDP line at all, so the rule's counter never ticked for UDP and
Insights showed a rule that appeared never to match), and `option tproxy_port`
pointed at the wrong listener. Same for the dns_intercept :53 lines, which sit
above the fib-local bypass. Each ingress device now resolves to the inbound
that OWNS it; a device no inbound claims still falls back to the primary,
because that is the only listener its traffic can reach. Verified
byte-identical output for every single-inbound shape against the pre-change
renderer.

Rule counters: a counter exists only for a rule the plane emitted a divert
line for, and it only emits them from SOURCE selectors — so a rule written by
domain or ruleset never appears in RuleTraffic at all, and absence there could
not be told apart from "carried nothing". It cannot be measured: which rule a
packet matches is decided inside the engine after the divert, where nftables
cannot see it. So no counter is invented. Instead the plane says which rules it
can measure (RuleMeasures) and what the numbers it does have actually mean —
an upper bound, not the rule's traffic — and the two are pinned to the rendered
ruleset in both directions. Counters are now declared BY the emitting line, so
a rule whose fragments were all dropped no longer leaves a counter attached to
nothing, reading a confident, permanent, false 0 B.

untunnelable_egress could resolve, pass validation and still mark nothing when
the plan has no LAN ingress device — while apply's note, gated on the same
binding succeeding, told the operator that IPsec/GRE/SCTP now leave through it.
The gate is right (the marking rule has no safe unscoped form), the silence was
not; bound-but-inert is now named.

stats/panel do NOT consult RuleMeasures yet — wiring it is a change outside
this package, and the gap is still visible to an operator today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:27:33 +03:00
omarandClaude Opus 5 d40eeede0c fix(model): refuse a config newer than the build, and never write the version back down
A downgrade ate the configuration silently, and permanently. Migrate() refuses a
newer schema, but nothing on the write paths calls it: the daemon starts
regardless, ParseUCIExport reads the options it knows and drops the rest, and
WriteUCI replaces the WHOLE package. So an older build rewrote /etc/config/shater
with only what it understood. The second half is what made it unrecoverable —
schema_version round-tripped through the Model, so the rewritten file claimed the
OLDER version, and a newer build put back afterwards saw cur == CurrentSchemaVersion
and migrated nothing. Nobody had to be at the keyboard for any of it: the profile
watcher looks every 25 s and `sub update` runs from cron every 6 h, and both
persist through WriteUCI.

The mechanism for refusing already existed and already worked in the other
direction (ErrUnmigratedConfig + CheckConfigWritable); this is its second caller,
not new machinery.

- guardSchemaDowngrade refuses the write and the panel's pre-flight when the
  config on disk is newer than this build, naming both versions and the way back
  (put the newer package on again — the config is untouched). ErrSchemaTooNew so
  a caller can answer 409 instead of 500.
- withDiskSchema takes schema_version from the DISK, never from the caller. A PUT
  body that omits it sends 0, and a rendered 0 is an OMITTED option: the version
  would have vanished and the next `shaterd migrate` would replay every step. A
  body claiming 99 would have locked the box out of its own panel.
- backupBeforeChange copies the live config to /etc/shater/config.pre-v<schema>.bak
  before the first migration and before the first write — once per schema version,
  write-then-rename. A failed backup aborts: the `uci commit` that follows writes
  to the same filesystem, so refusing costs nothing that was not already lost, and
  best-effort-and-carry-on is the silent skip we keep paying for.
- The reverse direction is fenced by a test: an unmigrated v1 config still refuses
  a rule-changing write as ErrUnmigratedConfig, still allows one that leaves the
  rules alone, and still keeps its `list dst_domain` and its v1 stamp.

The shipped /etc/config/shater now says what "conffile" actually buys — values
across a package upgrade, not comments across the first write, which happens
without an operator — and the annotated file is installed a second time as
/usr/share/shater/config.sample, where nothing rewrites it.

INSTALL.md gains the downgrade procedure. Measured on the testbed VM (ImmortalWrt
25.12.1 r37978, apk-tools 3.0.5) against the real apk-v0.2.9/v0.2.10 feeds in an
isolated --root sandbox: `apk upgrade <named>` does not downgrade at all;
`apk add <pkg>=<ver>` does, and leaves a pin in world that a later upgrade obeys;
`apk upgrade -a` downgrades too but took four unrelated packages with it.

Tests in shater/model/schemadowngrade_test.go; every assertion checked by mutation
(8 mutations, each killed a named test) and every refusal paired with a control
that accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:21:01 +03:00
omarandClaude Opus 5 7c93019e81 fix(panel): draw the settings that decide whether traffic leaks
Five things the panel knew and did not say, each one a state where the
screen read healthier than the router was.

Rule.Kill was typed, round-tripped and drawn nowhere. `open` sends a
rule's traffic out direct — around the kill-switch, with the real
address — when its target cannot be built, and such a rule looked
exactly like one that fails closed. It now has an editor beside Target
and an amber mark on the row; the fail-closed default draws nothing, so
the two states are not priced alike. An unreadable value is its own
state: it blocks, like the daemon, and the picker re-surfaces it
verbatim rather than rewriting a value it never showed.

Alert channels were write-once for Type/Token/ChatID/URL/Events, so
fixing a typo meant deleting the channel and going back to BotFather for
a token you already owned. Add and edit are now one form. The token box
starts empty and the caption says what empty means — keep, never clear —
because the panel refuses to show the secret and a save may only clear a
field the editor could show. Same rule covers a type switch: the other
kind's settings stay stored and unused.

The add-rule form pre-filled Target=direct. An untouched form is a rule
with no matchers, i.e. the default route, so one press put the whole LAN
on the plain WAN. `block` would only have swapped the leak for an
outage; the recoverable default here is no default, so the form refuses
and asks.

The empty state said "all traffic follows the default route" without
naming it. On a fresh install that route is `block` — the LAN has no
internet — and this is the page the kill-switch alarm sends people to.
Both it and the lead now name the route in force.

The interception board was computed from the config alone and lit `lan`
green over a stopped engine. Green now needs the engine up AND the full
plane; a hold plane blocks rather than carries, and unknown is an unlit
socket.

Also: four rungs of the untunnelable copy claimed traceroute works. It
prints `* * *` and no hops on every setting — the wording is now
apply/warnings.go's own udpTracerouteFacts, said once.

Tests: killPolicy / alertEdit / defaultRoute / intercept, 38 cases, each
mutation-checked (16 mutants, all caught). Browser-verified at 390 and
1280, no horizontal overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:10:32 +03:00
omarandClaude Opus 5 bc7ea359c8 feat(panel): the settings that only /etc/config/shater could reach
Five groups of real daemon settings had no control in the panel, so the
only way to change them was to edit the config over SSH. Each one is now
editable where it belongs, and each editor is built so it cannot lose a
field it declines to display.

DNS list refresh intervals. Blocklist.UpdateInterval was hard-coded to
"24h" in two places and shown nowhere, while the row displayed the
interval the ENGINE reported — a readout dressed as a knob.
Allowlist.UpdateInterval did not exist in the panel at all. It matters
because an allowlist is how a blocklist false positive gets corrected: one
pinned to a day delivers the fix up to a day after the site broke.

Geo data. GeoProvider and the four URL fields are consumed for real
(generate.SetGeoProvider, /api/ruleset/categories) and the panel USES the
data they pick, while offering no way to choose it. New Settings group
with the closed five-provider list, the custom {category} templates and
the two category indexes. Only `custom` reads the templates, so only
`custom` renders them; an unknown provider is preserved and marked rather
than silently rewritten to auto on page load.

Subscription filters. Include/Exclude/FilterProto/FilterCountry/Dedup,
Format, ExpireAlertDays and the three device-identity headers are now
editable — the same five filters a group already offered over its members,
applied one step earlier. 376 nodes can become the four Dutch ones without
SSH. ExpireAlertDays keeps its three states (blank = the 3-day default,
"off" = -1) instead of being flattened.

Edit-after-create. Blocklists, allowlists and resolvers could be
configured only at creation; a typo in a URL meant delete and rebuild, and
deleting a resolver clears whichever global slot it filled. Every one now
has a row editor. `file` and `geosite` sources are offered when a list
already IS one, so opening a list the panel cannot create never becomes a
way to destroy it.

Stale local type copies. DNS.tsx and Settings.tsx carried local
Blocklist/Allowlist/Globals extensions whose comments claimed api.ts did
not type those fields; api.ts had typed them for a long time. Deleted —
the note was an invitation to declare the next field twice. (Egress.Target
was already gone.)

Along the way, three defects the work surfaced:

  * parseDomains cut comments per TOKEN, so pasting "# ads and trackers"
    contributed ads, and, trackers as three real blocked domains. Cut per
    line now.
  * FetchVia=proxy with no FetchDetour resolves to the tag `direct`
    (engine.ViaToTag), so the feed is pulled over the plain WAN and the
    provider logs the router real address — the one thing `proxy` is
    chosen to hide. The row said "via proxy" for it. It now says
    "proxy - no route" and the editor carries an amber explanation. The
    picker also gained chains, which apply.resolveVia supports for real
    and the picker excluded with a comment that misdescribed the contract.
  * Adding a subscription only saved it. apply does not fetch, and
    shater-cron is inert unless globals.enabled=1 AND the service is live,
    so on a router not yet switched on nothing would ever fill it — and
    the only Update button sat at the bottom of a collapsed panel. Adding
    now fetches, reported separately from the save, and every row carries
    Fetch now. A row with no nodes says what to press.

Nodes also gained the forward link nothing had: a node is not something a
routing rule can point at, and no page said so.

The merges live in subEdit.ts / dnsListEdit.ts / geoProvider.ts because
`node --test` cannot mount JSX. The rebuilt shapes return Complete<T>, so
a field added to api.ts fails the build in the function that has to decide
about it; the subscription merge extends instead, because five of its
fields are provider-reported state no control can show.

Verified: npm run build green; 173 tests pass; 18 mutations each killed a
named test and a probe field added to Allowlist broke the build inside
nextAllowlist; zero Cyrillic in panel/src; Chromium at 390 and 1280 with
no horizontal overflow (the detector caught a real 559px select spill at
390 before the fix).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:07:00 +03:00
omarandClaude Opus 5 447cd8cf7d fix(panel): a switched-off service is not a fault, and say so before the apply that is
The shipped config is `enabled '0'` + `kill_switch 'closed'` with nothing
applied. Every lamp in the panel was derived from what is INSTALLED and none
from whether anything was MEANT to be, so a package that installed exactly as
designed showed a crit master lamp ("Engine down"), a crit kill-switch module
("NOT IN EFFECT") and three crit pips on Apply — at a person who had not done
anything yet. Red that fires on a correct installation is red nobody reads by
the time something is actually wrong.

serviceIntent() is the missing question, and every readout that used to answer
from the installed state now asks it first: off ⇒ unlit socket and a word that
says why; on ⇒ every alarm exactly as before. A positive `off` only — an
unreadable configuration stays `unknown` and keeps its crit, because that is
the state where the LAN really is cut off.

applyRisk() is the other half. Applying an empty config with the service on
and the kill-switch closed sets route.final = block, and the tproxy divert for
the shipped `lan` inbound is installed — so every TCP connection and UDP flow
from the LAN is handed to the engine and dropped. The panel read that state
perfectly once it existed and said nothing before, with confirm_timeout at 0,
so the most dangerous apply this router does ran with no auto-rollback. The
band names the outcome, the missing rollback and the fix, and does not block
the apply.

Insights had a short-circuit for this exact job that never fired: it was gated
on logging being off, and the shipped backend is memory. Ten sections drew ten
well-mannered "nothing yet" states and not one named the switch.

Every test is mutation-checked, and each one is paired with the control that
proves the instrument can still produce the alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:04:59 +03:00
omarandClaude Opus 5 65db309e3c fix(sub): fetch_via=proxy went out on the plain WAN from cron and at boot
`fetch_via=proxy` on a subscription means "pull this feed through the tunnel",
and it is set for exactly one reason: the provider is blocked, or the owner does
not want the provider (and every hop to it) learning the router's real address.

The panel honoured it — Applier.UpdateSubscription resolves fetch_detour against
the running engine — so testing it once from the browser showed it working. The
CLI verb did not: it logged one daemon.warn line and fetched DIRECT.
/etc/init.d/shater-cron calls exactly that verb, so every scheduled refresh and
the fetch-at-boot went out unproxied, and the only trace was a syslog line in a
log globals.log_syslog='0' switches off.

The CLI cannot do this fetch itself — only one process may own the engine — so
it now DELEGATES: a new control-socket verb `sub update <name>` runs the very
same Applier.UpdateSubscription the panel's Refresh button calls. One
implementation, so the two paths cannot drift again.

With no daemon to ask, the subscription FAILS (exit 1) instead of falling back.
The refusal is recoverable — shater-cron does not stamp the item, so it retries
after RETRY_SECS and the already-cached nodes keep working — where a silent
direct fetch is not: the disclosure has already happened. Direct subscriptions
are untouched and still need no daemon at all.

Order is load-bearing: the direct pass and its UCI write run first, then the
delegated ones, because the daemon re-reads UCI and writes back userinfo
counters a later write from this process would silently drop.

Also in this file, reported by the LuCI agent: the offline stub of `shaterd
status` published config_readable=false after a SUCCESSFUL read, telling every
consumer to disbelieve three values it had just read correctly (LuCI worked
around it by reading the field only when a daemon answered), and swallowed a
FAILED read with no trace — the inverted lie apply.Status() was fixed for, in
the one situation that matters most: a full /overlay where "not enabled" tells
the owner they switched it off themselves while the fail-closed plane holds the
LAN shut. Both halves now mirror the live path exactly.

Tests are mutation-verified in both directions, with an instrument that gives a
positive reading for BOTH "went through the tunnel" and "went direct" — a live
origin server and a live control socket in every case, so neither zero is an
artifact of the other endpoint being absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:53:19 +03:00
omarandClaude Opus 5 d63f1d896d fix(bridge): reassemble return-path IP fragments — the bridge dropped them
sing-tun's classifyReturn answers `returnPass` for any IP fragment, so the l3
return path never judges one. On the WireGuard endpoint a passed packet still
reaches the endpoint's own tun stack; the bridge has no second consumer — both
deliverReturn and the batch read loops offer a packet to each attached return
path and then drop whatever nobody claimed. A fragmented answer coming back
through a bridge outbound was therefore lost outright, 100% of the time.

Fragments do arrive: the return direction is fragmented by the LOCAL kernel
(conntrack defragments at PREROUTING for the NAT lookup, the output path
re-fragments to the bridge TUN's 1500-byte MTU honouring IPCB frag_max_size).
packet.go's fixReturnChecksum already recognises a fragment and declines to
touch it — the path was known to carry them.

frag_reassembly.go is a deliberate sibling of transport/wireguard/
frag_reassembly.go: same algorithm, same ceilings (64 datagrams, 1 MiB, 5 s,
non-refreshed deadline, partial overlap poisons the key), so collapsing the two
into one shared package later is mechanical. They are not shared today only
because the seam that would host the shared type — transport/wireguard/port.go
and its test suite — is owned by other work in flight.

Windows is deliberately untouched: there a fragment never reaches deliver() at
all, because classifyInbound needs a transport header to decide ours/not-ours
and WinDivert reinjects the rest into the host stack. Different function,
different defect, platform we do not ship.

protocol/tailscale gets a comment, not a fix: the one ReturnPackets call that
package makes carries BuildUnreachable replies, which are synthesised whole and
can never be fragments, and the real tunnel return path is upstream
tstun.Wrapper.Write, ahead of every seam this tree owns.

Verified: 23 tests, all 14 seeded mutations killed (including "seam removed" on
both the portable and the Linux batch loop), -race clean on linux/amd64 in
docker and on windows/amd64. The darwin seam is compile- and vet-checked only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:53:03 +03:00
omarandClaude Opus 5 55dea4e729 feat(stats): the DNS log says where it went out, and both logs can be searched
C1. handleEvent had QueryEvent.Outbound in its hand, used it only to compute
action(), and dropped it. The query log could name the resolver that answered
and not the channel that resolver's own packets took — the one fact an
anti-leak `detour` on a resolver exists to control.

LogEntry now carries Outbound + OutboundKind, on the ConnLogEntry.RuleKind
discipline: "" is reserved for NOT RECORDED, so the three states that all have
an empty tag stay distinct — "detour" (tag recorded), "default" (the resolver
names none, so its packets take the plain WAN), "local" (cache/optimistic/
filter block: nothing egressed at all). An unrecognised Source falls to
unrecorded, the recoverable side. Rows from older builds decode to unrecorded
and are therefore still distinguishable from a recorded no-detour row.

No rule name is attached, and that is deliberate: the DNS path has strictly
less to work with than the connection path did. A DNS *rule* picks a SERVER,
not an outbound, and the event carries no rule identity at all — only the
transport's tag. Inventing one would be a forgery.

Cost, measured: LogEntry 120 -> 152 B (+32 B/row, two string headers on
aarch64). +6.4 KB at the default ring of 200, +160 KB at 5000. Tag bodies go
through the existing intern table (maxRuleKeys=512, shared with the rule text).

C2. /api/stats/log and /api/stats/conns take q=<substring>, applied INSIDE the
store on the same walk as the seq cursor. It has to be there: Limit is applied
by the store, so post-filtering a returned page would hand back 3 rows of a
50-row page and call it a page. Substring, not regex — nothing a client can
type costs more than a linear scan.

Pagination stays honest. A filtered walk must examine rows it will not return,
so it is bounded (MaxFilterScan=20000) — and a page that stopped on that bound
is short for a reason that has nothing to do with how much data exists. That is
reported: LogPage.Truncated + ScanCursor, surfaced as X-Stats-Log-Truncated and
X-Stats-Log-Cursor. Unfiltered requests are untouched: no budget, never
truncated, same walk as before.

The logRing seam now returns LogPage/ConnPage instead of (rows, pending) so the
truncation state cannot be dropped on the floor between the ring and the API.

Tests: all mutation-verified (7 reverts, each reproduced with its message),
including the copying-variant control for the intern table — strings.Clone
passes an equality check and fails the identity check the test actually makes.
Filter coverage is both-directions (finds / does not find) on both backends,
with mem-vs-bolt parity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:51:17 +03:00
omarandClaude Opus 5 2dfd7caf2b fix(apply): the untunnelable note may not describe an egress the plane never bound
untunnelablePolicyWarnings opened its egress branch on
`Globals.UntunnelableEgress != ""` alone. netplane refuses far more than a
typo: UntunnelableEgressBinding fails CLOSED for any name that does not
resolve to an interface/tunnel egress WITH a device — nothing is marked in
prerouting, no forward-chain accept is rendered, addEgressRouting installs
no rule and no table, and the `untunnelable` policy decides everything by
itself. The note nevertheless opened with "...now leave through egress
"x": the kernel routes them out that interface", about a carrier that does
not exist; it even printed `(device )` once the device was interpolated.
The tail hedged the case thirty lines later, and the first sentence is what
gets read.

The branch is now gated on netplane's OWN verdict, called rather than
re-derived (apply imports netplane, so unlike model.ValidateUntunnelableEgress
there is no copy to keep in lockstep). That needs the whole model, so
collectWarnings/gatherWarnings/untunnelablePolicyWarnings take *model.Model
instead of model.Globals.

When the option is set and unbound, the note now LEADS with that fact and
then gives the ordinary policy text, because that is exactly what the router
is doing. The bound branch drops "a name that matches no interface/tunnel
egress" from its failure list — that case can no longer arrive there — and
names the device it resolved to.

Two further claims found while checking the rest of the file against the code:

- the `icmp` rung promised "IPTV and VPN passthrough work only toward
  addresses your rules route directly". Multicast crosses this router under
  NO setting (the stream is WAN-side inbound; a client's outbound multicast
  is UDP, which untunnelableFilter structurally cannot match), and the other
  three rungs all say so. One true clause was carrying one false one — the
  same sentence the `block` note records having removed for being false.
- "\"icmp\" drops it, excepting only ping/echo" understated a leak. `icmp` is
  the one rung that walks the destination plan and it ACCEPTS raw ESP/AH/GRE
  toward provably-direct destinations, so the operator was told it was
  contained while it left with the router's real address.

Ratcheted by untunnelable_egress_honesty_test.go, each assertion with a
control: the bound and unbound halves are walked in one pass, and the IPTV
and `icmp` checks fail if the matrix ever stops producing the notes they
read. The traceroute matrix grew a third egress value (set-and-bound,
set-and-unbound) so the bound branch keeps being walked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:45:24 +03:00
omarandClaude Opus 5 6f84d0ca7b fix(luci): read daemon_answered, and stop reading a config nobody could read
The dashboard told a live daemon from a dead one by `plane: ""` — a side
effect of the offline stub being a zero value, not a promise anyone made.
`shaterd status` now states it: daemon_answered, true on the live branch and
false on the stub. The detector reads the field first and keeps the plane test
only as the fallback for the non-atomic update window (new luci-app-shater,
old shaterd). When the two disagree the field wins; a stub carrying a plane
word must still read as "no daemon answered".

Both lists are positive and closed. A daemon_answered that is not exactly
true/false is not a verdict and falls through; a plane word this build does
not know lands in unknown. Nothing lights green or amber on a guess, and the
launcher button is still never disabled.

config_readable was already on the wire and nothing here read it. With it
false, enabled/kill_switch/panel_port are zero values: "inert (disabled)" and
a green "closed (fail-closed)" were being rendered out of placeholders, in the
one situation — a full /overlay, an interrupted commit — where the fail-closed
plane has the LAN cut off and the owner is told they did it to themselves.
Those rows now say "not known", a Configuration row carries the daemon's own
reason and its don't-switch-anything-off warning, and an absent nft table is
no longer softened to amber by an `enabled` nobody could read.

The field is consulted ONLY when a daemon answered: the offline stub reads UCI
directly and never sets ConfigReadable, so its false is a zero value while its
enabled/panel_port ARE real reads. Taking it at face value would put "could
not be read" on screen for a readable file. Same reasoning drops plane,
traffic and hash on the stub branch — the contract calls them placeholders.

panel_port is CONFIGURED, not bound: shaterd logs a panel bind failure and
carries on, and SHATER_PANEL_ADDR can switch the server off while the port is
still reported. Nothing measures a listener, so the hint, the tooltip and the
new Panel port row say the port is configured rather than checked, and its
lamp stays unlit even on a healthy router.

tests/status-readout.test.js grows the new cases and now runs under gate step
[7/7]. Mutation-checked four ways against copies: dropping the
daemon_answered branches fails 7 assertions by name, dropping the plane
fallback 5, reading config_readable without the daemon gate 5, and rendering
the placeholders as readings 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:40:24 +03:00
omarandClaude Opus 5 e38108a7c4 test(gate): install iproute2 in the docker lane — without ip every slot is free
test / go + panel tests (push) Successful in 15m18s
release / test gate (push) Successful in 10m57s
release / apk aarch64_cortex-a53 (push) Successful in 5m52s
release / apk x86_64 (push) Failing after 28s
release / release apk (push) Successful in 6s
This change was already in the working tree when this session started; it is
committed here because it is load-bearing and an uncommitted load-bearing file
is a trap.

netplane.L3SlotFor asks the kernel through `ip link show` and reclaims through
`ip link del`. golang:1.26 ships no iproute2, so in the docker re-exec lane
every slot read as FREE, TestIntegrationL3StaleSlotIsReclaimed stood itself
down rather than pass while proving the opposite of what it claims, and [5/7]
then failed the gate — correctly, since this environment HAS root and
/dev/net/tun and the capability guard is therefore not what skipped it.

Installing it is also what made the concurrent-namespace defect visible at all
(see 06c04c157): with no `ip` on PATH, no `ip link del` was ever issued and the
two test binaries that were destroying shater/generate's TUN looked innocent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 03:25:58 +03:00
omarandClaude Opus 5 06c04c157d fix(gate): a unit test in one package was deleting another package's TUN
`go test` runs package binaries CONCURRENTLY and every one of them shares the
host's network namespace. netplane.L3SlotFor is destructive by design — it
DELETES a candidate slot it finds occupied rather than waiting for it — and
netplane.removeL3Devices deletes both slots unconditionally. Two test binaries
reached those for real:

  shater/engine  l3slot_test.go calls l3RetargetForNext for its return value
  shater/apply   Applier.Teardown -> netplane.TeardownRouting -> removeL3Devices

Measured with an `ip` shim on PATH inside the gate container: apply.test issued
9 `ip link del shater-l3a` + 9 `ip link del shater-l3b` per run, engine.test one
per l3slot test — into the namespace where shater/generate's privileged tests
were holding a live TUN. From the other side that is

  post-start inbound/tun[l3-in]: starting TUN interface: find tun interface: Link not found
  no [shater-l3a shater-l3b] device exists after a successful Start

i.e. an intermittently red [2/7]/[4/7] in a package that did nothing wrong,
while [5/7] — which runs only `^TestIntegration`, so neither binary reaches the
slot code — passed the very same test seconds later. It only became visible when
iproute2 was installed into the gate container: without `ip` every slot read as
free and no deletion was ever issued.

Not a product defect. shaterd is one process with one engine; the running
generation's slot is excluded before anything is deleted, and nothing else on
the router calls L3SlotFor.

The kernel is faked rather than the CHOICE: making the engine's tests stub the
slot answer would delete the only place the ENGINE checks that the running
generation's slot is excluded, which is the invariant the production outage
violated. netplane.L3StubKernelForTest points the two kernel operations at an
in-memory set; engine and apply install it from TestMain (forget-proof, unlike a
per-test helper whose omission fails in a different package on some runs only).
netplane's TestL3StubKernelTakesTheSlotChoiceOffTheKernel is the control, in
both directions: stubbed, nothing reaches the exec seam; restored, the same call
does.

Mutation: with the engine TestMain reverted, the generate binary's
TestIntegrationL3* failed 8 of 8 runs beside a loop of the engine binary; with
it, 0 of 8. With L3StubKernelForTest degraded to a no-op, the control fails
naming the three escaped `ip` calls.

Also: the DoH3 ownership test's control now retries.
requireInstrumentFindsPackedQuery packed a query into a pooled buffer, released
it and demanded the scan find it — but under -race sync.Pool.Put drops one
object in four on purpose, so the control failed 18 of 60 measured runs and took
the whole -race pass down with it. Its sibling control in the same file already
retried for exactly this reason. The claim is existential ("this instrument CAN
find a released buffer"), so one success out of 32 proves it and nothing is
diluted; 0 of 60 after. What it does not buy is stated in the code: the VERDICT
is still a 3-in-4 detector under -race, which is the safe direction, and the
non-race pass runs the same test as a certainty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 03:25:46 +03:00
omarandClaude Opus 5 3654acf7fb fix(egress): tunnel was a device to the router and an unknown type to the engine
An egress type was read by two halves that never call each other. netplane's
EgressDevice accepted `tunnel`, so addEgressRouting gave it a mark, an `ip rule`,
a routing table with an unreachable floor and a prerouting mark bypass, and
`untunnelable_egress` (D26) carried ESP/AH/GRE/IGMP/SCTP out of it by kernel
routing with the engine nowhere in the path. generate's outbound switch had never
heard of `tunnel`: default arm, no outbound, so every node, group and rule bound
to the same egress was fail-closed. One name, two answers.

Refusing `tunnel` would have broken the half that works to match the half that
does not — D26's kernel egress is shipped and verified, and the generator's
refusal is already loud and fail-closed. `tunnel` is not a distinct kind either:
the data plane treats it identically to `interface` in every line that mentions
it, and the panel's own `interface` label already reads "out a specific WAN or
tunnel". So it is an ALIAS, and it is folded to `interface` ONCE, at the config
boundary (Model.NormalizeEgressTypes, called by ParseUCIExport/ReadUCI). Teaching
the generator a second string would have left two strings for the next consumer
to forget; after the fold there is one.

- model: CanonicalEgressType / EgressTypeKnown / KnownEgressTypes — a closed,
  positive registry, plus NormalizeEgressTypes on the load path. An unrecognised
  type is left as written, never defaulted: substituting `direct` for a typo
  would send traffic somewhere nobody asked for.
- model: ValidateEgresses now NAMES an unknown type at validate time. Until now
  the only notice was a generator warning raised while building an engine config,
  which said nothing about the data plane — and the two disagreed anyway.
- netplane: EgressDevice and the prerouting mgmt-bypass consult the registry
  instead of carrying their own copies of the rule. The bypass now keys off
  EgressDevice, so a device-kind egress with no interface no longer gets an
  accept for a mark addEgressRouting never installs.
- panel: the egress editor cleared Interface/Port/DPI for every type it had no
  branch for — including types it renders no field for — so opening an egress it
  labels "(unknown)", changing only the NAME and saving deleted its `interface`.
  On a `tunnel` egress that silently unbound untunnelable_egress and dropped the
  ESP/GRE carrier back to policy. A save may now only clear a field the editor
  was in a position to show.
- panel: the unknown-type hint said "This engine builds no outbound for that
  type", which was false for the one unknown type anybody had — the data plane
  was building it a routing table at that moment. It now names both halves and
  states what saving does.

Tests: TestEgressTypeMeansTheSameInBothHalves runs one table of written types
through the real boundary and then asks netplane AND generate, requiring one
verdict (external test package: generate imports netplane, so nothing inside
netplane can import generate). Mutation-checked both ways — dropping the fold
fails on `tunnel`; restoring the old EgressDevice string test reproduces the
historical split with "generate emitted outbound egress-probe = false ... want
true". Panel: egressEdit.test.ts, mutation-checked by restoring the
unconditional clear (Interface undefined, want 'wg0').

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:52:56 +03:00
omarandClaude Opus 5 3a9b3f523d fix(l3): a test that is not about the TUN must not open one
The l3_tunnel default flip (164b703a7) turned 32 ORDINARY tests in
shater/generate red — the whole CI — because every fixture with a tproxy inbound
now generates the `l3-in` TUN and engine.Apply then wants /dev/net/tun, which the
act_runner LXC guest does not have. Three PRIVILEGED tests failed too, on a host
that DOES have the device.

The proposed fix was to move the TUN inbound out of generate and have the engine
add it at apply time. Refuted, on three grounds:

- it does not fix the 32. Thirty of them fail inside engine.Apply, not box.New;
  the engine adding the inbound leaves them exactly as red, unless the l3_tunnel
  signal travels OUTSIDE option.Options — and then
- the hash gate stops seeing it. Apply's fast path is a hash of the options; a
  decision that is not in them makes toggling l3_tunnel a no-op reconcile, i.e.
  the device stays up with the option off, or never comes up with it on;
- and the `icmp "tunnel"` warning cannot move. It needs the model, and the panel
  reads it out of GenerateWithWarnings. Leaving it in a package that no longer
  makes the decision it explains is a lie generator by construction.

What the failures actually were was contention. Measured under `docker run
--cap-add NET_ADMIN --device /dev/net/tun`: run alone, all three privileged tests
PASS; run as a package, all three FAIL — and one fails by finding a `shater-l3`
device that a DNS-filter test created. There are two L3 slots and they are global
to the process. So the fix is that the engine instrument in this suite does not
open a kernel device it does not own: withoutL3Ingress, one helper, applied at
applyAndClose and at the six other call sites.

Nothing is skipped, and the ingress does not lose coverage — it gains some:

- TestL3TunnelChangesNothingButTheTunInbound (ordinary, portable) proves the
  default config MINUS the l3-in inbound is byte-identical, through the engine's
  own marshaller, to the l3_tunnel=0 config. That is what lets the 32 Starts keep
  speaking for the default config instead of merely for a config near it;
- TestL3TunInboundIsAcceptedByBoxNew (ordinary) puts the registry half of the
  privileged test on a gate that can actually run it: a slim registry that loses
  tun.RegisterInbound now fails on EVERY CI run with `type not found: tun`
  instead of only where /dev/net/tun exists. That regression changes no generated
  byte and costs a LAN-wide outage on the router;
- TestIntegrationL3StaleSlotIsReclaimed (privileged) covers what a RESTART finds:
  an engine with l3Device == "" next to a device it did not open. It must take
  the other slot, leave that one alone, and RECLAIM it on the next apply. The
  occupied slot is held by a second live engine, not planted with `ip tuntap
  add` — a planted device is PERSISTENT and therefore attachable, and the
  planted version of this test passed with netplane.L3SlotFor's reclaim loop
  deleted, i.e. proved nothing.

generate's placeholder device name is now longer than IFNAMSIZ allows. box.New
accepts it (measured), so the emitted config is still one the engine can
validate; Start refuses it and creates NO device. A caller that builds a box from
generate's output without going through engine.Apply therefore fails at once and
visibly, instead of quietly creating `shater-l3` — the one name every generation
wants, and the intermittent TUNSETIFF EBUSY that netplane/l3.go exists to refuse.

The "leaked TUN" in the sentinel's message was not a leak. Instrumented: Close
returns in ~300 µs with ZERO open /dev/net/tun fds (control: 1 fd immediately
before Close), and the device survives 3.8-4.6 s longer purely as the kernel's
deferred unregister_netdevice. On the stand (ImmortalWrt 25.12.1 r37978, kernel
6.12.94 — the router's revision) the same test takes 0.10 s, so the lag is a
nested-netns container artefact. l3GoneTimeout goes 5s -> 20s: a leak is
unbounded, so the longer budget costs one slow failure and gives up no
sensitivity.

Verification. CONTROL, the criterion that matters: without /dev/net/tun
`ok shater/generate` (was 32 failures). With `--device /dev/net/tun --cap-add
NET_ADMIN`: green, privileged tests really ran. On local_openwrt, cross-built
with the shipped tags: the WHOLE package green with every privileged test
executed, no contamination. `go build ./...`, `go vet ./shater/...` clean.

Mutation-verified, each reverted after: shortening the placeholder fails
TestL3PlaceholderCannotBecomeAKernelDevice by name; making withoutL3Ingress a
no-op brings back exactly 32 failures; gating a second config change on
l3_tunnel, and stripping nothing in the comparison, each fail
TestL3TunnelChangesNothingButTheTunInbound; removing tun.RegisterInbound fails
TestL3TunInboundIsAcceptedByBoxNew with the right hint; deleting L3SlotFor's
reclaim loop fails TestIntegrationL3StaleSlotIsReclaimed with the production
error verbatim (`TUNSETIFF: device or resource busy`); l3GoneTimeout at 1ms still
fires the leak sentinel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:49:40 +03:00
omarandClaude Opus 5 201aa7c168 fix(panel): traceroute never printed a hop — stop saying it works
Five untunnelable notes told the operator that a plain `traceroute` works,
"still follows your rules", or that the hops it prints are the tunnel's path.
Measured on the production router: it prints `* * *` and nothing else, under
every rung of the ladder — `direct` included — with the L3 ingress on or off.

There is no mechanism that could print a hop. The UDP probe is diverted by
tproxy and delivered LOCALLY to the engine's socket; local delivery is not
forwarding, so the TTL is never decremented and no router on the path is
provoked into a time-exceeded. The engine opens its own connection with a
fresh TTL, and an ICMP error raised against that has no way back to the
client's datagram. `traceroute -I` and Windows `tracert` are ICMP echo and do
work — that half of the text was true and is kept.

One shared udpTracerouteFacts now carries the symptom, the cause and the way
out, so the panel cannot fork the claim; netplane/untunnelable.go states the
same fact in the same terms.

Second correction in the same notes: the outbounds that carry an echo are not
just WireGuard/AmneziaWG. generate/route.go's l3Target is exhaustive by
adapter registration — a wireguard/AWG node AND the direct outbound behind
`direct` or an interface egress. In the commonest configuration here that is
most of the address space, and those pings answer out of the ordinary uplink
with its real address. The old text let an operator conclude either
"tunnelled" or "dropped"; it was neither.

traceroute_honesty_test.go is the ratchet: an exhaustive matrix over policy x
kill switch x L3 x egress, asserting the retired sentences never return and
that any note mentioning a trace carries the shared facts verbatim — with a
control that fails if the matrix stopped mentioning tracing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:20:41 +03:00
omarandClaude Opus 5 78bb6a1be8 fix(netplane): a read that fails, a floor nobody checked, a flow that predates the plane
Four defects, all of the same family: something the plane relies on stops being
true and nothing says so.

1. One failed `uci -q export firewall` opened a hole AND switched off the alarm
   for it. nftZoneDevices answered nil on a read failure — the same answer as an
   empty zone — so a rule with `src: zone:lan` produced no divert line, no
   fail-closed drop and no accept_local; and uncoveredNetworkWarnings, whose job
   is to report exactly that, ran the same command, got the same nil and stayed
   silent. The read now carries its error: renderNft refuses under a closed
   kill-switch (same contract as an unusable device name) and warns under an
   open one, and the coverage check names the blindness itself.

2. RoutingPresent did not check the fail-closed floor its Apply twin installs.
   addEgressRouting/addL3Routing install three things per binding; the presence
   checks knew two. A floor that failed to install once was never retried, and
   the table fell through to `main` the first time its device went down. The
   checklist test grows clause (e) so the next mark cannot repeat it.

3. A flow established before the divert plane existed bypassed it for life:
   confirmed by conntrack while nothing diverted it, offloaded to fw4's
   flowtable, steered by netdev-ingress ahead of our prerouting hook and
   refreshed by its own packets. On the divert going from ABSENT to PRESENT —
   not on every apply — the TCP/UDP entries of flows forwarded from the divert
   devices' subnets are dropped, so they re-derive their path. Not a flush: the
   router's own addresses and LAN-to-LAN are excluded, so SSH, LuCI and the panel
   survive. Measured on the stand: 3 client flows cut, the live SSH session and
   the router's own connections untouched; `conntrack` CLI confirmed absent
   there, which is why this is ctnetlink.

4. The untunnelable text claimed Linux/macOS traceroute "still prints hops". It
   prints none, under any policy: the UDP probe is delivered locally by tproxy,
   local delivery does not decrement TTL, and no router raises time-exceeded.
   `traceroute -I` is what works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:01:00 +03:00
omarandClaude Opus 5 ea3a4c518e test(generate): the L3 ingress is the default now — say so in the fixtures, not in 51 rewrites
The l3_tunnel default flip (164b703a7) turned 51 tests in shater/generate red.
Two premises had changed, and each is repaired where it broke rather than at the
assertion:

- ~43 fixtures build an engine-topology model with no inbounds at all and assert
  "this config produces no diagnostics". On the seeded-ON default such a model
  earns an honest `icmp "tunnel"` warning: the L3 ingress is fed only by the
  tproxy divert plane, and a model with no tproxy inbound raises none. The
  warning is TRUE of those fixtures — they are not routers. So they now say they
  run neither router-wide plane (nonDNSGlobals became plainGlobals, and gained
  the same treatment for l3_tunnel that D24 gave dns_intercept), and every
  "no warnings" assertion keeps its original strength instead of being loosened
  to "no warnings except this one".

- 8 assertions counted len(opts.Inbounds). The subject of every one of them is
  how many TPROXY LISTENERS survive a guard, and a total that also counts a
  synthetic inbound answers a different question — one whose right number
  changes whenever an unrelated global flips. They count tproxy listeners now,
  and while there they gained the assertion the count was standing in for: that
  the SURVIVOR of the clash guard is the first-declared listener, and that two
  distinct ports keep the ports their nft diverts aim at.

TestL3TunnelOffEmitsNoTunInbound had lost its meaning rather than its fixture.
It read the default and asserted "off", so after the flip it was pinning
DefaultGlobals, not l3_tunnel. It now sets the opt-out explicitly and says why
the opt-out has to keep working, and TestL3TunnelOnByDefaultEmitsTunInbound
pins the other direction — that a model which never mentions l3_tunnel gets the
ingress — which nothing in this package did.

TestSniffIsNotAnInboundField asserted "exactly 1 inbound" purely so it could
index ins[0]. It checks every emitted listener now and counts what it checked,
so the guarantee that assertion was really providing (the loop ran) survives
without a count that any future synthetic inbound breaks for no reason.

The warning text is rewritten. "l3_tunnel is on but no tproxy inbound is
enabled" accused the reader of a choice they no longer made: since the flip it
is the default, and a message that reads as "you turned this on" sends them
hunting for a switch they never touched. It now says what is not happening, that
the ingress is on by default, and names BOTH exits — a tproxy inbound restores
it, `option l3_tunnel '0'` says the router does not want it — because which one
is right is a fact about their router the generator cannot know.

model/dnsintercept_test.go had the blindness its l3 twin documented: a plain
strings.Contains is satisfied by `#option dns_intercept '1'`, and the parse half
cannot tell either, because a commented option falls back to the seed, which
since D24 is also true. A config shipping the option commented out would have
passed both halves while giving a fresh install no visible option to flip. The
check is line-wise and comment-aware now, and its "config unreadable" branch is
a Fatal instead of a Skip — a guard that skips itself is how one ends up
reporting ok while guarding nothing.

Mutation-verified, each reverted after: seeding L3Tunnel=false fails the
default test by name; removing the l3_tunnel guard fails the opt-out test;
stripping either exit from the warning fails TestL3TunnelWithoutTproxySkipped;
setting a legacy SniffEnabled on the tproxy listener fails the sniff test;
disabling the listen-clash guard fails TestDuplicateTproxyPortSkipped; freezing
the tproxy port at the default fails TestMultiLanDistinctTproxyPortsBothKept;
commenting out the shipped dns_intercept fails the shipped-config test (and the
parse half stayed silent, which is the blindness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:54:48 +03:00
omarandClaude Opus 5 0f69880150 test(gate): a skipped test is a test that did not run — name it, or fail
Three holes, one shape: work that reads as coverage and is not.

1. shater/apply's TestApplyInstallsHoldWhenEngineFailsToStart — the only
   end-to-end test between "the engine died" and "the LAN forwards to the
   WAN in the clear" — asserted nothing. It broke the engine by pointing a
   rule-set at /nonexistent/nope.srs and stood itself down with t.Skip when
   that failed to break anything; it stopped breaking anything once
   LocalRuleSet.reloadFile began treating an unreadable file as empty.
   Measured in golang:1.26: the skip fired unconditionally and the package
   still printed `ok shater/apply`.

   It now injects the failure at the engineApply seam — the branch under
   test is applyLocked's, and a particular cause that stops causing retires
   the test silently — and COUNTS the seam calls, so applyLocked ceasing to
   go through it fails by name instead of quietly asserting something else.
   Everything else stays real: the model, generate, the kill-switch
   decision, netplane.RenderHoldNft, the latch, Status. New companion
   TestEngineApplyReallyFailsWithoutStarting is the control that the real
   engine.Apply can fail with the engine left stopped, so the simulated
   state is one this fork can be in.

   Mutation-checked both ways: drop the holdLocked call from applyLocked and
   the test fails with "0 holding planes were installed, want 1"; bypass the
   seam and it fails with "the engine-swap seam ran 0 times, want exactly 1".

2. warnings_test.go had two of the same genre. The len(genWarnings)==0
   t.Skip is now a t.Fatal — an unloadable blocklist must always warn, and a
   generate that stops saying so is the W7 regression, not a reason to stand
   down. TestStatusWarningsAlwaysNonNil pins readConfig itself: its
   "zero warnings" assertion was true on a build host only because the
   config read failed SILENTLY, so once that failure started publishing a
   critical warning the same line meant two different things in two
   environments.

3. The gate could not see any of it. It now runs the suites with -v and
   matches every `--- SKIP` against SKIP_DECLARED; an undeclared skip fails
   BY NAME, a declared one prints its reason on every run. check_skips
   proves its own instrument first (no `=== RUN` line => the check was
   reading a blank page), and it also reports on a suite that failed
   elsewhere, so a red tree cannot become a hiding place. -v costs no test
   time (38/25/24 s plain vs 38/24/24 s, warm) — only output, which is
   filtered on a green run.

Also closes the same hole one language over: [6/7] requires every non-Go
test file in the tree to be claimed by a named runner, and [7/7] runs the
ones this gate owns with a verdict by name. openwrt/luci-app-shater/tests/
status-readout.test.js — 24 assertions over the one screen an operator
reaches while the LAN is cut off — was executed by nothing at all, and
[1/7] could not report it because `go list` is its instrument. The non-Go
suites run on the HOST before the docker re-exec, so the local loop really
executes them rather than printing "did not run" every time; where there is
no node at all they are named and the notice replaces the closing banner.

Controls, all run and reverted: a planted t.Skip is caught and named; a
planted failing .test.js is caught and named; an unclaimed test file is
caught and named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:30:39 +03:00
omarandClaude Opus 5 164b703a7d feat(l3): ping travels the tunnel by default, and every LAN zone can reach it
l3_tunnel was opt-in, and "off" had no honest win left in it. Off, a LAN ping
is decided by `untunnelable` alone and every rung is a drop (block) or a
disclosure (icmp/direct send the echo out of the WAN with the client's real
address). "Ping works" was never the state where ping was tunnelled — it was
the state where ping was leaking. On, an L3-capable outbound carries the echo
and one that is not drops it honestly: adapter.JudgeFlow returns ActionDrop for
an ICMP flow whose outbound is not a tun.Port, so no reply is forged. The price
is a standing TUN + gVisor netstack, ~2 MB RSS, and it is stated where the
option is.

The switch stays. It is a real answer on a 32/64 MB device and when bisecting
whether the L3 ingress is what broke a box — but it is now a WARNED answer:
ValidateGlobals says what the off state does to ping and names the policy that
takes over. Two combinations also changed meaning and are now reported:
untunnelable=icmp is no longer "block plus working ping" (the prerouting L3
mark claims every ICMP packet before the forward chain the echo accept lives
in, and a LAN host's ICMP errors are marked in with them and dropped in the
TUN), and the existing =direct report gains a sibling rather than standing
alone.

The fw4 seeding was the second half of the same problem. The divert set spans
every LAN inbound and every iface:/zone: rule source, but 30_shater-core seeded
a forwarding into shater_l3 for `lan` only — so on a multi-zone router ICMP
from the other zones is marked, routed, accepted by `inet shater`, and dropped
by fw4's zone policy with nothing in any log. Every zone gets a forwarding now,
guarded by a scan of the actual src/dest pairs so a re-run adds nothing. Every
zone including an uplink, because guessing which zones hold clients is wrong
somewhere and a superfluous entry authorises nothing: accept_to_shater_l3 is
`oifname "shater-l3*" accept`, and the only thing that routes a packet into
that device is our own fwmark rule.

scripts/testbed-lao.sh builds the second LAN zone this needs to be visible at
all. It is not installed by the package — that is the whole opt-in mechanism.

Verified on local_openwrt (ImmortalWrt 25.12.1 r37978): three runs of the
seeder leave exactly one forwarding per zone (lan/wan/lao) and no existing
section altered; deleting the lao forwarding removes `jump accept_to_shater_l3`
from chain forward_lao and re-seeding restores it; with the idempotency guard
disabled two runs produce nine forwardings instead of three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:26:00 +03:00
omarandClaude Opus 5 de6fa8ebf4 fix(doh3): Close is not an ownership handoff — stop pooling the query buffer
Review found the hole and it is real. My previous fix gave the pooled buffer to
the transport and released it when the transport closed the body, on the grounds
that "http3.Transport closes the request body on every path, hence the Once".
That sentence is true about how many times the body is closed and says nothing
about when — the failure mode this project keeps writing down.

Verified against the pinned quic-go: on every error path RoundTripOpt
(http3/transport.go:167-173) closes the body the moment doRequest returns, and
doRequest (http3/client.go:338-341) waits only on the request-CANCELLATION
watchdog — close(reqDone); <-done — never on the goroutine writing the body.
Nothing in quic-go joins that goroutine. So Close is not a handoff point, and
the sync.Once stopped a double Release while doing nothing about a read after
one.

One correction to the review's severity, since it changes what we tell people:
on the failure path the bytes do not reach the resolver. Every ReadResponse
error branch (http3/stream.go:325, :336, :343, :363) calls str.CancelWrite
BEFORE RoundTripOpt closes the body, so what the writer reads out of the
recycled buffer is thrown at a cancelled stream. The disclosure primitive is the
success path only; the failure path is a read of somebody else's memory, which
is undefined behaviour and a -race finding, and not shippable either.

Fixed by not sharing at all: Pack() into memory the body owns. The alternative —
a lock around Read and Close — would also be correct and was rejected because it
keeps a released-but-referenced object alive, and that is now twice in one day
that an assumption about quic-go's internal lifetimes has been wrong.

The cost is negative, measured rather than assumed: Pack is 87 ns/op at 64 B and
1 alloc against 108 ns/op at 64 B and 1 alloc for the pooled version, because
buf.NewSize allocates the Buffer struct itself — the same 64 bytes — and then
adds Get/Put on top. The pool was never saving an allocation here.

The failure path cannot be caught on the wire, so the new test pins the cause:
a query tagged with a random needle, an exchange that fails (server never
answers; context already cancelled), then the pool drained on the goroutine
RoundTripOpt ran on, demanding the needle is not there. Mutations run without
-race: restoring pooledRequestBody fails both subtests 5/5, and blunting the
scan trips its control. -race is a separate pass, green at -count=3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:12:15 +03:00
omarandClaude Opus 5 b91fba1295 fix(l3): a covered last fragment must complete; say what the timeout really does
Two review findings on the fragment reassembler.

1. A whole datagram could vanish. entry.total was assigned before addRange
   was asked, so a last fragment (MF=0) whose range was already covered by
   MF=1 fragments answered fragInsertDuplicate and returned nil — while the
   entry was already complete(). Nothing re-examined it, because every later
   fragment is a duplicate too, so it died at its deadline with all its bytes
   present. A duplicate now falls through to the completion check: it
   contributes no bytes (held bytes still win) but it does contribute the
   total length. This is what the documented first-wins policy always
   implied; the code just did not do it.

   The sender needed is non-conforming, so the old behaviour was safe rather
   than exploitable — but it contradicted the comment three screens up, and
   that comment is the next reader's only defence.

   Also closed positively: a last fragment declaring an end BELOW the bytes
   already held now poisons the datagram instead of quietly never completing.

2. The 5 s timeout was not a memory ceiling and the comment said it was.
   sweep ran only when a NEW key was created, so once fragmented traffic
   stopped, up to fragMaxEntries entries stayed resident indefinitely.

   Both halves are fixed, and the honest one is the comment. sweep now runs
   on EVERY fragment — an O(64) scan on a path that is already the rare one —
   which releases residue as soon as any fragment arrives instead of waiting
   for an unrelated new datagram. That still does not cover total silence, so
   fragTimeout now documents the guarantee the code actually keeps: bounded
   by fragMaxEntries/fragMaxTotalBytes at all times, released on the next
   fragment, NOT "freed within 5 s".

   No timer, deliberately: it would need a goroutine with a lifecycle tied to
   something returnDeviceWrapper has no teardown hook for, and a goroutine
   that must be stopped and might not be is a failure this project has
   already paid for — to reclaim at most ~1.1 MiB that only exists after
   fragmented traffic has already happened. What bounds growth is the byte
   and entry ceiling; this timeout's job is correctness, and for that a
   check driven by the arriving fragment is exact.

   The now-unreachable per-key deadline check is removed rather than left as
   dead defence in depth.

16 mutations, all red. M15 (duplicate returns early again) reds only the
buggy case while the control and the poison case stay green, so the test is
shown able to see both an assembled datagram and a lost one. M17 (sweep back
inside the new-key branch) reds the new test while both old timeout subtests
stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:08:25 +03:00
omarandClaude Opus 5 df078c3205 fix(model): the write rollback may not swallow its own failure
The rollback added for the "failed import commits the deletion" defect went
through migrate.go's staged(), which drops the revert's error on the floor
(`_ = u.Revert("shater")`). That is defensible where staged() lives — a
migration that cannot revert leaves a half-migrated config, wrong but visible —
and it is not defensible here, because the delta this path stages STARTS WITH A
DELETE OF THE WHOLE PACKAGE. A revert that silently does not take leaves that
delete in /tmp/.uci, the caller is told only "import failed" and believes
nothing happened, and the next `uci commit shater` from any process publishes
an EMPTY /etc/config/shater. The guard reintroduced the exact loss it was
added to prevent.

writeUCIWith now uses its own revertStagedWrite, which reports both failures.
migrate.go's staged() is untouched: changing its signature to suit this caller
would rewrite a contract three migration paths depend on, for a hazard those
paths do not have.

The wrapped error names the CONSEQUENCE and the one command that clears it
("a staged DELETE ... will publish it ... run `uci revert shater` NOW"), not
just the fact — "revert failed" tells an operator nothing about what it costs.
ErrStagedWriteStuck makes it machine-detectable, so a caller can tell "your
change did not happen" from "your change did not happen and this router is one
unrelated `uci commit` away from an empty config".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:06:52 +03:00
omarandClaude Opus 5 d0471b2418 build(shater-core): ship the keep.d entry, or the node inventory dies at the next flash
files/ is not installed wholesale — every path in Package/shater-core/install is
explicit — so the keep.d file added alongside it would never have reached a
router. sysupgrade's "keep settings" walks /lib/upgrade/keep.d/*, and without
this entry /etc/shater/subs does not survive a flash: the restored box has its
rules and its groups and no nodes for them to point at, and the only repair is
`sub update`, which needs the internet the tunnel was going to provide.

/etc/config/shater needs no entry — it is a package conffile and sysupgrade
already keeps it that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:02:14 +03:00
omarandClaude Opus 5 3314927bef fix(panel): stop the readouts claiming things the daemon never said
Nine places where the panel asserted more than it could know. Each was
checked against the daemon before being changed, and the two that a test
can reach are pinned by tests proven with a mutation.

MULTICAST IPTV WAS AN INSTRUCTION, AND IT WAS WRONG. The `direct` rung
said "Ping, multicast IPTV, and connecting to a VPN ... all work", so
someone who wanted IPTV read it and moved to the most open setting on the
ladder — the one that also lets a client's ESP/GRE past the proxy — and
still had no IPTV. The stream is UDP; every rule the policy emits carries
`meta l4proto != { tcp, udp }`, and the fail-closed forward chain accepts
only the RFC1918/link-local daddr sets, with no 224.0.0.0/4 among them.
The daemon says so itself in the note drawn a few pixels below. IPTV is
now stated once, and it says it does not work.

THE `block` COST LINE WAS UNCONDITIONAL, and three settings contradict
it: an open kill-switch (no drops are emitted at all), Globals.L3Tunnel
(ICMP is marked into the engine's TUN before the forward chain) and
Globals.UntunnelableEgress (ESP/AH/GRE/SCTP are routed out a named
device). The last two were not in the panel's `Globals` type, so the page
could not have been honest about them even in principle; they were added
rather than papered over with a vaguer sentence, and the copy is now
derived from all three.

THE KILL-SWITCH WAS READ WITH `=== 'closed'`. The daemon decides with
!EqualFold(TrimSpace(v), "open") and `Status.kill_switch` is the raw UCI
string, so `'Closed'`, `' closed '` and `''` — all of which BLOCK on the
router — drew OPEN, amber, "Nothing is meant to be blocked", and through
protectionState downgraded a plane-less router from crit to amber. One
normaliser now, `planeState.killSwitchClosed`, used by all five callers
that had their own spelling of it.

AN UNREADABLE CONFIG IS NOT "TURNED OFF". `enabled`, `kill_switch` and
`panel_port` are sourced from the config and are placeholders when it
could not be read (new `config_readable`). That happens on a full
/overlay or an interrupted `uci commit` — exactly when the fail-closed
plane has the LAN cut off on purpose — and the daemon publishes
plane:"hold" with enabled:false. Checking `!enabled` first rendered
"Turned off", amber, no alarm, and pointed at a Settings page backed by
the same unreadable file. The check now comes first, carries the daemon's
"do not turn anything off to fix it", and the kill-switch readout refuses
to name a policy it could not read instead of printing ARMED from "".

Also: the holding plane promises "no client TRAFFIC reaches the WAN", not
"nothing" — DNS to the router still goes to the ISP in the clear, by
design, so the daemon can recover; the stats backend is bbolt, not SQLite,
and reclaims space by rebuilding the file, not by a VACUUM that does not
exist (and skips it when the disk cannot fit the copy); the lock screen
sent people to System → shater when the menu entry is admin/services/shater,
which is the one instruction the product gives to someone who has just
lost access; and the panel port is configured, not confirmed — a failed
listen is only a log line.

RULESET.FORMAT WAS DESTROYED BY RENAMING A LIST. The edit form rebuilt
the object from its own controls and has no control for `Format`, so the
value could only be restored over SSH. It decides how a `file` list is
parsed and stops a `url` .srs being read as text; without it the list
matches nothing, the rule stops firing, and the traffic falls silently
through to the next rule. Carried now for the two sources the generator
consults it for. The same class of loss is made loud elsewhere: the two
other rebuild sites return `Complete<T>`, so adding a field to `Inbound`
or `DNSRule` fails the build in the function that has to decide.

Egress.Target is deleted: it is not in the Go model, so the "which egress
points at this node" branches could never fire, and had anything ever put
a string on it PUT would have rejected the whole write under
DisallowUnknownFields.

One layout fix on the way past: at 390px the policy plate's grid column
was sized by the select's longest option, so the sentence beside it was
clipped mid-word — which is how a line about what leaks loses its second
half.

Verified: npm run build + tsc clean; 57 tests pass; mutation-checked by
restoring the old comparison, the old check order and the old rebuild in
turn, each time watching the matching tests fail with the exact inverted
reading; browser-checked at 390 and 1280 against the mock, which now
reproduces `?ks=Closed` and `?cfg=unreadable` verbatim instead of
normalising them out of existence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:01:08 +03:00
omarandClaude Opus 5 6801146240 feat(core): back up the product state, and let the watchdog see a crash loop
Two things the box could not survive, both silent.

BACKUPS CARRIED NOTHING. No shater package put a single entry in
/lib/upgrade/keep.d, so "keep settings" and LuCI Backup took /etc/config/shater
(a conffile) and nothing else. Everything the product knows besides UCI lives in
/etc/shater: the entire node inventory (subs/*.json, hundreds of nodes on the
live router), the boot-armor arm token, the compiled blocklists. Restored onto a
new router the config looked complete and had no nodes to route to — and the
repair, `sub update`, needs the internet the tunnel was supposed to provide.

keep.d/shater-core keeps subs/, boot.nft, lists/ and alert-state.json, and names
what it refuses and why: stats.db is history bounded only by stats_disk_limit_mb
(0 = unlimited) and the archive is built in RAM; cache.db is sing-box's cache and
a stale one is worse than none; shaterd.log is a log carrying the query history
of the box it came from.

THE WATCHDOG COULD NOT SEE A CRASH LOOP. /etc/init.d/shater respawns every 5s,
forever; shater-cron escalated only after five consecutive ticks where `pidof`
found nothing. A daemon dying seconds into startup is back before the next
60s sample, so the counter reset every time — while the fail-closed plane held
the LAN shut and the panel, served by that daemon, never came up.

The tick's sleep is now spent sampling the daemon's identity (via its pidfile,
not `pidof`, which also matches the CLI verbs this loop runs) every 5s. A tick in
which 3 different daemons lived is churn; two such ticks in a row is the verdict.
A legitimate bounce replaces the daemon once and is announced twice over
(RESTART_FLAG up, ACTIVE_FLAG down), either of which discards the tick.

The action is the one the operator already chose: kill_switch=open stops the
stack, exactly as the dead-daemon path does; kill_switch=closed — and an absent
or unrecognised value, which is the documented default — reports at daemon.crit
and leaves the decision to the person, naming the command that opens the LAN.

Also drops the ruleset loop from shater_run_due. `shaterd ruleset update` has
never existed; it exited 0, so the loop stamped every url rule-set as freshly
updated and fired a reconcile for work that never happened. Now that it exits
non-zero the same loop would emit ~288 syslog lines a day per rule-set instead.
The comment says who does own the refresh, and where the gap that is left is.

Verified: sh -n and busybox `ash -n`; the pure detector driven with synthetic
sample streams under busybox ash (13 cases); shater_sample_pid against a real
/proc with a live process named shaterd as the positive control; and the whole
chain end to end against a real 2s-lifetime crash loop. Each threshold and each
veto is pinned by a mutation that makes the gate fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:58:14 +03:00
omarandClaude Opus 5 2f8c692c39 fix(l3): the TUN is a reclaimable slot — one fixed name made every apply fatal
On the production router every configuration change with l3_tunnel=1 killed the
engine and held the LAN down, three times in a row:

  19:10:33  reconcile failed: start inbound/tun[l3-in]: open tun: TUNSETIFF: device or resource busy
  19:14:02  start instance failed and could not restore previous config; engine stopped
  19:14:38  reconcile failed: TUNSETIFF: device or resource busy

A new generation had to open the device the outgoing one still held. That alone
is a failed apply; what made it an outage is that the recovery path rebuilds the
PREVIOUS config, which named the same device — so the rescue failed for exactly
the reason it was needed. A recovery path must not depend on the resource whose
contention it is recovering from.

The device is now one of two slots, chosen by the ENGINE at box-build time, on a
copy of the options taken AFTER the hash — so the stored config stays canonical
and a no-op reconcile is still a no-op. It cannot be chosen in generate: generate
runs every minute and its output is what Apply hashes, so an alternating name
there would rebuild the engine once a minute forever.

Rotation alone was NOT enough, and that was measured, not reasoned: the two-slot
build survived five applies of five kinds and then failed on 4 of 10 back-to-back
changes with the original outage in full, because a retired generation keeps its
TUN until its budgeted Close finishes. So an occupied non-current slot is now
DELETED rather than waited for — the running generation's slot is excluded first
and never touched, every other slot belongs to a box that is carrying nothing.
No bounded wait: waiting on an asynchronous kernel teardown is the race this
design removes.

The firewall never learns which slot is live — our accepts and the fw4 zone match
`shater-l3*`, verified to validate AND load on ImmortalWrt 25.12.1 / nftables
1.1.6, so the ruleset is byte-identical across a swap. Routers seeded by a
pre-slot build are migrated in place, or fw4 would silently resume dropping the
forward.

A2: turning the feature off left the device, the ip rule and table 8200 behind —
addL3Routing returned early instead of tearing down, and nothing else owns that
device. The disabled branch and TeardownRouting now remove all three.

Two smaller lies found while proving this, both measured: `ip -6 route flush`
does not take a non-unicast route, so the fail-closed floor survived and the next
add answered `File exists` — reported as a CRITICAL "this table has no floor,
traffic can leave over the plain WAN" on every apply, about a floor that was
right there; and teardown left it behind. Fixed both.

Verified on local_openwrt (ImmortalWrt 25.12.1, kernel 6.12.94 — the router's
revision) before and after, with binaries built from the same tree: the pre-fix
binary reproduces the outage and the leftovers; the fixed one survives all five
apply kinds and 12 back-to-back changes and leaves nothing behind. Ten reverted
mutations, each shown failing. See D28.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:55:44 +03:00
omar c788425cad feat(stats): the connection log now says which rule sent it there
The tracker has carried the matched route rule and the outbound chain since
upstream (common/trafficcontrol/tracker.go Rule/Chain); nothing in shater/ ever
read them, so "why did this connection go out that exit" was unanswerable from
the log and cost hours per report.

ConnLogEntry gains RuleKind/Rule/Chain. Rule is the engine rule text, not the
model rule name: nothing survives generation that ties an emitted option.Rule
back to the /etc/config/shater rule it came from, and a guessed name would be
worse than none. RuleKind keeps the two empty cases apart — "default" is a
recorded fact (nothing matched, took route.Final), "" means not recorded at all,
which is what an old persisted row decodes to.

Both fields are interned, so the ring pays 56 B/row of headers instead of a
private copy of text that is identical across every connection one rule matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
@
2026-07-26 23:47:49 +03:00
omarandClaude Opus 5 0144282f5e fix(apply): a finding that is still true may not erase itself
Three ways this package published calm over a router that was not doing what
its config said. All three are the inverted failure: not an error raised when
things are fine, but silence when they are not.

1. Critical policy-routing findings were erased by the next no-op reconcile.
   applyDataPlaneLocked set routeWarnings only on the full path; applyLocked
   published the set unconditionally, so a minute later the fast path replaced
   it with one that no longer contained the finding. Neither surviving finding
   ("this egress CANNOT REACH ANYTHING outside its own subnet", "table could
   not be given a fail-closed floor") makes RoutingPresent false, so nothing
   brought it back: zero findings, plane full, green, over an egress carrying
   nothing. The comment on the gate claimed the previous set stood; it did not.

   planeOutcome now distinguishes "nothing was found" from "nothing was
   checked" (routeMeasured, written only by measuredRouting), and applyLocked
   carries the last MEASUREMENT forward across the fast path. A re-measurement
   still retires a finding, so this is not a latch.

2. An unreadable configuration was published as enabled=false. The panel tests
   !enabled before plane and renders "Turned off", amber, no alarm, "turn it on
   in Settings" — over a LAN the boot armor had cut off, pointing at a settings
   page backed by the same unreadable file. Status now carries config_readable
   and config_error, plus a critical finding in section "config".

3. The reason the engine failed to start existed nowhere. holdLocked logged it
   and called no publisher, and Warnings carries the last SUCCESSFUL apply — so
   plane="hold" with an empty findings list was a normal state of the product.
   The cause is recorded and published at read time while the engine is down,
   so it self-clears when the engine comes up; the boot-time arm is a warning,
   a real failure is critical.

Each fix is mutation-checked, and the route-warning test carries its control:
it sees a live finding, sees it survive the fast path, and sees a re-measured
clean state retire it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:45:28 +03:00
omarandClaude Opus 5 b642e5d8fe fix(egress): an interface egress with no interface was bound to br-lan
generate/outbound.go resolved the bind device with netplane.IfaceDevice,
whose empty-name fallback is "br-lan" — correct for an INBOUND with no
network, a black hole for an egress. netplane.EgressDevice returns "" for
the same egress on purpose (it calls br-lan "catastrophic here"), so
addEgressRouting installed no `ip rule` and no routing table for that
egress's mark, and the prerouting marking and the forward-chain accept
skipped it too.

The outbound was therefore emitted with SO_BINDTODEVICE=br-lan and a
routing mark nothing routed: every node, group and rule bound to that
egress dialled public addresses out of the LAN bridge. Not a leak — the
bind pins the socket to the LAN — but a total, silent black hole, with the
panel showing a configured, applied egress and no findings at all. The
`if dev == "" { dev = eg.Interface }` line that stood there read as a
guard against exactly this and could never execute: IfaceDevice never
returns "".

- generate now calls netplane.EgressDevice — the data plane's own
  resolution — so a bind can no longer name a device the routing was never
  installed for, and ` eth1 ` binds what the netplane routes. A device-less
  egress emits NO outbound and is reported; every reference to it then
  resolves through egressDetourOrBlock to tagBlock, so the traffic is
  blocked rather than sent out over the plain WAN.
- model.ValidateEgresses reports the same egress on the config channel
  (netplane's own skip is silent), built on model.EgressHasDevice — the
  model-side twin of EgressDevice, which ValidateUntunnelableEgress now
  shares so the two model resolutions cannot drift either.
- TestEgressDeviceResolutionParity runs one table through
  netplane.EgressDevice and model.EgressHasDevice and requires one verdict,
  the same treatment TestUntunnelableEgressResolutionLockstep gave the
  earlier validator/data-plane divergence.

Also: the UntunnelableEgress comment claimed "the panel says which, at
apply time, from whether the device is point-to-point". It does not. The
operator-facing text states both possibilities and declines to claim
either, there is no UI for the option, and isPointToPoint is consulted
only to warn that a gateway-less device can reach nothing. Said so, so the
next implementer does not read a described feature as a built one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:44:35 +03:00
omarandClaude Opus 5 35e4900769 fix(shaterd): three success reports for work that was not done
`shaterd status` fabricated a status when the daemon was unreachable and
exited 0. The stub is the same struct, printed by the same marshaller, so the
only thing that distinguished it was `plane` being "" — a value a live
Applier.Status() cannot emit. luci-app-shater was forced to key its "daemon
down" verdict off exactly that side effect, and filling `plane` in the stub for
any reason would have silently turned "dead" into "fine" on that page.

Both branches now carry an explicit "daemon_answered" boolean, and the offline
branch exits 1. The field is ADDITIVE and spliced in, not re-marshalled: every
existing key keeps its name, value and position (including plane:"" — still
emitted deliberately so dashboard.js keeps working until it moves onto the new
field), and a newer daemon's unknown fields are relayed untouched.

model.writeUCIWith committed the staged package DELETION when the import that
was supposed to refill it failed: /etc/config/shater came out empty, the caller
saw only "WriteUCI: import: ...", the next ReadUCI reported Enabled=false and
the next reconcile tore the plane down. Both error paths now revert through
migrate.go's staged() instead — the same idiom, for the same reason.

`shaterd ruleset update` printed a note and exited 0. shater-cron runs it with
output discarded and, on a zero exit, stamps the ruleset as freshly updated and
sets changed=1, so every source=url ruleset was permanently "just updated" by a
verb that fetched nothing. notImpl now exits 1 (not 2 — a caller must be able to
tell an unimplemented verb from an unknown one).

pidfilePath becomes a var so the daemon-answered / daemon-absent split is
testable without writing to the real /var/run, mirroring ctlPath.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:39:17 +03:00
omarandClaude Opus 5 d3e33294c1 fix(l3): reassemble return-path IP fragments — classifyReturn drops them
sing-tun's forwardReturn.classifyReturn refuses to judge a fragment
(flow_parse.go sets `fragment` for IPv4 MF/offset and for an IPv6
fragment extension header; flow_dispatch.go:703 answers returnPass), so
a fragmented answer coming back through a WireGuard/AmneziaWG endpoint
falls through to the endpoint's own tun stack instead of the l3 return
path, and the LAN client never sees it.

Measured on the live router: `ping -c3 -s 1400` through an AWG tunnel
with MTU 1280 is 100% loss while the WAN capture shows 3 x (1312 + 208)
in both directions — the far host answers, the peer fragments the answer
to fit the tunnel, the fragments die in classifyReturn. `-s 56` is 3/3
and PMTUD with DF works end to end, so only the fragmented return is
broken.

sing-tun is pinned upstream with no `replace`, but the fix does not need
to live there: every decrypted packet passes returnDeviceWrapper.Write
before it is offered to ReturnPackets. Reassemble there and
classifyReturn gets a whole datagram.

Hard ceilings, because this runs on a 128-256 MB router: 64 concurrent
datagrams, 1 MiB of held bytes, 64 disjoint ranges per datagram, 65535
bytes per datagram, 5 s to complete (timer starts at the first fragment
and is never refreshed). Over any ceiling evicts oldest-first.

Overlap policy: a range contained in one already held is a duplicate and
is ignored (first-wins, deterministic) because benign networks do
retransmit; any PARTIAL overlap poisons the datagram until its deadline.
No conforming fragmenter emits one, and every historical hole in this
area comes from a reassembler that tried to resolve the conflict.

The MTU of shater-l3 is untouched (65535 on purpose) and sing-tun is
untouched.

14 mutations run against the tests; each turns at least one test red,
including the two that first survived (a stale-head reuse the sweep was
covering for, and a fast-path copy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:38:07 +03:00
omarandClaude Opus 5 32aac89139 docs: stop the docs promising a safety net that ships disarmed
Every install recipe walked the reader through `shaterd apply` + `shaterd
confirm` as if commit-confirm were armed. It is not: DefaultGlobals() never
seeds ConfirmTimeout, the shipped config carries confirm_timeout '0', and
ArmRollback returns at once on a non-positive timeout. A reader following the
README believed an apply that cut their SSH would undo itself. It would not.
README/README.en/INSTALL now arm it in the recipe and say what 0 means; the
apply-flow diagram gained the edge it always took on a stock box.

The boot armor was documented nowhere at all (`grep -rli armor --include=*.md`
returned zero) while shipping enabled and blocking LAN->WAN on every boot.
INSTALL 4 now says what it is, why SSH/LuCI stay up on purpose, every condition
under which it refuses to arm, and how to switch it off.

Also removed or corrected, each checked against the code, not inherited:

* MASQUE/CONNECT-IP is advertised in both READMEs and absent from parse,
  generate and model -- registry names it among the types deliberately left
  unregistered. Dropped, with the fork-vs-product distinction spelled out.
  The inverse too: Hysteria2/TUIC/XHTTP were tagged [T1] while shipped under
  with_quic/with_xhttp; ShadowTLS is generate+registry only, no parser.
* `direct (flow-offload on)` -- no offload/flowtable/flow_offloading anywhere
  in openwrt/, shater/ or panel/src. The product does not do this.
* shater-core deps were two releases stale in two places, one of which vouched
  for a config.buildinfo check that never covered kmod-tun. Ruling narrowed to
  what was actually checked.
* PORTING's "Full schema" -- the shipped config points at it -- was missing
  l3_tunnel and untunnelable_egress (UCI is their only path; the panel does not
  show them) and the blocklist/allowlist/device/alert sections, while listing a
  `config preset` that ReadUCI has no branch for.
* ARCHITECTURE had no L3 ingress and no kernel egress at all, though both are
  [MVP] and one creates an fw4 zone in the user's firewall config. New 3a.
* nftset-for-routing in the DNS diagram: that is the v0.1 mechanism, gone in v0.2.
* CONTEXT described a pre-Phase-1 repo and a 24.10.3 testbed. The testbed is
  ImmortalWrt 25.12.1 r37978-cd0a06bfd3fd (read off the box), which is not a
  detail: .apk does not install on 24.10 at all.
* The gate existed and no .md mentioned it. README/README.en/CONTEXT now do.
* release.yml's header still described publishing as either/or after the rolling
  pointer became unconditional. Comment only.
* Shipped /etc/config/shater: schema_version '1' against CurrentSchemaVersion=2;
  a pointer to a dns_filter line that was not in the globals block (added, '0');
  and `option sniff '1'` on the inbound -- an option the model deliberately does
  not have, which the first panel save would have silently washed out.
* lx-changelog pointed at a D25 heading that does not exist.
* ROADMAP 2b and 5 were done and unmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:35:06 +03:00
omarandClaude Opus 5 9e6dda22b3 fix(http3,doh3): stop releasing what is still being read
Two suspicions, both put to a test rather than to a reading. Both were real, and
neither was the leak the suspicion named — both are objects released while still
in use.

roundTripHTTP3Race ran both racers on one cancellable context and cancelled it
before returning the WINNER. quic-go and net/http reset a request's stream when
its context dies, so the caller got a response whose body stopped mid-read:
H3_REQUEST_CANCELLED (local) (read 2687 of 65536 bytes). That path is taken
whenever there is no cached HTTP/3 connection and the request is replayable —
the first request to every host, and every one after an idle close. Each racer
now has a context of its own; losers are cancelled where everything used to be,
and the winner's cancel travels with its body.

DoH3's Exchange packed the query into a POOLED buffer and released it the moment
RoundTrip returned. But http3 writes the request body on a goroutine of its own
and returns as soon as the response HEADERS arrive — the body is still being
read. With the window held open the query on the wire diverges from the query we
packed at exactly offset 8192, quic-go's copy-buffer size: everything past that
was the next pool user's memory, sent to the resolver. Not a slowdown — a data
race and a small memory-disclosure primitive. The buffer now goes back when the
transport closes the body, which http3 does on every path, and can do twice.

Both files diverge from upstream again, hours after 0a6689b29 made them
byte-identical on purpose. Upstream carries the second defect in
dns/transport/https.go too; that file is outside this audit and is named in D27
so the next person finds it instead of rediscovering it.

sing-quic moves v0.6.2-0.20260525051024 -> v0.6.4-0.20260709034545. quic.go is
byte-identical across the two, so this neither duplicates nor retires the
packet-conn ownership fix — quic-go still does not own the socket. What it does
carry is the other half of the family we took only half of: clientConn.Close in
tuic/, hysteria/ and hysteria2/ now sets a past write deadline, word for word
the fix v2rayquic already had. We ship tuic and hysteria2. Cost, measured:
+256 KiB exactly on the stripped aarch64 binary and six indirect modules for a
realm port-mapping path nothing we generate can reach.

Tests are mutation-checked: reverting each fix makes them fail, with the text
quoted above. The DoH3 test carries its own control — it first proves the pool
does hand a released buffer back and that poisoning it lands, because a clean
result from an instrument that cannot produce a dirty one proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:26:03 +03:00
omarandClaude Opus 5 fde4bed571 fix(luci): stop calling the daemon dead when only the engine is
`running` changed meaning on 2026-07-26 (a8970b8ac): it was a hardcoded
true and is now the ENGINE's liveness (apply.go `Running: engineUp`).
dashboard.js was last touched on 15 July and stayed in the old epoch, so
a dead engine made the page report "Daemon (shaterd): not running" in
red, advise "start the Shater service first" — the service was running —
and DISABLE the button to the panel, which is the one place the config
can be fixed. The holding plane keeps management reachable on purpose
(netplane/nft.go: "The operator can always get in to fix the config");
LuCI was the only thing taking that guarantee away.

Daemon liveness is now derived from the wire, not from `running`. "The
ubus call returned" is not enough either: `shaterd status` EXITS 0 WITH
A FABRICATED STATUS when the daemon is unreachable (cmdStatus offline
stub), and that stub is the apply.Status zero value plus a UCI read — so
it carries enabled/table/kill_switch but leaves `plane` at "", a value
no live daemon emits. A known plane word is the positive proof a daemon
answered; an explicit empty one is proof none did. Everything else —
{} from a failed call, {"error":...} from the plugin (also what a live
but WEDGED daemon produces), a pre-`plane` daemon — is unknown, and
unknown is an unlit lamp, never green. The launcher button is never
disabled again: a mint that fails already reports itself.

"Interception: active" is gone. apply.go says of `active`, verbatim:
"Never render it as 'we are proxying'" — it is the run latch that gates
hotplug and cron, it stays raised while the engine is down and the LAN
is blocked, and this page painted it green next to two more green lamps
in exactly that state. It is now "Service latch", and its lamp reports
only whether the latch agrees with globals.enabled. The row that was
missing is `plane`: full / hold (LAN->WAN BLOCKED) / none. `traffic` is
shown too, because plane=full is not "tunnelled" — a `default -> direct`
router has a full plane and no tunnel at all.

The rpcd plugin's status docstring listed five fields of fourteen and
had done since before half of them existed; it now describes the real
shape and the two fields that are easy to misread.

tests/status-readout.test.js runs the derivation against six recorded
status shapes with no browser and no router. Mutation-checked: reverting
to `st.running` fails 14 assertions including the operator-visible
"not responding - start the Shater service" over a live daemon;
restoring the "Interception: active" row fails 9; putting
openBtn.disabled back fails 1 by name; opening the closed plane list
fails 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:22:56 +03:00
omarandClaude Opus 5 cb26936ebf fix(wgdedup): merge identical WireGuard copies instead of blocking one
A rule pointing at node:awgout, which was already the first hop of the
default-route chain, took the house off the internet for two minutes.
The pass saw one private key materialised twice, kept the copy that
sorted first alphabetically, and fail-closed everything that routed
through the other one — which happened to be the default route for all
traffic.

The mechanism was right and the framing was wrong. The physical limit is
one DEVICE per key, not one mention per key. Two copies that build the
same device — same key, same peers, same address/MTU/AWG parameters and
the same dialer — are one device written down twice, and there is nothing
for them to fight over. Those are now MERGED: one survives and every
reference to the others is rewritten to it, silently. That makes the
shape the owner wanted expressible: one chain using awgout as an
intermediate hop and another using it as a terminal, both entering over
the same egress, coexisting on one device.

Identity is the marshalled options blob rather than a hand-picked field
list, so a field added to WireGuardEndpointOptions or DialerOptions later
reads as "different" instead of being silently merged.

Only a real incompatibility — different detour, different peers,
different device parameters — is still two devices, and then:

  - the survivor is chosen by WEIGHT, not by tag order: reachability from
    route.Final (the default route) dominates, breadth of use breaks
    ties, tag order only settles a true tie;
  - the warning names the consequence. "Everything that routed through X
    is fail-closed" is equally true of a stray test rule and of the whole
    house's default route, and that is what the operator read it as. It
    now says which of the three it is, measured on the finished config:
    the default route is dead, or it survives via another path, or it
    never touched the lost copy.

A merge must not rename away the subscription fetch detour: that
reference lives in the model and is resolved against the running box, so
this pass cannot rewrite it. Such tags win the survivor slot outright,
which costs nothing since every copy in a class is the same device.

Tests: identical copies coexist on one device; a real incompatibility
keeps the default-route copy even when it sorts last and says so; the
warning does not announce an outage when the default route survives
through a group, and does announce one when it dead-ends behind a
surviving exit; no duplication at all is a no-op. All seven mutations
(merge off, weight off, member-dedup off, pin off, detour-following off,
consequence collapsed, plus a positive control) fail the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:16:12 +03:00
omarandClaude Opus 5 8db29b6267 fix(apply): say when shaterd apply armed no safety net
`shaterd apply` exists for one reason: snapshot the last-good, apply, and arm
an automatic rollback so a change that costs you access to the router undoes
itself. It answered `{"changed":false}` and not one word about that.

On the live router (2026-07-26) that was a trap. The operator edited UCI, ran
`uci commit`, the `config.change` reload trigger had already restarted the
daemon, and the fresh daemon applied the new config on startup. By the time
`apply` ran there was nothing left to apply — and the last-good it snapshotted
as the ROLLBACK TARGET was the newly applied config itself. The watcher was
armed onto the very configuration it was meant to protect against: firing it
would have restored exactly what was already loaded. No safety net, no word
said, house offline.

The verb now answers the question it exists to answer, in a closed vocabulary:

  rollback_armed  true ONLY when a window was armed AND its target differs
                  from what is running. An armed watcher pointing at the
                  running config is not a net and is not reported as one.
  reason          applied | already-applied | nothing-to-apply | disabled |
                  commit-confirm-off | config-unreadable | apply-failed
  message         the same thing in the operator's words, never empty.

The two "nothing moved" cases are told apart where they CAN be: an
/etc/config/shater mtime later than this daemon's start, with the running
config already matching it, can only mean a reconcile beat this command to it
(reason=already-applied). Where they cannot — the `uci commit` reload trigger
is stop+start, so it moves the daemon's start past the edit — the text says
so instead of reading as success: no net, harmless if you changed nothing,
unprotected if you did, and shaterd cannot tell which.

Two silent holes surface as a side effect, both previously reported as plain
success: `confirm_timeout=0` (the SHIPPED DEFAULT in
openwrt/shater-core/files/etc/config/shater) makes ArmRollback a no-op, and a
failed post-apply ReadUCI skips the arming entirely.

Arming behaviour is byte-for-byte unchanged — this only makes its absence
visible. A real safeguard for the already-applied case is separate work.

Tests are mutation-verified three ways: reverting classifyApply to the old
{changed,error} fails 11 tests; blinding the mtime discriminator fails exactly
the discriminating one (and falls back to the honest ambiguous text); making
sameConfig always report "different" fails every invariant that forbids
claiming a net over an identical target.

NOT verified on hardware: local_openwrt was held by another agent, so the
control-socket round trip and the real mtime/daemon-start comparison have not
been exercised on a router.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:12:38 +03:00
omarandClaude Opus 5 564033cd10 fix(apply): chain: as a subscription fetch detour resolved to a name nothing answers to
`fetch_detour=chain:<X>` never worked. engine.ViaToTag maps "chain:X" to the
bare tag "X", but the generator materialises a chain as one wrapper per hop —
chain-<X>-h1..chain-<X>-hN — and routes into the LAST one. The lookup missed and
the update failed with "unknown outbound tag".

It failed CLOSED, so the feed was never pulled over the plain WAN by this path.
But the miss had a sharp edge: when a node or group happened to share the
chain's name, the lookup HIT it, and the subscription was fetched through a
completely different outbound with nothing said.

Applier.HTTPClient now resolves chain: before the engine sees it, against the
tags the RUNNING box actually holds (outbounds unioned with endpoints — a WG hop
is an endpoint and Outbounds() does not list those), mirroring the generator:
the highest-indexed chain-<X>-h<i> wrapper is the entry, and a chain that
flattens to one hop IS that hop. Every other via form is passed through
untouched.

The case the generator cannot serve is named rather than papered over: chains
are built lazily, only for a chain some enabled rule/egress/DNS detour targets,
and a fetch detour is not one of those references — so a chain nothing else
points at has no outbounds at all. That, and every other miss, is an explicit
refusal wrapping engine.ErrOutboundUnknown (the panel already maps it to 400).
Never a fall back to direct: that would put the feed and the owner's real
address on the plain WAN, which is the thing fetch_via=proxy is set to avoid.

Tests are mutation-checked. Pre-fix behaviour resolves "work"/"solo" and kills
every chain case; first-hop-instead-of-last, member-copies-count-as-hops,
dropped pass-through, and a silent direct fallback each kill their own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:09:35 +03:00
omarandClaude Opus 5 add90b5b2f fix(panel): the DNS footnote was a grid item nobody placed
`.dns-filter-note` under the endpoint-resolver readout is a DIRECT child of
`.dns-filter-card`, so it is a grid item. With no explicit span it auto-placed
into column 1 — the toggle's `auto` track — and sized that track to its own
max-content: 237px at 390px, 322px at 1280px. That left the `1fr` copy column
with 0px, so "Network-wide ad & tracker blocking" laid out one word per line
and spilled 2px past the viewport, scrolling the whole page sideways on a
phone. On desktop the same cause parked the 52px toggle in a 322px column,
270px away from the copy it labels.

Measured at 390px: documentElement.scrollWidth 377 vs clientWidth 375. With
`grid-column: 1 / -1` on the footnote: 375/375, and the track list goes from
`237px 0px` to `52px 185px`. Cancelling just that one declaration in the live
DOM puts 377/375 and `237px 0px` straight back, so nothing else contributes.

Verified with playwright over 320/360/375/390/414/430/480/560/640/720/768/
1024/1280/1440: zero horizontal overflow at every width, with every rule
editor open, all three master toggles flipped, every source tab, and every
resolver type. No `overflow-x: hidden` anywhere — the page does not scroll
sideways because nothing overflows, not because the symptom is hidden.
Focus rings and prefers-reduced-motion re-checked and unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:06:30 +03:00
omarandClaude Opus 5 a571bd0e1a docs(claude): model is the executor's call, skills are mandatory, standards that earned their place
The old file pinned every subagent to fable — which broke the moment that
quota ran out mid-session — and spent half its length on panel scaffolding
that has been done for weeks. It said nothing about the test gate, the
testbed, or the hardware router, so none of that reached a subagent unless
it was retyped by hand into the brief.

What is new is not advice, it is the list of things whose absence cost a
day each: a test must be mutation-checked or it is decoration; an
instrument with no control proves nothing; a subagent must be told it may
refute the orchestrator, because the best results this project has had
arrived exactly that way; a formally-true sentence that reads as "it works"
is still a lie.

Skills are now a table mapping this project's areas to the skills that
cover them, with the rule that they are invoked BEFORE the work rather
than after something failed to run, and that every brief must name them —
a subagent cannot see this conversation and will not guess they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 22:48:05 +03:00
omarandClaude Opus 5 1267d20fb8 docs: drop the L3 handoff note — it is merged, and it said to
test / go + panel tests (push) Successful in 8m33s
release / test gate (push) Successful in 8m8s
release / apk aarch64_cortex-a53 (push) Successful in 6m33s
release / apk x86_64 (push) Successful in 3m45s
release / release apk (push) Successful in 8s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 20:00:46 +03:00
omarandClaude Opus 5 35f697ed08 docs(openwrt): say why mtu_fix is inert instead of claiming an MTU we no longer set
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:52:39 +03:00
omarandClaude Opus 5 d0fb6befb1 fix(l3): the l3-in MTU is not a tunnel budget — 1420 was a forgery generator
shater-l3 was created at 1420, the WireGuard payload budget, copied one
layer too far out. It bought nothing: what actually goes into the tunnel
is sized by sing-tun's forwardToPort against Port.PortMTU(), which
already fragments to the outbound MTU without DF and answers a
well-formed `fragmentation needed` quoting it with DF. All 1420 did was
make the KERNEL split every packet above 1392 bytes of payload on its
way into the device -- and a fragment is the one thing sing-tun will not
judge. Dispatch returns on parsed.fragment before calling JudgeFlow, the
fragments reach the gVisor stack, it reassembles them, and the ICMP
forwarder's installFlow demands an unspecified port address that a
WireGuard endpoint never has. So it declined and answered the echo
itself. `ping -s 1392` honest, `ping -s 1393` a lie, and only for the
outbounds the feature exists for.

65535 rather than merely "large": no IP datagram can exceed it, so the
kernel cannot fragment at this device for any packet ever. Anything
smaller leaves a band open and re-opens the class. It is also sing-box's
own default TUN MTU on Linux.

Memory was measured, not argued. Three paired runs of the integration
test under -test.memprofilerate=1 allocate 5.41/5.48/5.47 MB at 65535
against 5.76/5.46/5.70 MB at 1420, and a -diff_base profile puts every
difference in netlink interface enumeration. Nothing in the read path
scales with the MTU: gVisor reads through fdbased.BufConfig, which
sing-tun pins to one 65535-byte view regardless. I predicted a ~1.8 MB
saving from GSO switching off above 49152 and was wrong -- protocol/tun
turns GSO back on at StartStateStart whenever a FlowOutbound exists, so
the GRO scaffolding is there at both values. The corrected reasoning is
in the constant's comment so the next reader does not redo the mistake.

The integration test now reads the MTU back off the real kernel device,
which is the assertion the value exists for: a kernel that clamped it
would restore the forgery without changing a generated byte.

D25's KNOWN HOLE block is replaced with what is genuinely left. Chiefly:
a big non-DF ping does not start WORKING, it starts failing HONESTLY --
classifyReturn declines fragments on the way back too, so the packet
really leaves, the far host really answers, and the reply is not NAT'd
home. And a client that fragments on the wire itself is still uncovered;
that is the nft carve-out's job, with a warning that conntrack defrag
may reassemble in prerouting and leave such a rule unable to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:50:08 +03:00
omarandClaude Opus 5 81c96019b5 fix(panel): let a routing rule say ICMP, instead of calling one broken
The Proto picker was a closed list of the two transports and the ten
sniffed L7 labels, and anything else drew "<value> — never matches".
The engine now routes ICMP by rule (Rule.Proto accepts icmp, icmpv4,
icmpv6), so a working ping rule was rendered as a dead one and could not
be created here at all — the operator had to hand-edit /etc/config/shater
and then watch the panel call the result broken.

Adds a third group, "Layer 3". All three spellings are offered: they are
not synonyms — icmpv4/icmpv6 pin the rule's ip_version — so hiding the
narrowing would both strand a capability outside the UI and silently
widen such a rule the first time someone edited it here.

The doc comment no longer claims the list IS generate/route.go's
sniffedProtocols; only the middle group is. ICMP goes to the emitted
rule's `network`, never to `protocol`, which is the whole reason it never
matched as a sniffed label.

An unknown value is still kept and offered as written, but the
never-matches flag is now judged on the lower-cased value, the way the
engine judges it — a hand-written `ICMP` is a live rule, not an inert one.

Verified: npm run build clean (tsc --noEmit + vite build); an icmp rule
added through the panel renders as a plain "PROTO icmp" chip; no
horizontal overflow at 360px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:26:07 +03:00
omarandClaude Opus 5 baed8ff8f2 fix(model): fwmark_base 0x7f routes the engine's own traffic into its own TUN
The panel offers fwmark_base and table_base as free hex fields under
"Advanced" and nothing has ever checked them. What makes that more than a
footgun is that the derived values are invisible from the number typed: the
L3 mark is base+0x80, so 0x7f lands it exactly on 0xff — the loop-guard mark
the engine stamps on its OWN traffic — and `ip rule fwmark 0xff lookup 8200`
then captures everything the engine sends and routes it into the engine's
TUN. The router loses the internet the moment l3_tunnel is switched on, for
a reason nothing on screen connects to a collapsed section. fwmark_base 0xff
had produced the same failure since long before the L3 offset existed.

table_base is worse and got the same treatment: its derived values can land
on the kernel's own table ids, and teardown does `ip route flush table <n>`.
It is count-sensitive (egress #i uses base+0x10+i), so the check takes the
egresses rather than living in ValidateGlobals.

Written as "derive every value this layout produces, then look for
duplicates and reserved ids" rather than as a blacklist, so a future offset
is covered by construction. The layout constants are duplicated from
netplane (the import only runs one way) and pinned by netplane's
TestMarkLayoutConstantsLockstep.

Warn-only, like every check in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:54 +03:00
omarandClaude Opus 5 f80fb4dd1b fix(netplane): give every mark-driven table a floor, and check the L3 pair
Two halves of the same omission.

1. A fwmark lookup that finds an empty table does not fail — it falls
   through to main. Every mark-driven table now gets an `unreachable
   default` at the maximum metric: it loses to any real default route while
   one exists, it has no device so the kernel never garbage-collects it, and
   it turns "lookup failed, try main" into "lookup succeeded: unreachable".
   The fallthrough stops depending on somebody reading a warning at the
   moment an interface goes down. Deliberately not gated on the kill-switch:
   that switch decides whether traffic may escape the tunnel, while an egress
   binding is a statement about WHICH UPLINK, and silently substituting a
   different one is not what "fail open" was meant to permit.

   RoutingPresent's "does this table have a default route" test is tightened
   in the same breath, or the floor would answer it and turn the safety net
   into a blindfold.

2. RoutingPresent had never heard of addL3Routing. This is the same defect
   its own comment describes as already caught twice ("a presence check must
   cover everything its Apply counterpart installs"), committed a third time
   — and its trigger needs no interface to go down: editing a node URI
   restarts the engine, the kernel destroys shater-l3 and takes `default dev
   shater-l3 table 8200` with it, the rendered nft text is unchanged, so the
   fast-path skipped ApplyRouting forever and LAN ping stayed dead until
   someone restarted the daemon.

TestRoutingPresentSeesL3Table, TestEgressTableGetsFailClosedFloor and
TestEveryStampedMarkIsRoutedAndVerified all fail on the code they replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:54 +03:00
omarandClaude Opus 5 b71b793681 fix(netplane): a mark says where a packet was sent, not where it went
The forward chain let untunnelable-egress traffic past the kill-switch on
the strength of its fwmark alone. `ip rule fwmark X lookup N` does not
deliver the packet to table N, it delivers the LOOKUP there — and a lookup
that finds nothing falls through to main. So when the egress interface goes
down and the kernel garbage-collects its default route, every non-TCP/UDP
packet from the LAN is still stamped, still accepted here (above the
fail-closed drop), and leaves out the plain WAN with the router's real
address. Nothing we render changes, so no apply runs and nothing notices.

Ordinary egress traffic never had this hole: the engine binds those sockets
to the device, and a dead device fails the socket. The untunnelable-egress
path is made of nothing but a mark, so the accept now carries the second
opinion instead — `meta mark X oifname "dev"`, strictly narrower than either
half, true only when the routing did what the mark asked. The comment being
replaced argued correctly that oifname ALONE would be too loose, then drew
from that the conclusion that oifname should be dropped rather than added.

Same conjunction in the holding plane, where it is theory (that plane stamps
nothing) but where a bare mark accept has no business sitting.

Also folds the egress device resolution into one EgressDevice(), because the
binding and model.ValidateUntunnelableEgress had already drifted: the
validator trimmed the interface name and the binding did not, so `option
interface '   '` gave a panel saying "the option is ignored" over a data
plane that was marking packets for a table nobody built.

TestUntunnelableEgressAcceptIsBoundToItsDevice and
TestUntunnelableEgressResolutionLockstep fail on the code they replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:32 +03:00
omarandClaude Opus 5 61c87ad1d9 fix(l3): guard the ICMP honest-drop at PreMatch, not inside the walk
The drop that keeps a ping from reading as tunnelled lived in
preMatchFlow, overriding the pre-declared continueResult. That covered
every exit of THAT function and none of the walk above it: the
prepareMatchMetadata error return (which arrived later, with the shared
metadata refactor), the sniff bail-outs, and the default: arm of the
rule-action switch all returned PreMatchContinue on their own.
adapter.JudgeFlow maps Continue to tun.ActionAccept, and sing-tun answers
Accept by rewriting Echo into EchoReply itself -- the exact forgery this
delta exists to remove. Narrow paths, but paths.

PreMatch is now a funnel over the renamed preMatch walk, so the guard
sits on the single return value and cannot be outgrown by a new exit.
PreMatchBypass joins the drop: sing-tun implements ActionBypass on the
nfqueue plane only, so on the TUN path it lands in the same default: arm
as Accept and forges too.

Every ICMP case has an explicit TCP/UDP twin; the JudgeFlow mapping
table is pinned outright, including the one fix that must NOT be made
there -- refusing ActionFlow for a port whose address is not unspecified
would drop every ping through WireGuard/AWG, because the forward
dispatcher and the ICMP forwarder share that function with identical
arguments and only the latter needs an unspecified address.

That leaves a real hole open, now named in D25 rather than papered over:
a FRAGMENTED echo to a WireGuard/AWG outbound is still answered by the
router. The dispatcher returns before asking for a verdict at all when
the packet is a fragment, and the reassembled packet reaches the ICMP
forwarder, whose installFlow demands the unspecified address a WireGuard
endpoint never has. The two fixes that would close it both live outside
pre-match and are written down; the Consequence paragraph is scoped
until one lands.

The stack comment in generate/inbound.go repeated the "only gvisor
really forwards ICMP" argument that D25 itself retracts -- both stacks
run the same ForwardDispatcher first. Brought in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:21:29 +03:00
omar 4dee508e12 fix(route): let a rule say "icmp", and say when saying it is a lie
`icmp` fell through ruleMatchers' proto switch into RawDefaultRule.Protocol —
the SNIFFED-L7 field, compared against what the sniffers labelled a connection.
Nothing ever labels a flow "icmp" (PreMatch skips the sniff action for an ICMP
flow outright), so the rule was structurally valid and permanently dead. That
made the whole L3 ingress unusable on a real config: with no way to write "ICMP
goes here", every ping fell to the catch-all, which resolves to the chain's last
hop — a group of VLESS nodes that cannot carry layer 3 at all.

icmp is a NETWORK. NetworkItem.Match is a map lookup over metadata.Network, and
adapter.JudgeFlow sets that to N.NetworkICMP for BOTH ICMPv4 and ICMPv6 (one
case covers both protocol numbers), so there is exactly one network value and it
covers both families. `icmpv4`/`icmpv6` narrow that same network with an
ip_version item instead of inventing a second one: metadata.IPVersion comes from
the destination address, and an ICMPv6 packet always has an IPv6 destination —
no false positives, no false negatives.

An ICMP rule that cannot fire is not a dead setting: ICMP has no fall-through,
so route.preMatchFlow DROPS it. Four ways to get that silently are now reported:
l3_tunnel off (nothing enters the engine at all), icmpv6 with ipv6 off (neither
the nft mark nor the TUN address exists), a port matcher next to it (JudgeFlow
zeroes both ports), and a target that cannot carry layer 3 — decidable from the
model, because the capability is fixed by the outbound TYPE: only wireguard/AWG
endpoints and the direct outbound behind direct/interface egresses declare
N.NetworkICMP. A mixed group gets its own text (the answer follows group.Now()),
`block` gets none (dropping the ping IS the policy), and an unresolved target
gets none either (ruleKillFallback already said the louder thing).

Wording stays clear of shater/apply's criticalMarkers on purpose: a failed ping
is fail-CLOSED, and a cosmetic alarm is how the real one stops being read.
2026-07-26 19:18:06 +03:00
omarandClaude Opus 5 76da5134ef test(gate): the two tests that need a kernel may not skip in silence
The L3 branch adds TestIntegrationL3TunInboundStarts and
TestIntegrationL3EgressICMPIsAFlow — the only tests that prove the engine
really opens shater-l3 and that the egress outbound really is a FlowOutbound.
Both need root plus /dev/net/tun, both guard themselves with t.Skip, and the
gate could not see either: `go test` prints `ok <pkg>` whether a test ran or
skipped, so [2/5]'s per-package `ok` check is satisfied and the gate closes by
claiming it "passes every test we own". That is this script's own founding
failure (115 of 116 test files never running while CI stayed green) one level
down, and it would have shipped invisibly.

Two halves.

Where the capability CAN be granted, grant it. From a non-linux host the gate
re-execs into a container; that container now gets --cap-add NET_ADMIN and
--device /dev/net/tun, probed rather than assumed, so a plain
`scripts/run-tests.sh` on a dev box actually exercises the kernel path instead
of quietly stepping over it.

Where it cannot, say so where it cannot be missed. The act_runner is an LXC
guest whose kernel has no tun module at all (checked on 10.10.10.211:
`modprobe tun` -> "Module tun not found", /dev/net does not exist, act_runner
runs job containers with privileged:false and no container.options), so the
device cannot be handed down without reconfiguring the Proxmox host. New step
[5/5] therefore DISCOVERS every ^TestIntegration under the fork's trees — no
hand-kept list, so a privileged test written next month joins on the day it is
named — runs them with -v, and demands a verdict for each BY NAME: RAN, or
FAILED/MISSING (fatal), or SKIPPED while the environment could have run it
(fatal, because the capability guard cannot be what skipped it), or skipped for
a reason this box genuinely has — which replaces the closing banner, so the
last line of the gate can never claim coverage it does not have.
SHATER_REQUIRE_PRIVILEGED=1 makes that last case fatal for runs that can.

The discovery call carries -ldflags for the same reason every other call does:
`go test -list` links each test binary, and without -checklinkname=0 every
package pulling common/badtls fails to link. The first cut of this step omitted
it, swallowed the error, and printed "none declared" — a check against silent
skipping that was itself silently skipping. Its exit status is now inspected
and an empty list is only ever reported after a successful enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 18:50:46 +03:00
omar 4c630c9a13 docs: handoff note for the L3 branch
Transient, to be deleted when omp/work merges. Everything meant to outlive the
merge is already in D25/D26 and the lx changelog; this file is the part that is
only useful while the branch is still a branch — the verification commands, the
testbed recipe, what was proven on hardware and what was not, and the six files
that will conflict on rebase.
2026-07-26 18:31:59 +03:00
omar d8dbefcd07 docs: record the AWG site-to-site path as declined, not impossible
D26's "no port-like selector" line disposes of NAT-based forwarding and nothing
else, and read alone it says "impossible" — which is false and would be
re-derived at the cost of another research pass. The endpoint is protocol-blind
in both directions, so ESP could ride it untouched with the client's own source
address and no NAT whatsoever. That was declined for two reasons worth naming:
lx-owned code in the forward hot path, and a server-side AllowedIPs prerequisite
that turns a router option into a deployment contract.
2026-07-26 18:31:59 +03:00
omar 974208fc05 docs: record the kernel egress, and retract the reason D25 gave for the ceiling
D26 writes down where the engine's boundary actually is, because the intuitive
answer is wrong and someone will look for it again: the WG/AWG forward path
never consults gVisor in either direction, so the limit is sing-tun's
ForwardDispatcher — its parser and its port-shaped NAT — and the kernel egress
was chosen because it clears that limit without a line of new hot-path code, not
because userspace "cannot". Tailscale documents the same boundary for their
userspace mode and is quoted as corroboration, with the caveat that ours sits at
the dispatcher rather than the stack.

D25 said two things that do not survive checking, and both are corrected in
place rather than left for the next reader to trip over. It blamed the netstack
for the ICMP-echo ceiling; that was the dispatcher. And it called `stack: gvisor`
mandatory because the system stack fakes ping — the system stack runs the very
same dispatcher first and only forges an echo for packets the dispatcher
declined, so gvisor is a deliberate choice (already linked via with_wireguard,
and the combination the integration test exercises), not a necessity.

The operator note says what the option buys and refuses to call an egress a
tunnel on its own say-so: with a WireGuard device it is one, with a second WAN
the destination sees that uplink's address. It also says what the option does
not fix — multicast IPTV stays broken — and that IPsec through NAT-T is ordinary
UDP that never needed any of this.
2026-07-26 18:31:59 +03:00
omar 2eb71e8244 feat(netplane,model): hand the protocols the engine will not dispatch to the kernel
ESP, AH, GRE, IGMP and SCTP cannot enter the engine, and the reason is not the
one that looks obvious. A WireGuard or AmneziaWG endpoint forwards straight past
its gVisor stack — WritePackets reads the IP version and the destination address
and hands the raw bytes to the device, and the return path offers every
decrypted packet back before the stack sees it. WireGuard would carry ESP today
if anything handed it one. What refuses is sing-tun's ForwardDispatcher: its
parser recognises TCP, UDP and ICMP echo, and its NAT wants a port-shaped
selector that ESP, AH and GRE do not have. The retracted rationale is corrected
where it was written down, not quietly dropped.

So these protocols go to the kernel instead. untunnelable_egress names an
interface or tunnel egress; prerouting stamps that egress's OWN mark on
everything that is not TCP or UDP, and addEgressRouting has already bound that
mark to a table whose default route leaves via the device. Every protocol works
because nothing in the path has to understand any of them. No new mark, no new
table, no new code in the hot path.

Whether that is a tunnel depends on the device, and nothing here claims
otherwise: a WireGuard interface is one, a second WAN is a different uplink
whose real address the far end sees.

The wide `!= { tcp, udp }` filter is safe here and stays banned for the L3
ingress, for the same reason stated in both places: there the receiver is a
dispatcher that knows four protocols, here it is the kernel. ICMP is claimed by
the L3 ingress first when both are on. The local plane keeps its exclusions —
router-addressed traffic, private destinations, ICMPv6 ND/RA — and with IPv6 off
the marking is scoped to v4, because addEgressRouting installs no v6 rule then
and a marked v6 packet would fall into the main table.

An interface egress with an empty `interface` no longer resolves: IfaceDevice
defaults to br-lan, so it passed the binding while addEgressRouting skipped it —
mark set, no rule, straight past a closed kill switch and out the default WAN.
2026-07-26 18:31:59 +03:00
omar 668cccbf24 test(generate): the L3 device name is a singleton, so wait for the kernel to take it back
Both gated tests stand an engine up on shater-l3. Run together, the second met
`TUNSETIFF: device or resource busy` and failed for a reason that had nothing to
do with what it asserts — the first had closed its box and yielded while
unregister_netdevice was still catching up. Each passed alone, which is the
shape of a fixture bug that gets rediscovered rather than fixed.

The poll that already guarded the first test is now a shared helper both call.
It stays a poll rather than a sleep for the reason it always was: the removal is
usually immediate and a fixed wait would be either flaky or slow.
2026-07-26 18:31:59 +03:00
omar 4ea4585402 test(generate): pin that ping through an interface egress is real, and byedpi's is not
An interface egress is a direct outbound carrying BindInterface and a routing
mark, and direct builds its ICMP port from the very same dialer control — so
ping routed at that egress leaves through that device, marked, like every other
packet bound to it. Nothing said so. Both halves of that sentence are one
`common.Cast[*dialer.DefaultDialer]` away from being false: if the dialer ever
stops being a DefaultDialer, icmpPort is nil, PreMatchFlow declines, and ping
through the egress degrades to a drop without a single generated byte changing.
The gated test asserts the live outbound, not the config, because that is where
the cast happens.

The failure the codegen half guards is worse than a broken ping: losing
BindInterface or the mark does not stop the echo, it sends it out the main table
over the plain WAN with the real address, which is the one thing an egress
exists to prevent.

byedpi is a SOCKS outbound and cannot be a tun.Port, so ICMP aimed at it is
dropped. That is the honest end of l3-honest-drop and it is pinned too, because
the alternative the TUN stack offers is a forged reply.
2026-07-26 18:31:59 +03:00
omar dc6d102473 docs: put a number on the second netstack, and say what it does not bound
Measured on a throwaway harness in a container: peak RSS of a process that
brought the engine up went from ~26 MB to ~28 MB with l3_tunnel on, three
paired runs. It is x86_64, idle, with an empty ICMP NAT table, so it stays
listed as unverified for the router — an indicative figure is more useful than
silence only if it says loudly what it is not.
2026-07-26 18:31:58 +03:00
omar 683afc0a47 docs: record how ping got through the tunnel, and where it stops
D25 writes down the reasoning that is expensive to reconstruct: why a TUN rather
than TPROXY, why the interface is its own with auto_route off, why gvisor is
mandatory rather than preferred, and why the ceiling is ICMP echo — a boundary
in sing-tun's flow parser and gVisor's protocol set, not an unfinished edge of
ours. It also records what carries layer 3 and what does not, that masque could
and does not, and the two things still unproven: the live-router path end to
end, and what a second gVisor NIC costs in memory on the hardware.

D17 gains one line: its claim that TPROXY cannot carry ICMP is still true, and
is no longer the end of the story.
2026-07-26 18:31:58 +03:00
omar 2c3e20512e feat(openwrt): let fw4 know the L3 tunnel device before it exists
Both nft tables run and a drop in either one wins, so our forward accept for
shater-l3 decides nothing on its own: fw4 sees a device in no zone and drops the
forward, and the feature fails with exactly the symptom it was built to fix —
ping does not work, and nothing says why.

The zone names the device directly rather than a network. fw4 resolves a zone's
networks through netifd, and a proto-none interface for a device the daemon
creates is never up and contributes nothing, so list network would compile to an
empty device set. list device compiles to a plain iifname/oifname match that is
valid before the TUN exists and starts matching the moment shaterd creates it,
with no firewall reload at enable time.

It is seeded unconditionally, not gated on l3_tunnel: uci-defaults run once, and
a zone naming an absent device is inert. Gating it would mean the option could
be switched on and never take effect. The sections are named so a re-run is a
no-op instead of a second zone, and kmod-tun joins DEPENDS because /dev/net/tun
is not on a stock image.
2026-07-26 18:31:58 +03:00
omar 51b2f04672 feat(netplane,generate): carry LAN ping through the tunnel, on a TUN of its own
Kernel TPROXY needs a socket to hand a packet to, so it moves TCP and UDP and
nothing else. Everything else reached the forward chain and met the untunnelable
policy, whose best answer was "let it out with your real address" and whose
default was "drop it" — so on a stock install ping simply did not work, and the
setting that fixed it did so by leaking.

The engine has been able to do better for a while: sing-tun's ForwardDispatcher
does real ICMP forwarding with NAT on the echo id, and a WireGuard or AmneziaWG
endpoint is a tun.Port that carries the packet for real. What was missing was a
way in, because nothing on the router could hand it an IP packet.

l3_tunnel (opt-in, off by default) adds one: the generator emits an "l3-in" TUN
inbound and prerouting fwmarks LAN ICMP into it. The interface is its own and
auto_route is off, so the main routing table is never touched and the fwmark
plus addL3Routing's ip rule are the only entrance — the TPROXY plane is byte for
byte what it was. gvisor is not a preference: the system stack forges echo
replies locally, which is the very thing this is meant to end.

Only icmp and ipv6-icmp are ever marked, and only after the local plane is out
of the way — the router itself, private destinations, and ICMPv6 ND/RA, which
mean nothing off-link and take v6 down if one neighbour probe is tunnelled.
ESP, AH, GRE, IGMP and SCTP are deliberately left alone: sing-tun's parser and
gVisor's stack know no such protocol, so marking them would black-hole the
traffic while looking like a feature. They stay with the untunnelable policy,
which also keeps its say over what happens if the ip rule fails to install.

Ping and Windows tracert now cross the tunnel; IPv6 traceroute shows only the
destination, because the return path recognises TimeExceeded for v4 alone.
2026-07-26 18:31:58 +03:00
omar f190c8251e feat(lx): stop answering ping on behalf of a tunnel that never saw it
PreMatchContinue is not "fall back to the ordinary route" the way it is for TCP
and UDP. An ICMP flow has no ordinary route: the TUN stack takes the packet back
and answers the echo itself, swapping the addresses and writing a reply
(sing-tun stack_gvisor_icmp.go). So a ping routed to any outbound that cannot
carry layer 3 — every proxy protocol; only adapter.FlowOutbound can — came back
successful, and the operator read a working tunnel off a packet that was never
sent.

That is worse than the packet loss it replaced. Loss is a fault the operator can
see and chase; a forged reply is a fault that reports itself as health, and it
reports it on the one tool anyone reaches for first.

preMatchFlow now overrides continueResult once, at the top, for
N.NetworkICMP. One hunk covers every exit that used to fall through — no such
outbound, a group whose selection is gone, an outbound whose Network() omits
icmp, an outbound that is not a FlowOutbound — and keeps the diff to three lines
against a function upstream will keep editing. JudgeFlow carries the same
verdict in its !isPort branch, because FlowOutbound and tun.Port are separate
interfaces and drift between them must not reopen the forgery.

TCP and UDP are untouched, and the test pins that as hard as it pins the drop.
2026-07-26 18:31:58 +03:00
omarandClaude Opus 5 1945404eaa fix(armor): a reboot is not someone switching the product off
test / go + panel tests (push) Successful in 5m24s
release / test gate (push) Successful in 5m24s
release / apk aarch64_cortex-a53 (push) Successful in 3m9s
release / apk x86_64 (push) Successful in 3m9s
release / release apk (push) Successful in 8s
The boot armor never armed on the router it shipped to. procd runs the
K-links on the way down with the action `shutdown`, and stop_service
classified actions with an OPEN default:

    case $action in restart|reload) keep;; *) DISARM;; esac

`shutdown` matched nobody, fell into `*`, and deleted the arm token. The
mechanism erased itself at exactly the transition it exists for, so every
boot found nothing to load. Measured on the live router, one minute apart
across a reboot:

    13:28  /etc/shater/boot.nft present
    ----   reboot
    18s    at_S22: NO_TABLE  armor_file=NO_FILE

It did not fail every time, which is worse than failing always: on the way
down `rm` from this script raced a `SaveBootArmor` driven by the ifdown
hotplug storm, and whichever landed second won. Two reboots on the same box
an hour apart gave opposite outcomes.

Both lists are now positive and CLOSED. Only `stop` disarms; only
`restart`/`reload` hand off. An action nobody thought of changes nothing,
so the default now fails toward a boot that arms when it need not have --
recoverable in the second before the daemon applies, and still gated by
shater-armor's four state refusals. The old default failed toward the
plaintext window the feature was built to close.

Also closed, found while proving the above:

  * Every restart left the LAN in the clear for 80-90ms. The exit path was
    `Teardown(); armOnExit()`, and TeardownNft DELETES the table -- two nft
    transactions with no `inet shater` between them, leaving fw4's
    `lan -> wan ACCEPT` as the only policy. Every restart, every LuCI Save
    & Apply. TeardownExiting arms first under the apply lock and skips the
    delete iff a plane actually went in; RenderHoldNft is one `nft -f` that
    REPLACES the table, so the kernel never observes its absence.
    35k-sample instrument: 7 and 6 no-table hits before, 0 across three
    runs after.

  * SaveBootArmor fsynced the payload but not the directory, so a power cut
    could lose the rename that publishes it -- a boot with no armor and no
    error anywhere.

`stop` now also reads rc.d state, so a package transaction that stops the
service is not mistaken for a person switching it off. This one does not
reproduce on apk (it runs no pre-upgrade script and never calls prerm on an
upgrade; verified with apk adbdump and 245k samples across a real reinstall)
-- it is one returning opkg lane away from being live, and the removal case
is now stated rather than implicit.

Both new tests are mutation-checked: reverting the predicate fails naming
`shutdown`; reverting the teardown fails with `did [arm delete], want [arm]`.
initscript_test.go sources the SHIPPED shell and calls the real predicates
with every action procd uses -- a comment claiming `shutdown` was handled is
what shipped last time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 17:33:53 +03:00
omarandClaude Opus 5 6476722372 fix(panel): stop shipping a fabricated router in the binary
test / go + panel tests (push) Successful in 5m26s
release / test gate (push) Successful in 5m28s
release / apk aarch64_cortex-a53 (push) Successful in 6m7s
release / apk x86_64 (push) Successful in 3m5s
release / release apk (push) Successful in 7s
mock.ts was a static import and the mock switch was read from the query string at
runtime, so the bundle that ships inside the daemon carried a complete fictional
router and a link ending in ?dev rendered it: protected, 119 of 122 nodes alive,
without a single request to the daemon. The only tell was a line in the footer.
That is worse than any wrong number — there is no data at all and nothing says
so. It is out of the production bundle now, which is 21 kB smaller for it.

Unknown state stopped reading as good news in two more places. The kill-switch
tile treated an absent plane as armed, because the check was "not none" and
undefined satisfies it — the contract in the API types says the opposite. And the
apply page announced "daemon auto-rolled back" from its own timer, while the
daemon, seeing the state generation move, disarms and says it is NOT rolling back
in the log only.

Alerts moved to Settings. They are about the kill switch, apply failures, new
devices and subscription expiry, and they lived at the bottom of the DNS page,
while Settings mentioned them in prose with nothing to click.

Findings truncation is visible now: the notice that says how many were suppressed
arrives as info, and the attention list keeps only critical and warning, so past
fifty findings the operator saw forty-nine and no hint of the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:39 +03:00
omarandClaude Opus 5 4078334d85 fix(stats,alert,panel): put a ceiling on everything that only grew
Four maps had no bound on a box with 512 MB that runs for months. The health
board only ever inserted — the delete exists but no path in this fork calls it —
and it lives on the engine context, so it outlives every generation. Its keys are
node tags, and providers rename nodes on each subscription refresh: about 440k
keys a year, some 88 MB. Alert dedup keyed on MAC with no delete at all. The
stats aggregator's server and outbound counters were the only ones with no cap,
no prune and no top-N, and one of them was handed to the panel whole on every
poll.

They are bounded now, evicting least-recently-seen, with numbers argued from this
box rather than round: the board holds 4096 against a live generation of about
1200 tags, so a rename day cannot evict a tag still in use. Nothing is dropped
silently — the same rule the log sink already follows — and a new Dropped section
in the snapshot reports all six bounded aggregates, including the three that had
been evicting without saying so.

Snapshot did O(devices × domains) under the aggregator lock, sorting five
thousand entries to show fifteen, and could read the DHCP lease file from inside
it. Meanwhile the event subscribers have 64-slot buffers that drop without a
counter, so an open Overview page cost the query log real rows. Selection is
top-K now — proven byte-identical to the old sort over 200 random trials — and
both the lease read and the row ordering happen outside the lock.

The panel server had one timeout, on headers. An unauthenticated client could
hold a goroutine, a socket and a descriptor forever by sending its body one byte
at a time; a stopped reader on the log stream held the handler, the pipe and a
child process that outlived the request. Every phase is bounded now, with the
unauthenticated route on a tighter budget than the rest, and the log stream
renewing its deadline per chunk so a slow-but-reading client is never truncated.

And the last of the detour transports: each call built a fresh one, and the alert
delivery path dropped it, pinning keep-alive sessions through the engine's own
outbounds for 90 seconds — eighteen times the budget a retiring generation gets.

The race skip is gone from the gate. The test it existed for raced in its own
clock, not in the product; that is fixed, so nothing is excluded under -race any
more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:21 +03:00
omarandClaude Opus 5 0a6689b29e fix(quic,v2ray): close the sockets quic-go was never going to close
DialEarly with a packet conn the caller made sets a flag that means quic-go does
not own it: closing the transport only stops reading from the socket. Neither DNS
transport closed it. On the QUIC one it was closed on a failed handshake and
never on success, so every redial — idle timeout, retry error, engine reload —
left a UDP socket for the life of the process. On the HTTP/3 one the library
drives its own reconnects, so the leak compounds without anything in our code
looking wrong.

That is the same shape as v2rayquic's, where offerNew overwrote the raw conn on
every reconnect without closing the previous one. Both are now owned by a watcher
tied to the connection's own context, so the socket lives exactly as long as the
connection does.

This matters more than it did last week: the shipped resolvers are DoH, and DNS
is intercepted by default now, so the whole network's query stream rides this
path on a router with 512 MB.

The same upstream commit fixes both halves. We had taken the v2ray half and not
the DNS one — the third time this session a paired fix arrived half-applied, and
the first of those cost a day of debugging. These two files are now byte-identical
to upstream so a rebase cannot reopen it.

Also from that family: websocket and httpupgrade leaked their conn on failed
handshakes, and a QUIC stream's Close did not release a blocked write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:59 +03:00
omarandClaude Opus 5 ef22167b1a fix(apply): report the hold when the plane was armed by someone else
Booting with the armor loaded, or restarting through the handoff, left the status
saying the LAN was not being held while it was being dropped. Transient after a
successful apply, but permanent on the unreadable-config path — and there the
apply-failure alert words itself "traffic is NOT being blocked" at the exact
moment it is. That sends the operator to fix something that is not broken, past
the protection that is holding.

The table cannot be identified from here — netplane exposes no read-back and nft
does not keep comments — but identifying it is the wrong question. Holding does
not claim the holding plane is the object in the kernel; it claims the engine is
down and forwarded traffic is being dropped. A leftover full ruleset does that
too: with no engine socket the tproxy statement breaks its own rule before the
accept, so the packet reaches the forward chain unmarked and meets the primary
drop. What decides it is whether the last applied config was enabled and
fail-closed, which is exactly what the boot armor's presence already means.

So it is derived at read time rather than latched. A latch set from an inference
would have to be remembered in order to be cleared, which is the trap the active
flag already taught us. ArmHold also stops deferring to a table it cannot
inspect and installs its own render instead — the honest answer to "do not claim
a foreign table blindly" is to make it ours, and a fresh render beats a snapshot
that predates an interface rename.

Also closes the last of the detour transports: the subscription fetch took a
client and dropped it, and the exits that leak are the error ones, retried by
cron forever against a broken feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:39 +03:00
omarandClaude Opus 5 cbda0fee0a fix(netplane): arm the fail-closed plane before the daemon can
The plane only ever existed while the daemon did. It starts at 99, after fw4 has
already loaded lan→wan ACCEPT, and only reaches ArmHold after waiting out its
predecessor, migrating the schema, building the engine and reading UCI — with a
UPX-compressed binary decompressing off flash first. Every boot therefore had a
window with no protection at all, landing exactly when Wi-Fi comes up and every
client reconnects. A restart, a reload or a package upgrade opened the same
window on purpose: Teardown does not consult the kill switch, and the init script
guarantees the interval is non-empty.

The holding plane is now persisted to /etc/shater/boot.nft on every apply and
loaded by a small service at 21, right after fw4 and netifd. Its presence is the
arm token: it exists only while the last applied config was enabled AND
fail-closed, and goes away the moment either stops being true. Writes are
content-gated — the cron reconcile runs a minute — and atomic, because the one
boot that reads this file is the boot after a power cut.

The service refuses to arm four ways so it can never brick a box, and its
enabled-check reads /etc/rc.d directly rather than asking rc.common, which would
take a blocking flock in the middle of boot. On exit the daemon re-arms only for
restart and reload, read from a snapshot of rc.common's action; anything else,
including an unknown one, degrades to a real stop that also disarms.

An unreadable config used to leave the router bare forever: the arm call sat in
the branch that requires a successful read, and nothing downstream could recover
it. It now arms from the same path.

A network nobody named was neither diverted nor blocked — the divert set is built
from inbounds and rule sources, and the same set scopes the fail-closed drops. It
is now enumerated from the interfaces whose firewall zone the operator forwards
to a WAN zone — their own statement that those clients reach the internet through
this box — and reported critically, by name, with both resolutions. Deliberately
not closed automatically: this router cannot know a guest SSID was meant to be
off the tunnel, and guessing is an outage. A device name that resolved to nothing
is reported the same way, for the same reason: there is no fail-closed action
available for a device we cannot name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:16 +03:00
omarandClaude Opus 5 7234817adb fix(panel): flush the log before serving it
test / go + panel tests (push) Successful in 4m59s
release / test gate (push) Successful in 4m58s
release / apk aarch64_cortex-a53 (push) Successful in 3m5s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 7s
Splitting the log sink made its writes asynchronous, so a download could miss
the last lines still in the queue — silently, with a successful response. Those
are the lines the operator came for: a log is downloaded to find out what just
happened.

The panel is handed a barrier, not the sink: a func() set once at startup, the
same shape as the reconfigure hook and the stats setter already in the tree. It
cannot write, reconfigure or close, so it stays a consumer, and nothing about
the sink's type reaches it.

The wait is bounded at the sink's own control budget and enforced on the panel
side, so a wedged writer cannot turn the download into the new place the daemon
gets stuck — the very thing the async split was for. Past the bound the handler
serves what is on disk. With no barrier installed the path behaves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:42 +03:00
omarandClaude Opus 5 f1c36d6eea fix(panel): say when the engine is down, and ask before the irreversible
The header could not render "offline": it keyed on a field the daemon pinned to
true, so a dead engine behind a fail-closed plane showed a pulsing green lamp.
Health now needs both signals to agree before it reads as up, and a negative
from either is enough to say down — which is honest against the field that was
already honest, and stays honest now that the other one is too.

Deleting the last catch-all rule was described as "traffic will fall through to
the next rule" on the very row the page badges as the default route. What
happens instead is the kill switch: closed, the network loses the internet;
open, it leaves with the real address. The dialog now says which, by reading the
saved setting, and the toggle asks the same question — the generator only emits
enabled rules, so switching it off is the same event.

The master switch tore the whole plane down without a word, while deleting a
rule-set got a confirmation. Deleting a node or a resolver claimed to remove it
"from the config" without mentioning what still points at it, though the
reference finder was already there and used for renames.

Every Apply button armed the auto-rollback, and only one page said so. The
window is now recorded where all of them pass through, carried in a band under
the nav on every route, and persisted — so the countdown and the keep button
survive a reload, which is what made the window unconfirmable before. Overview's
Confirm button is gone rather than gated: Confirm cannot fail, so a permanently
live button could only ever report success.

Blocklists printed "filtering" from two config checkboxes without asking whether
the list had ever loaded — while the daemon grades a failed load critical. They
now show what the rule-set rows already showed, and say "not loaded — nothing
blocked" when that is the truth.

Also: the clock read UTC while every timestamp rendered in the browser's zone,
so the router appeared to have started in the future; the rule counter on
Overview counted saved rules rather than the ones in force, unlike the routing
page; and the hop badge counted the entry egress the rail below it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:28 +03:00
omarandClaude Opus 5 bb21ceb7f5 fix(lifecycle): a busy file is not a broken one, and three ways to lose state
Changing any stats knob on the persistent backend deleted months of history.
The replacement store was opened before the outgoing one was closed, so it hit
the first one's flock, timed out — and the open path treated ANY error as
corruption and unlinked the file. Unlink of an open file succeeds on Linux, so
the new ring opened an empty database while the panel was still told the
backend had not changed. The store now hands its resources over before asking
for them again, and deletion is gated on an allow-list of real corruption
signals; a busy, unreadable or read-only file degrades to the in-RAM ring and is
left alone.

The holder's reads were unguarded in a subtler way, caught only after the gate
failed twice: the accessor took the read lock, returned the pointer and released
it, so the call ran outside. A reader could hold a store the swap then closed and
be served its empty answer — an empty page presented as data. The accessor is
gone entirely, along with the possibility of handing out an unguarded reference.
Readers still do not block each other; the swap now waits out reads already in
flight, which is a page at most.

The urltest group published its chosen node through two plain fields written by
the prober and read on every dial and every panel poll — while the selector next
door does the same job atomically. They are one value now, so TCP and UDP can no
longer be read as a mismatched pair. Nothing had ever dialled through a group
while it was probing, which is why the detector had never seen it; a test now
does, and reproduces it deterministically against the old shape.

Close on a group whose ticker had already stopped returned before closing its
channel, and Touch would then arm a fresh loop nothing could stop. Reached by
pressing Test in the panel and applying a config within the next two minutes: the
orphan kept failing probes against a cancelled context and writing forged dead
verdicts into the board the live generation selects from. Close is now final.

The log sink held one mutex across a blocking write. Under procd stderr is a
pipe, so a reader that stopped draining wedged everything that logs — engine,
panel handlers, signal loop — while the process still answered a signal. It is
split: a front that assembles lines and a writer that owns the destinations,
joined by a bounded queue that drops and counts rather than blocking. Proven by
restoring the old shape: the package deadlocks for the full ten-minute timeout,
parked exactly where the field symptom said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:06 +03:00
omarandClaude Opus 5 a8970b8ace fix(apply): stop the status from reporting a state the daemon is not in
Running was the constant true. The panel builds its header from it, so the
"offline" branch was unreachable code: with the engine dead and the LAN behind
a fail-closed hold, the operator saw a pulsing green lamp and, on the page
people open to fix things, "engine: running". The honest field sat beside it,
documented as the honest answer to are-we-proxying, and was read nowhere.

running now means shater is running: the daemon answered and its engine has a
started instance. active stays what it always was and is documented as such —
the "meant to be running" latch that gates hotplug and cron, not a health
signal. It is deliberately not cleared on hold, because the cron loop gates on
it and clearing it would switch off the reconcile that brings the engine back.

Two paths published nothing and so left the previous config's verdict standing
for as long as the fault lasted. A rollback with no snapshot re-applied the
engine and the plane and never touched the traffic verdict, so a router rolled
back to a direct default kept reporting the tunnel. And an apply that failed in
the netplane stage had already swapped the engine, then returned before every
publisher, so status described the config that was no longer running — and the
next reconcile, seeing an unchanged hash, failed the same way and published
nothing again. Both now publish, with an unknown verdict: after a no-snapshot
rollback the engine runs options this process does not hold, and guessing from
UCI would describe the config we rolled away from.

The severity classifier had drifted from the texts production emits. Markers
were compared case-sensitively against wording that had since changed, and the
entity pattern could not match a message beginning with an upper-case tag —
so a blocklist that failed to load graded as a warning while a typo in its URL
graded critical, and the panel's banner, which only lights for criticals, stayed
dark for the outage. RULESET-NOT-APPLIED and DNS-FILTER-NOT-APPLIED are now read
as the structural markers their producer documents them to be, so severity no
longer depends on wording at all. Five markers that matched no living text are
deleted; three protection-section texts drop to warning, because a blocklist
that is stale but still blocking lights the alarm on most reconciles behind a
flaky link, and an alarm that is always on is how the real one goes unread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:26:40 +03:00
omarandClaude Opus 5 4996bc0984 fix(untunnelable): make block actually block
The block policy collapsed into direct whenever routing's final target was
direct — the common "tunnel only what is blocked, everything else direct"
shape. So ICMP, ESP, AH, GRE, IGMP and SCTP left with the client's real
address under the setting whose own field doc promises "nothing ever leaves
with the client's real IP", including a standing VPN on the real address,
which is exactly what the middle rung exists to separate out.

Both ends of the ladder now short-circuit before the plan is consulted and
neither may consult it: direct accepts everything, block emits no line at all
and lets the fail-closed drops the caller writes next do the work.

A rule scoped by source could also widen the other family: emit() skipped a
family whose destination list was empty but not one whose source list was, so
a rule carrying only IPv6 source prefixes rendered an IPv4 line with no
ip saddr clause — an accept for every IPv4 host on the LAN. The two halves now
read "scoped" the same way the catch-all collapse already did.

No destination plan is built for block at all now. It is the shipped default,
and a geoip-backed plan is ~159 000 prefixes pushed into kernel memory and the
ruleset text for a policy that cannot use them.

The operator-facing texts said IPTV works. It does not, on any of the three
rungs: inbound multicast is never matched by these rules and a client's
outbound multicast UDP dies at the fail-closed guard regardless. Saying
otherwise invited trading the ESP/GRE block away for nothing. What actually
stops working under block is stated instead, and precisely: raw ESP/AH and
GRE, but not IPsec through NAT or any UDP VPN, which are ordinary tunnelled
traffic.

TestOnlyPinnedAddressIsTunnelled is how this hid: it asserted, on the default
policy, that an exception line was emitted, and read that as the feature
working. It was block rendering direct. Its render assertions move to icmp,
where they mean something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:26:07 +03:00
omarandClaude Opus 5 a0de597d69 feat(dns): intercept by default, and bootstrap node addresses off the tunnel
test / go + panel tests (push) Successful in 4m56s
The posture was inverted. A client using the DHCP-supplied resolver — the router
itself — was NOT intercepted: dnsmasq answered and forwarded to the ISP in the
clear, so the filter, the blocklists, the per-device rules and BlockDoH were all
inert for exactly the clients that did nothing wrong. A client that hardcoded
8.8.8.8 to route around us WAS intercepted, by the catch-all. Meanwhile the
docs promised no DNS leaks. The default now matches the promise.

Turning it on crosses a threshold that was already dangerous for anyone with two
resolvers. Above one transport, a node's domain server address stops being
resolved by the transport directly and goes through the client DNS plane
instead — so a blocklist entry, a block_doh NXDOMAIN or any dns_rule can answer
your own node's hostname, and one sloppy line in an ad list stops being an ad
that got through and becomes a tunnel that never comes up.

So the fix is gated on having two or more transports, not on the intercept
toggle: resolver_default plus resolver_fallback always reached that threshold,
long before this change. When no endpoint_resolver is configured the plane now
carries a bootstrap server — the default resolver cloned with its detour
dropped, keeping its type, so a DoH default stays DoH and only the tunnel hop
goes. An explicit endpoint_resolver still wins.

This is not a restore of the previous behaviour and the comment says so: at one
transport the dialer used the default resolver WITH its detour, so a lone
DoH-through-the-tunnel resolver was already a bootstrap loop. It is strictly
better than what came before.

Existing installs keep whatever they set — the config file is a conffile and is
never replaced — and an explicit dns_intercept '0' survives the render-parse
round trip, which a default-true bool otherwise makes easy to lose.

The no-resolver warning stays, and no default resolver is shipped to silence it:
a placeholder would remove the sentence without moving a single query, and the
panel would then say a resolver was configured while nothing was filtered. Its
wording is corrected instead — .lan keeps working through the built-in local
transport, which the old text denied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:54:15 +03:00
omarandClaude Opus 5 544da29863 fix(dns,netplane): close three paths that sent traffic out in the clear
A resolver whose detour no longer resolved fell back to "the default outbound",
which is not a default at all — it is a plain system socket. Every other place
in this generator fails such a reference closed, with an essay explaining why,
and wgdedup rewrites the very same field to block when it drops an endpoint. One
field, two opposite policies, and which one applied depended on whichever code
noticed the breakage first. A resolver detoured through a node the operator
switched off therefore handed the whole network's query stream to the ISP in the
clear, while the kill switch held the traffic itself.

It now fails closed, and the warning says what that means: the resolver answers
nothing, and if it is the default one, name resolution stops network-wide until
the target is restored. A dns_rule naming a missing resolver used to be dropped
whole, sending exactly the names the operator singled out to a resolver they did
not choose; it keeps its matchers and answers NXDOMAIN instead. Not a reject
action — one built in Go with an unset Method panics the engine at match time.

RoutingPresent never looked at per-egress rules or tables, and applyLocked skips
the whole routing stage on its word. So an egress table wiped by an ifdown was
never restored: the marked traffic fell through to main and left over the plain
WAN, permanently, with plane full and no warnings. It now verifies each binding
it installed, recording intent rather than outcome so a broken egress keeps the
plane reported absent and heals when the interface returns.

addEgressRouting discarded every ip error, so an egress that failed to install
reported success and the panel drew it green. Failures are now critical warnings
naming the egress, the device and what ip said — but still warnings, because
returning would abort the apply and punish the household for one bad uplink.

Also anchors the fwmark check: with a small fwmark_base the main mark is a
literal prefix of the first egress mark, so a substring match could answer "the
main rule is installed" while looking at an egress rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:53 +03:00
omarandClaude Opus 5 daaa0fda41 fix(wireguard): stop holding AmneziaWG down behind a WireGuard hop
The guard refused to start an AmneziaWG endpoint whose detour chain reached a
WireGuard one, and refused silently: not an error, just started=false, after
which every dial failed with "WireGuard is not ready yet". A selector hook went
further and suspended an already-working node the moment its group switched to a
WireGuard member.

It existed because AmneziaWG inside WireGuard hung the kernel on Android. We do
not ship Android, upstream dropped the guard once the cause was gone, and the
cure landed here yesterday — the ClientBind reserved-gate plus the submodule pin
that carries its twin. So the tree held both the cure and the prohibition on
using it, and the configuration simply did not come up while looking like a node
that "just does not work".

Also takes the two fixes that belong with it. ClientBind.conn was read on a
lock-free fast path and written under a mutex; upstream found that race with the
same end-to-end test we wrote yesterday, so we had taken one half of a pair
again. And the outer WireGuard UDP socket forced DF, unlike direct, hysteria and
tuic — with encapsulation the datagram regularly exceeds the path MTU and the
kernel drops it instead of fragmenting, a symptom indistinguishable from the bug
we spent yesterday on.

The race needed its own test: the existing e2e run did not flag it under -race
even at -count=15. Eight goroutines over both connect branches reproduce it
deterministically, naming the lock-free read and the guarded write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:32 +03:00
omarandClaude Opus 5 56a276bcc1 ci: make the tests a gate instead of a decoration
The fork had a full suite and no CI that ran it. Upstream's test workflows
trigger on stable/testing/unstable; this repo only has main. And Gitea does not
read .github/workflows at all once .gitea/workflows exists, so those files were
decoration here. 115 of the 116 test files under shater/** had never executed in
CI even once, which is how TestDNSFilterRemoteBlocklistHTTPClient stayed red
across two published releases without anyone noticing.

The gate is a job inside release.yml that build-apk needs, because a separate
workflow cannot block another one. It runs the suite under the shipped tag set,
on Linux — 6 of 7 test files in transport/wireguard and 12 in shater/generate
compile only there or only under those tags, and those are exactly the files
covering AmneziaWG.

Three guards stop it from passing by running nothing, which is the failure this
whole change is about. The tag set may only ADD test files, never remove one.
Every package go list says has tests must appear as "ok <pkg>" in the output, so
a suite that collapses to "no test files" fails instead of passing. And the
panel run counts its test files first, because node --test exits 0 with "pass 0"
when the glob matches nothing.

The publish step used to exit 0 having published nothing: its assertions all
live inside a loop over artifacts, so an empty directory ran the body zero times
and reported success. It now counts what it published and fails on zero.

Verified by extracting the shipped step text and running it against stubs: empty
artifacts gives exit 0 before and exit 10 after; the rolling-release readback
still fires its own exit 14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:14 +03:00
omarandClaude Opus 5 754bbcf1fa fix(submodule): point .gitmodules at the line the pin is actually on
The wireguard-go submodule is pinned to 7d15f33, which lives on lx-awg2-v005.
.gitmodules named `lx` — a separate line, 42 commits one way and 131 the other,
with no common recent history.

That is a loaded gun rather than a cosmetic mismatch. `lx` has no hasReserved()
gate in conn/bind_std.go at all, so a single `git submodule update --remote`
would move the pin there and silently restore the defect fixed yesterday: the
bind shreds the AmneziaWG magic header of every transport packet, handshakes
complete, no data moves, and no chain containing an AmneziaWG node carries
traffic. It would also drop the padding-overrun fix and the v0.0.5 re-graft.

Nothing about the checked-out tree changes — the pin is untouched. Only the
branch a --remote update would follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 03:18:21 +03:00
omarandClaude Opus 5 63e6b709f8 fix(health): a chain blocked at a hop is dead on the board, unknown on the card
release / apk aarch64_cortex-a53 (push) Successful in 3m4s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 7s
The short-circuit left the chain's exit tag untested, because the exit is itself
a hop and every hop behind the break was rewritten that way. A stand run caught
it — the test lives in a file that does not compile on the dev host, so nothing
local could have.

That is not neutral silence. selectExcluding ranks untested ABOVE dead and says
so in its own comment: with no fresh-alive member, an untested one is a better
bet than a known-dead one. Leaving a provably broken path untested is therefore
a positive preference for it over a path we merely know is dead.

The two readings answer different questions and now differ on purpose. Is this
hop's own node alive — unknown behind a break, so the card keeps untested and
blocked_by. Can this chain carry traffic — known, no, because the hop in front
of it was probed and did not answer. The board carries that second answer, which
is the one selection, the freshness gate and the manual test all read.

The exit verdict is derived, not dialled: it records the consequence of a probe
that did happen one hop earlier, and it is re-derived every pass, so the moment
the blocker answers the walk reaches the exit again and the next verdict there is
a real measurement.

Also keeps a routed group warm. Its checker used to stop on the idle timeout and
nothing filled in behind it, so a rule that fires rarely would show untested
while being in force and pay a cold probe on the first real request. The gate
that adds this work answers false when it does not know — the mirror of the one
that withholds work, so plain sing-box keeps the lifecycle it always had.

And the tls-spoof suite now skips without tcpdump instead of failing sixteen
times: a missing tool is not measured, not broken. The same distinction this
commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:48:26 +03:00
omarandClaude Opus 5 8612b0a9e9 fix(health): one dialler per target, and it is the group's own checker
A member of a chain hop wrapper was reachable by two probers: ours, from the
observatory plan, and sing-box's, from the urltest group the wrapper actually
is. Two independent readings of one node can disagree, and then neither can be
trusted — which is worse than the wasted dial.

The group's own checker is the right owner. A hop wrapper's members are the
per-chain copies, each carrying the previous hop as its detour, so that checker
already travels the chain prefix — the path the traffic takes. The plan now
records who dials each target and the observatory skips the ones a live checker
owns, keeping only what no group covers: node hops, the AmneziaWG endpoint,
selector members, and the members of groups that have been stood down.

The jobs stay in the plan rather than being deleted, and that is load-bearing:
the short-circuit reads the plan as the map of which tags measure which hop, so
deleting a urltest hop's members would erase that hop from the map and quietly
stop it blocking anything — on exactly the chains the feature exists for.

The short-circuit therefore moves to the group as well, through a ProbeGate the
engine implements: a scheduled check asks whether the path in front of it is up
before dialling, while an explicit check is never refused. Nothing is stored —
the gate recomputes from the live board every call — and Touch still arms the
ticker even while blocked, because a hop that refuses to tick has nothing left
to notice its own recovery. The gate answers yes whenever it does not know:
refusing on missing information is how a system talks itself into silence.

Two grounds now exist for a group not to probe and they must not be merged:
stood down means no rule reaches it at all, blocked means the path in front is
down right now. Both doc comments say so and name the chain hop wrapper as the
case where the difference bites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:27:00 +03:00
omarandClaude Opus 5 96d9cfaa63 fix(health): stop probing a chain below a hop that is already down
Hop probes were independent, so every hop was dialled whether or not the path
to it existed. A hop is dialled THROUGH the hops above it, so when hop 2 had no
live member left, the probe for hop 3 failed at hop 2 and hop 3 was recorded
dead. Dead means "we tested this and it did not work" — but nothing was learnt
about hop 3 at all. One broken hop painted the whole chain dead and pointed the
operator at the wrong place, and every one of those probes was a dial with a
timeout down a path already known to be broken.

Chain jobs now run in path order and the walk stops at the first hop that reads
dead. Hops below it are not dialled at all and are reported untested with
blocked_by naming the hop that stopped the walk — the honest answer, since
nothing was measured.

Nothing latches. There is no blocked flag: the gate is a fresh read of the
health board at every hop of every pass, and the cursor rewinds to the top each
cycle, so the first dead hop is never behind a break and is always retried. The
moment it answers, the rest of the chain runs in that same pass. Only a positive
dead blocks; untested never does, or a cold start would never open.

Blocked hops are rewritten rather than annotated, because board records do not
vanish when the prober stops dialling — they age out on their own TTL, and the
worst version of that is a stale dead pointing at a hop that may be fine.

The exit tag is exactly what stops being dialled, so the group test would have
waited out its full deadline and then reported "not reached yet" about a chain
it already knew was down. It now names the blocking hop immediately, gated on
the same freshness watermark so a break seen before the request cannot
short-circuit a pass that may be about to find that hop alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:00:38 +03:00
omarandClaude Opus 5 3c7536dba0 fix(panel): show the chain hop by hop, and stop reading unused as broken
release / apk aarch64_cortex-a53 (push) Successful in 3m5s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 8s
The chain card gave a single verdict, so a dead hop was invisible: the operator
saw "the chain is unhealthy" and had to guess which of four hops to look at.
Meanwhile a group used only inside a chain showed "unused" next to a live
alive/dead count, which reads as a diagnosis when it only means nothing measures
it on that path.

Render the hops as a rail that severs below the first dead one, so which hop is
answered before a word is read, and split the two "not routed" messages into the
routing fact and the explicit non-fact. The group one names the case directly: a
group used only as a hop inside a chain reads unused here on purpose, and its
real health is on that chain's card.

Also fixes a bug this would otherwise have shipped: the readout painted every
ok:false in the critical colour, so "not routed" would have rendered as a fault
— the exact lie being removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:09:42 +03:00
omarandClaude Opus 5 3c92e1cbfd fix(health): one prober, on the path the rules actually use
A node reached only as a chain hop was being measured twice, and the reading
the panel showed was the wrong one. On a router in Russia that is not a cosmetic
difference: a node the chain carries fine behind a WireGuard hop is dead when
dialled straight out of the WAN, so the group card read "0 of 2 alive" while
that very group was carrying every packet.

Two dial paths existed outside the observatory plan. URLTestGroup.PostStart
warmed up every urltest group at box start whether or not any rule reached it,
and the panel's Test button reached URLTest.DialContext, whose first act is
Touch() — arming a ticker that re-swept those groups directly every probe
interval for the next thirty minutes. Both wrote under the BASE node tag, and
both dialled the base outbound, which carries no chain detour at all.

The observatory was never the liar: its plan roots come from the rules, and a
chain hop copy is stored only under its own tag, so no plan job could ever
write under a base tag. The fix is therefore to remove the other two paths, not
to touch the plan.

TestGroups now asks the observatory for an out-of-turn pass and reports what it
measured; a target no enabled rule routes to is not dialled at all and says so.
Unused urltest groups stand down their own self-check via a new SelfCheck option
(nil keeps today's behaviour, so every existing config is unchanged). The one
direct dial left is the exit-address lookup, which has no other possible source
— it now runs only for a target that is both routed and already read alive, so
it travels the routed path and never touches an unused group.

Chain hop wrappers are probed as measurements of their own and surfaced as
chains[].hops[], because "which hop is dead" is the question an operator has and
the chain-level verdict cannot answer it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:09:42 +03:00
omarandClaude Opus 5 bcc9df9282 test(wireguard): drive a real AmneziaWG tunnel through ClientBind
release / apk aarch64_cortex-a53 (push) Successful in 3m25s
release / apk x86_64 (push) Successful in 3m14s
release / release apk (push) Successful in 8s
The unit tests pin the reserved-byte gate on each side in isolation, which
would still pass if the two halves disagreed about when to apply it. This wires
two real wireguard-go devices together over loopback UDP through ClientBind on
both ends — the bind the detour path actually uses — configures ranged h1-h4
plus s4 and junk, and asserts an inner IP packet reaches the peer's TUN.

It is red against the unconditional clear and green with the gate, so it covers
the failure the field hit rather than the code we happened to write. Tagged
with_awg, so it runs under the shipped router tag set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:25:05 +03:00
omarandClaude Opus 5 ee3641fe45 fix(logsink): collapse interleaved floods, not just consecutive lines
The previous suppression compared each line with the one before it, which the
field never obliges. A dead chain makes the engine cycle the same message
across three outbound tags, so no two identical lines are adjacent: on the
router it produced 854 daemon lines in a ~760-line syslog ring and exactly one
summary, all while claiming "repeated 1 time". The rest of the system's log —
netifd, dnsmasq, the kernel — was evicted anyway.

Track a bounded table of open series keyed by the existing repeat key instead.
The first copy of a key prints; further copies inside its window are counted
whatever arrives in between; the window end emits one summary per key. The
summary now names its message, because several can close at once and "last
message" would simply be false under interleaving.

The table holds 256 keys and evicts the least recently seen, never silently: an
evicted series with a pending count prints its summary on the way out, marked
so the truncation is visible. Close, Reconfigure and any fatal flush every open
series first — a dying daemon may never reach Close.

TestRepeatAlternatingNotSuppressed asserted that A B A B must never be
collapsed. That assertion was the bug. It is replaced by a stronger one: the
messages get separate series, separate summaries and separate counts, so
distinct events still never fold into a single number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 439f62238f fix(engine): retire the superseded instance instead of leaving it running
Every config apply built a new box and left the old one alive. The engine's own
log gives it away: inside a single shaterd process, lines carried uptime
counters half an hour apart in the same second, and a live router was found
running four generations at once. A process restart cleared it, so the leak
accrued purely on re-apply.

That is not just wasted memory on a 512 MB box. Each surviving generation keeps
its WireGuard devices up, and two devices sharing one private key evict each
other at the peer — so the leak reproduced the duplicate-device defect between
generations, underneath the deduplication that only reasons about one config.

Retirement now has a hard budget: 5s, which is exactly sing-box's own
C.StopTimeout (past which upstream already calls a stop excessive) and stays
under C.FatalStopTimeout. It is paid after the replacement is serving and only
on an apply that changed something, so a no-op reconcile stays free.

A close that blows the budget is ABANDONED, not waited on, and the apply is
still reported as the success it is — the new box is built, started and
carrying traffic, and failing there would abort the netplane stage and leave a
stale ruleset over a healthy engine. The stuck instance is surfaced through
PendingCloses() into `shaterd status` and the panel, and clears itself if the
shutdown ever completes. Repeated applies over a stuck close no longer stack:
the abandoned generation is remembered, not re-created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 d971eb85ee fix(wireguard): stop ClientBind from shredding the AmneziaWG magic header
An AmneziaWG node worked standalone and died the moment it was placed behind
an egress or a chain hop: the handshake completed, the peer answered, and then
not one byte of data ever arrived. The peer never confirmed the session, so it
re-handshook every 15 seconds, forever.

ClientBind cleared bytes 1-3 of every datagram on receive and stamped them on
send, unconditionally. Those bytes are Cloudflare's "reserved" field. They are
also where AmneziaWG puts the upper three bytes of its little-endian uint32
magic header, so zeroing them collapses the value to its low byte, which falls
outside every h1-h4 range and makes the peer classify the packet as an unknown
type and drop it silently.

Handshakes survived because s1/s2 padding pushes their magic past byte 3 — the
clear only scribbled on the random junk prefix. Transport packets have s4 = 0,
so their magic starts at byte 0 and took the hit. That asymmetry is the whole
signature: session up locally, zero data through.

Only the detour path was affected, because Endpoint.Start picks StdNetBind when
the dialer exposes WireGuardControl (no detour) and ClientBind otherwise. The
gate had already landed in StdNetBind; ClientBind was its untouched twin. The
two implement one contract and are now commented as the pair they are, so the
next fix cannot again land on one side only.

Measured on the box: h4 spans 0x60728123-0x60728155, so zeroing bytes 1-3
leaves 35..85 — the captured transport packet began with 56, while a node
without a detour carried a correct 0x6b039798 at the same moment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 a0f6083e28 fix(panel): show whether a rule is in force, not just what was saved
release / apk aarch64_cortex-a53 (push) Successful in 3m7s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 8s
With two catch-all rules both enabled in UCI and a WAN profile enabling one
and disabling the other, the panel drew BOTH switches on while the engine
ran only one chain. GET /api/config is right to return the raw model — that
is the desired state the panel PUTs back — but Routing.tsx read the row
state and the active count from it too, so the interface claimed a setting
was in force when it was not. Same defect class as the Protected badge.

/api/rules/reachability now carries the effective flag and, where the active
profile changed the outcome, its name and direction. The annotation is a
DIFF of ApplyProfileRuleOverrides output against desired state rather than a
second reading of the profiles name lists, so profile logic is not
duplicated and cannot drift — an unmigrated rule the profile is forbidden to
enable produces no diff and gets no badge, with nothing here needing to know
about LegacyDst.

In the UI the two states stay separate: the switch remains the only carrier
of desired state and still writes UCI, while the effective state drives the
dimmed row, the badge, the banner and the header count. Mirroring the
effective state into the switch would be worse than the original bug — the
operator would be toggling someone elses control, and the profiles decision
would be written back as their own choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 77369aedfe fix(logsink): collapse repeated lines instead of erasing the routers syslog
A broken outbound makes the engine repeat one line about once a second —
370 copies in six minutes. The routers syslog ring holds ~760 lines, so
within minutes it evicts the history of every other subsystem and our own
startup lines with it. Diagnosing the WireGuard duplication above required
restarting the service purely to catch the first seconds of a boot.

Collapse runs into "last message repeated N times". The comparison key is
level + text with the uptime field dropped: comparing whole lines would
suppress only same-second bursts, because that counter ticks. The per
connection "[id duration]" group is deliberately KEPT in the key — those ids
are distinct connections, and folding "50 connections failed" into one count
would be a worse lie than the flood. Window 5s, so a standing fault keeps
being reported instead of looking like a frozen log. fatal/panic are never
suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 515ae6d1b7 test(generate): make the remote-blocklist test exercise the remote path
TestDNSFilterRemoteBlocklistHTTPClient has failed on every Linux run for two
releases, which made the whole package exit non-zero no matter what the code
did — a real regression would have drowned in the familiar red.

The cause is not the packages no-network fetcher stub, as it first appears.
ruleSetURLIsEngineNative decides remote-vs-compiled-local by URL EXTENSION
alone, and httptest.NewServers bare "http://127.0.0.1:<port>" has none, so
the fixture fell into the TEXT-list path: downloaded by generates own
fetcher, parsed as a hosts file, compiled into a LOCAL rule-set — which
every assertion below then contradicted. No stub content could fix that; the
stub decides the lists contents, not the rule-sets type.

Give the URL the .srs suffix the test always meant it to have, so the engine
fetches the compiled set itself through the direct outbound. No assertion is
weakened and the no-network stub stays in place.

Verified on the stand (ImmortalWrt 25.12.1 x86_64, shipped build tags):
338 PASS / 0 FAIL / 1 SKIP, exit 0 — against 327/1/1 on pristine HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 a2ffbb1292 fix(generate): one WireGuard device per private key
A node may be copied freely by this package: a per-chain hop copy and a
per-group egress copy are rebuilt from the share-link so each can carry its
own Detour. For vless that is right — a copy is another TCP client. For
WireGuard it is not: each emitted endpoint is a real device holding the
nodes private key, and a peer keeps exactly ONE session per public key.
Two devices from one key evict each other continuously, and with keepalive
on both the loop never settles: NEITHER passes traffic.

buildOutboundsAndEndpoints emits the base endpoint for every enabled node
whether or not anything references it, so a WG node used only as a chain hop
always produced two devices. That is what any chain containing a WG node
looks like — every such chain was permanently dead.

Observed on the box: two UDP sockets from shaterd to the same peer port, the
servers peer endpoint flapping between them, +32 bytes/min through the
tunnel and every hop failing with "context deadline exceeded".

Deduplicate once on the assembled options, which catches all three producer
paths by construction. Duplicates are DELETED, not merely unreferenced:
box.New starts every endpoint regardless of reachability, so a leftover
would still bring its device up and still fight for the session. Dangling
references go to block, never to direct — a consumer whose tunnel just
disappeared must stop, not fall out onto the plain WAN.

Subscription fetch detours seed the reachability walk (they are direct
references like any rule), mirroring engine.ViaToTag exactly, with a
tripwire test against drift. A config that genuinely needs two devices for
one key keeps one and fail-closes the rest with a critical warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 1746d4d0ef fix: stop the panel and the shipped binary from lying about what works
release / apk aarch64_cortex-a53 (push) Successful in 9m13s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 7s
Four defects, all found by the owner on the live router, all of the same
family: something declared itself working while it was not.

WIREGUARD WAS DEAD IN THE SHIPPED BINARY (B17). Setting up WireGuard gave
"create WireGuard device: gVisor is not included in this build". The router
tag set carried with_wireguard and with_awg but not with_gvisor, so
sing-tun compiled its stub instead of the netstack every WireGuard device
needs. FEATURES.md marks WireGuard [MVP] and AmneziaWG "a driving
requirement", so this was a broken promise, not a trim.

The tag itself was the small half. The tag set was the ONE build
configuration nothing in the repo tested: TestAmneziaWGEndpoint passes
because tests build with the full upstream tags. So the set now lives in
one file (scripts/router-tags.sh) and two guards hold it to the feature
list -- a static check that needs no tags, no Linux and no network (so the
next such gap fails on the developer's machine), and a behavioural one that
constructs every declared protocol through box.New UNDER THE SHIPPED TAGS,
where skipping is forbidden. Removing the tag now fails with the feature
name, the missing tag, and why: "Either add the tag back, or stop declaring
the feature -- those are the only two honest options." Cost: +2.8 MB raw,
+0.6-0.7 MB packed per arch. D23; D9 corrected.

THE PANEL CALLED A DIRECT-ONLY ROUTER "PROTECTED" (B16). The headline came
from plane === 'full', which reports whether the data plane is installed --
nft table, policy routing, live engine -- and says nothing about where the
traffic goes. On a config with one `default -> direct` rule and no groups
the plane is fully installed and every packet leaves in the clear, so the
worst possible state rendered as the reassuring one.

The verdict is now computed on the daemon FROM THE GENERATED OPTIONS at the
moment they reach the engine, not from the model: buildRoute changes the
answer (a scheduled rule outside its window is never emitted, only the last
condition-less rule reaches Final, an unresolved target is rewritten by
ruleKillFallback), and re-deriving it anywhere else is a second
implementation that will drift -- model/reachability.go exists because two
already did. Four verdicts, not three: `blocked` is separate because under
a closed kill-switch with no catch-all nothing leaks, and calling that
"going out directly" is a lie in the alarm direction. Rider: Overview's
defaultTarget printed the highest-Order enabled rule as the default; a rule
becomes Final by having no conditions, whatever its Order.

"PREVENT THIS PAGE FROM CREATING ADDITIONAL DIALOGS" KILLED EVERY DELETE
(B15). Once the browser suppresses dialogs, window.confirm returns false
immediately, so all 15 confirmations across 7 pages read as "cancelled" and
silently did nothing, with no way to recover from inside the panel. Replaced
with an in-app dialog the browser cannot mute: focus trapped and parked on
Cancel, Esc and veil cancel, focus returned to the opener, crit styling for
destructive commits. useConfirm() throws if the provider is missing rather
than falling back to a quiet false -- the failure mode being fixed.

HYSTERIA2 AND TUIC NODES WERE DROPPED (B6). No share-link parser existed,
so a feed's nodes of those types vanished. The real landmine was one layer
up: ParseSubscriptionBody splits a feed by scheme prefix before parsing, so
without schemePrefixes the links were gone before any parser ran and the
fix would have looked complete. Undeliverable parameters are refused when
the node cannot work or would be less secure than the link asked (obfs,
pinSHA256, tuic v4/non-UUID) and flagged via Proxy.Warnings when it
survives -- shaterd nodes shows both. uTLS is dropped for QUIC: it cannot
produce a QUIC TLS config, and that fails at dial time, not at box.New.

Also: nodes added by hand can be named and renamed. The name is the
outbound tag, so a rename rewrites every reference in one PUT -- rule
targets, group members, chain hops, detours -- in the spelling each already
uses, and is refused outright when a group answers to the same bare name.
Subscription nodes state why they cannot be renamed instead of hiding the
control.

go build, go vet, go test ./shater/... (13 packages), panel npm run build
and npm test (13/13) all green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 20:08:58 +03:00
omarandClaude Opus 5 f86501bf77 ci!: drop the opkg lane — apk only, and fix the stale rolling release
Both routers are past opkg: mini_router runs ImmortalWrt 25.12.1 and
main_router OpenWrt 25.12.0, both with apk-tools 3.0.5, and main_router has
no `opkg` binary at all. The 24.10 lane was building and signing a feed no
device could consume.

Removed jobs `build` and `release` with the scripts only they called
(ci/build-feed.sh, ci/sdk-build.sh, ci/make-index.sh, ci/install-usign.sh)
and the usign trust anchor dist/shater-feed.pub. A committed public key is
an instruction: it invites the old install path for a feed that is no longer
produced. The key is retired, not revoked -- git history keeps it, KEY_BUILD
still holds the secret half, and a usign secret contains its own public half,
so the identity is reconstructible if a 24.10 device ever needs serving.
D7 is marked SUPERSEDED by the new D22 rather than deleted.

Separately: the rolling `apk-latest-<arch>` release was frozen at 0.2.0 from
2026-07-24 while every tag run published its versioned release correctly.
The publish loop was an either/or -- `TAG=apk-latest-<arch>` when VER=latest
(workflow_dispatch only), ELSE `TAG=apk-<ver>-<arch>` -- so a `v*` tag run
never touched the rolling pointer. Asset replacement was never the problem;
ci/gitea-release.sh already deletes before recreating. A router pinned to
the rolling URL sat on 0.2.0 while `apk update` reported success: silent
staleness, the failure mode this repo keeps having to close.

The rolling pointer is now published on EVERY run, tag runs included, and a
new assert reads the release back over the API afterwards: our three
tag-versioned packages at the built version plus the index and the key must
be present (exit 13), and no package asset at any other version may survive
(exit 14). Same class of check as sdk-build-apk.sh's package-version assert,
added for the same reason -- the previous failure mode was silent.

KEY_BUILD can now be deleted from the Gitea repo secrets; nothing references
it. Docs state plainly that mini_router is deliberately pinned to a
versioned URL and that the hand-edit per release is the price of pinning.

Known consequence: the x86_64 QEMU testbed is still OpenWrt 24.10.3 and can
no longer install our packages. Its 25.12 rebuild is in flight separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 18:46:21 +03:00
omarandClaude Opus 5 eccfc6136c fix(routing)!: make the v1->v2 destination migration fail safe
release / aarch64_cortex-a53 (push) Successful in 3m56s
release / x86_64 (push) Successful in 3m25s
release / apk aarch64_cortex-a53 (push) Successful in 2m44s
release / apk x86_64 (push) Successful in 2m43s
release / release (push) Successful in 9s
release / release apk (push) Successful in 7s
Code review of a8ef887c5 + 244b7c419 ("a rule's destination is a rule-set,
and nothing else") found that the change rested on a comment that was not
true. ParseUCIExport dropped dst_domain/dst_ip on the strength of "the
migration is re-run on every load"; model.Migrate() actually runs only from
`shaterd migrate`, i.e. the service init and uci-defaults. The daemon's run
path, the SIGHUP reconcile and the panel's config write never migrate.

So an uncommitted migration (a full /overlay is the documented way that
happens) turned `list dst_domain 'bank.ru'` + `target direct` into a rule
with NO matchers, which IS the spelling of a catch-all: generate points
route.Final at it and the LAST such rule wins. One failed `uci commit` sent
every packet on the router out the plain WAN, silently.

Rule.LegacyDst is the tripwire. It is non-empty exactly when the config
still carries the removed options, and three locks hang off it:
  - ParseUCIExport holds such a rule DISABLED. Chosen over "make IsCatchAll
    false" alone, which only covers matcher-less rules: `dst_domain` plus a
    `src` was never a catch-all, and routing it without its destination
    would still have sent a whole subnet direct.
  - IsCatchAll returns false for it, so it can never own route.Final even
    if something hands its Enabled bit back.
  - ApplyProfileRuleOverrides refuses to enable it (a profile with
    `list enable_rule` would otherwise have defeated the parser).
ValidateRules reports it through the existing warning channel, before the
Enabled gate, so the one message explaining the outage is not suppressed by
the fact that caused it. The init script logs a failed migration to syslog
instead of discarding its exit code and stderr.

The write path had none of this. PUT /api/config decodes a Model straight
from the request body and render.go wrote `enabled` from it, so a panel
save erased the operator's lists (as did the subscription cron, which
re-renders the whole package), and a crafted body with Enabled:true and no
LegacyDst put a live matcher-less rule on disk -- the same whole-router
leak, re-entered from the other side. WriteUCI now reads DISK state and
refuses a rule-changing write over an unmigrated config (409, not 500);
non-rule writers pass and legacyDstOpts carries the options across so cron
preserves them; withDiskLegacyDst takes the field from disk so a fabricated
one can never reach the renderer.

Migration hardening: an entry list that migrates to nothing no longer has
its legacy option deleted (that made "matches nothing" silently become
"matches everything"); a hand-written rule-set whose name collides is no
longer allowed to swallow the entries; delete failures propagate instead of
bumping schema_version past them forever; every error path reverts the
staged uci delta so another process's commit cannot flush a half-migration.

untunnelable.go follows the destination out of the rule: a rule whose
rule-sets are known to match by name is still skipped by the ping/IPTV/VPN
plan, as its v1 form was. D21 documents the AND->OR widening for the
engine's TCP/UDP path; it does not follow that a leak-guard should widen
itself during an upgrade, and with target=direct that meant previously
tunnelled ICMP leaving with the client's real address. Inline rule-sets are
now read from the options, so an engine that has not started yet no longer
costs the operator their ping.

Rule-set vocabulary: `full:`/`suffix:`/`keyword:`/`regexp:` in a text list
fetched by URL were dropped with no diagnostic at all (normaliseListDomain
rejects any token with a colon) -- not "reported as an unknown prefix".
Unifying was rejected: published filter lists are full of colon-bearing
syntax, and a third-party `regexp:` is compiled into the router's matcher
and run per query. The difference stands and is paid for in diagnostics,
per list, on every generate. D21 gains the source/vocabulary table.

Panel: the add form warns about a matcher-less rule exactly as the edit
form does, from one shared predicate; its isCatchAll matches the daemon's
new one; an unmigrated rule reads as held-off rather than merely switched
off. The comment promising a "New list" button that D21 rejected is gone.

go build ./..., go vet ./shater/..., go test ./shater/... (13 packages) and
panel `npm run build` are green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 18:04:21 +03:00
omarandClaude Opus 5 244b7c4199 feat(panel): a rule's destination is a ruleset picker, nothing else
release / aarch64_cortex-a53 (push) Successful in 3m21s
release / x86_64 (push) Successful in 3m19s
release / apk aarch64_cortex-a53 (push) Successful in 2m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 9s
release / release apk (push) Successful in 6s
Follows the schema-v2 model change: `Rule.DstDomain` and `Rule.DstIP` are
gone from api.ts, so the Routing page loses the two controls that wrote them.

The add form's Match picker (rulesets / ip / port) collapses to a plain
Port(s) field beside the ruleset checkboxes — with no inline address list
there was nothing left to choose between. The edit form drops its "Domain(s)
— legacy" and "IP / CIDR(s)" fields; it now shows exactly what the add form
shows, which is the honest shape of a rule that carries one destination
mechanism.

The destination picker renders even when the config has no rulesets yet, and
says where to get one. Hiding it (the old behaviour when the list was empty)
would leave the rule form with no destination control at all, at precisely
the moment the user needs to know one exists. It is checkboxes and nothing
more: creating and filling a list stays in the Rulesets panel, so a list is
authored in one place and its naming and entry rules cannot drift between two
editors.

isCatchAll() drops the same two fields as model.IsCatchAll, so the "never
applies" badge and the daemon's apply warning keep agreeing about which rule
is the default; the matcher chips lose their `dns` and `ip` rows for the same
reason. The mock backend's reachability shim follows.

Rendered against `?mock` in both themes; `.rt-field-wide`, the only rule the
removed wide inputs used, is deleted rather than left dangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
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 8fd5c52488 fix(tproxy): connect the UDP write-back socket + release NAT sessions on close
release / aarch64_cortex-a53 (push) Successful in 3m29s
release / x86_64 (push) Successful in 3m21s
release / apk aarch64_cortex-a53 (push) Successful in 2m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 6s
B3, real root cause. On the live BPi-R3 Mini `netstat -lnup` showed shaterd
holding 33 sockets on the router's own LAN address 10.67.0.1:53, next to
dnsmasq's single socket, several with a growing Recv-Q. Reproduced read-only on
the box: 5 host queries to 10.67.0.1 -> 0 answers and total Recv-Q on those
sockets 0 -> 19200 (5 x 3840, one datagram parked in each, never read); 3
control queries to 127.0.0.1 -> all answered.

Where they come from: protocol/redirect/tproxy.go, tproxyPacketWriter.
WritePacket. The TPROXY UDP write-back socket must carry the ORIGINAL
DESTINATION as its source address, so upstream binds it there — but leaves it
UNCONNECTED (net.ListenPacket + WriteToUDPAddrPort) and sets SO_REUSEADDR AND
SO_REUSEPORT (sing's control.ReuseAddr sets both). An unconnected bound socket
is a RECEIVER as far as the kernel is concerned, so each one silently joins the
UDP demultiplex/reuseport set for that address:port. Nothing ever reads them —
this writer only sends.

With dns_intercept the original destination IS the router's LAN address, so
every intercepted DNS session parks another silent receiver on <lan-ip>:53. The
host's own queries to that address take the loopback path, are never diverted by
the nft plane (iifname is scoped to LAN devices), and are therefore spread across
that set by the reuseport 4-tuple hash: they land in a silent socket at random
and time out. Hence "2 restarts of 3 fine, the third dead", and hence a failure
that no ruleset rebuild or reconcile can touch. The stale [UNREPLIED] conntrack
entry seen alongside is a CONSEQUENCE of the unanswered query, not the cause.

Fix (upstream file, lx:tproxy_writeback_connect):
  * CONNECT the write-back socket to the one peer it ever talks to. The kernel's
    compute_score() rejects a connected socket for any other peer, and a
    connected UDP socket (sk_state == TCP_ESTABLISHED) is excluded from
    reuseport selection outright — so it can no longer be handed a datagram it
    will not read. Nothing about the reply changes: same spoofed source, same
    single peer, Write instead of WriteTo. The unconnected path is kept verbatim
    for a destination that cannot be bound (domain socksaddr).
  * A failed cached write now CLOSES the socket instead of only dropping the
    reference (upstream left the fd to the GC finalizer).
  * TProxy.Close() purges the UDP NAT cache. Closing the listener stops ingress
    but the cache evicts lazily, so after the inbound is gone nothing wakes the
    live sessions and each strands its write-back socket. Invisible upstream
    (one close at shutdown); on this fork the engine is rebuilt on every apply,
    so it was one stranded generation per apply.

Measured on the live box: the socket count is steady-state (22-40, fds 55-66),
i.e. bounded by the udpnat session lifetime rather than an unbounded leak — the
count itself is inherent to per-session write-back sockets and is harmless once
they are connected. The Close() purge removes the per-apply generations on top
of it.

The netplane UDP:53 conntrack flush from 32e8f8ff0 is KEPT, with its comment
corrected: it is hygiene on plane transitions, not the cure for B3.

Regression tests fail on the pre-fix code (verified by reverting each half):
TestWriteBackUsesConnectedSocket / TestWriteBackReusesOneSocket /
TestWriteBackClosesSocketOnWriteFailure ("use of WriteTo with pre-connected
connection") and TestTProxyCloseReleasesNatSessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:41:02 +03:00
omarandClaude Opus 5 32e8f8ff0b fix(restart): serialise stop->start and flush stale DNS conntrack (B3)
release / aarch64_cortex-a53 (push) Successful in 6m27s
release / x86_64 (push) Successful in 3m21s
release / apk aarch64_cortex-a53 (push) Successful in 5m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
`/etc/init.d/shater restart` left DNS to the router's own LAN address dead
and never recovering, while `stop` + pause + `start` was fine — with the
status still reporting plane=full / engine_running=true and `shaterd
reconcile` fixing nothing.

Cause: `restart` is not synchronised end to end.

  * procd's `stop` is ASYNCHRONOUS. rc.common's `restart` is literally
    `stop; start`, and the `service delete` ubus call returns as soon as
    SIGTERM has been SENT. `start_service` therefore re-adds the instance
    (and runs `shaterd migrate`) while the outgoing `shaterd run` is still
    executing its honest teardown.
  * The successor's only defence was `daemonAlive()` -> exit(1), leaning on
    procd's `respawn 3600 5 0` to try again five seconds later. That is a
    blind retry, not synchronisation: it neither knows nor waits for the
    teardown, and it turns every restart into a logged crash plus a
    five-second hole with no data plane.
  * `term_timeout 10` SIGKILLs a predecessor whose teardown outlives it —
    engine.Close of a several-hundred-outbound box flushes cache.db to
    flash before the netplane teardown even starts — aborting the teardown
    at an arbitrary point and leaving the plane HALF removed.
  * Nothing in the tree ever touched conntrack, so flows that crossed one
    of those windows kept entries formed against a plane that no longer
    exists. For UDP there is no handshake to resynchronise on and every
    retry merely refreshes the entry, so the flow stays wedged for as long
    as the client keeps asking — a flow-scoped, permanent failure that no
    ruleset rebuild can reach.
  * RoutingPresent() reported "plane intact" from the ip RULE alone, while
    ApplyRouting installs a rule AND a `local default dev lo` route removed
    by two independent commands. A teardown interrupted between them was
    therefore invisible, applyLocked's fast-path skipped ApplyRouting
    forever, and no reconcile could repair it.

Fix (fail-closed posture unchanged — no new window in which LAN traffic can
reach the WAN; teardown still removes the table LAST and the forward-chain
drop is untouched):

  * init: `start_service` waits for a live predecessor pidfile to clear
    before opening the instance, so restart == stop + pause + start. Zero
    cost at boot. term_timeout 10 -> 30 so an honest teardown is never
    killed halfway.
  * daemon: the single-owner guard WAITS for the predecessor (bounded,
    60s) instead of exiting 1; it still refuses if the budget expires.
  * netplane: new FlushDNSConntrack() (ctnetlink, UDP orig-dport 53 only —
    a blanket flush would drop the admin's own SSH/LuCI sessions) called
    on every plane transition: after a ruleset loads, after the table is
    removed, and once more in applyLocked when the whole plane (table +
    policy routing + sysctls) is assembled.
  * netplane: RoutingPresent() now verifies both halves it installs.

Regression tests fail on the pre-fix code (verified by reverting each fix):
TestApplyNftFlushesDNSConntrack, TestTeardownNftFlushesDNSConntrack,
TestRoutingPresentRequiresLocalDefaultRoute, TestWaitForPredecessor*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:51:19 +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 6f89acbae7 feat(panel): badge routing rules that never apply
The Routing page drew every condition-less rule as "default route · final",
so a config with two of them showed two identical claims and no hint that
only the last one is the default the router uses.

A superseded rule now loses those marks — it keeps its real Order in the rail
instead of the "·" that means final — and gains a "never applies" badge plus
a line naming the rule that beat it and what to do about it: give this one a
condition, or delete one of the two. Warn semantics throughout (--amber,
dashed frame, dimmed target chip): orange is the ACTIVE state on this
faceplate, and a rule the router ignores is the opposite of active.

Verdicts come from GET /api/rules/reachability and are keyed by the rule's
index in Rules, never by name — the config that prompted this had two rules
both called `default`. They are re-fetched after every save, and a verdict
whose echoed name/order no longer matches the row is dropped rather than
shown, so the window between an optimistic edit and the refetch cannot badge
a working rule.

Rule rows were also keyed by name in React, which silently collapses two rows
that share one; the key now carries the model index.

The mock fixture gains a second condition-less rule so `?mock` renders the
state, and mock.getRulesReachability derives its verdicts from the live
fixture config rather than hard-coding them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:36:36 +03:00
omarandClaude Opus 5 88a82c7297 feat(routing): report rules that can never fire (B1)
A routing rule with no conditions at all is not matched in sequence — it
becomes the engine's route Final (generate/route.go buildRoute points Final
at it and moves on). Two consequences were invisible everywhere:

  * two condition-less rules retire each other, and the LAST one by Order
    wins, so an earlier "default -> direct" is dead while looking live;
  * a condition-less rule can NEVER retire a rule that HAS conditions —
    those are emitted ahead of Final whatever their Order.

A config in the field had two rules both named `default`, both with zero
conditions, order 20 -> direct and order 100 -> group:auto. One of the two
did nothing, the log was clean, and the panel drew both rows with the same
"default route · final" badge.

model.RuleReachability is the one implementation of the verdict, in the
stdlib-only leaf both consumers import, so the warning and the panel badge
cannot drift. generate.isCatchAll / effectiveRuleTarget / sortedRuleIndices
now delegate to it — three copies of "what is a default and what order do
rules run in" was how this would come back.

Scope is deliberately narrow: only condition-less over condition-less, which
is certain from the config. Whether one conditional rule's matchers subsume
another's is not decidable here, and a false "never fires" badge on a working
rule is worse than no badge.

Profiles are honoured: the analysis runs on the EFFECTIVE rules
(Model.EffectiveRules applies the active WAN profile's enable/disable), so a
rule the profile switched off is not blamed for retiring anything, and one it
switched on is. A SCHEDULED default never retires anything — outside its
window the rule above it is the default again — but can itself be retired by
an unscheduled one below it, which makes its schedule pure decoration.

Apply-time this reaches the operator through the existing status warnings,
graded by consequence rather than by "a setting is dead": critical when the
surviving default is `direct` while the retired one asked for a tunnel or a
block (the operator's default policy is not in effect and everything
unmatched leaves on the plain WAN); warning otherwise. The field config's own
shape — a dead `direct` under a live tunnel — is the warning case.

GET /api/rules/reachability serves the same verdict to the panel, the routing
analogue of the per-chain `used` flag on /api/groups/health. Keyed by index
into Rules, not by name: this config has two rules called `default`.

Diagnosis only — nothing is renamed, reordered, disabled or dropped, and
apply keeps working on a config that already has two defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:36:36 +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 0b32a6d58b fix(log): no ANSI colour outside a TTY — syslog and the log file stay grep-clean (B5)
Every log line the daemon produced carried aurora escapes, and under procd
stderr is not a screen, it is syslog:

  daemon.err shaterd[27540]: ...Z ESC[31mERRORESC[0m[0026]
  [ESC[38;5;193m1728741629ESC[0m 70ms] dns: exchange failed ...

`logread | grep ERROR` misses that line — the level word has invisible
bytes inside it — external collectors store the escapes forever, and a
captured log reads as mojibake.

Both producers defaulted to colour, and both are fixed at the producer,
because colour is a property of the DESTINATION and should never be
generated for a destination that cannot render it:

  * control plane (cmd/shaterd): log.Formatter{BaseTime: ...} left
    DisableColors at its false zero value. It now comes from
    controlLogFormatter(), gated on logsink.IsTTY(os.Stderr). The helper
    lives in an untagged file (same split as profilewatch.go) so it is
    unit-testable off the linux target.
  * engine (shater/generate): the generated option.LogOptions never set
    DisableColor, so box.New built a colouring formatter over the shared
    sink. logOptions() now sets it from the same TTY gate (seam:
    logColorAllowed).

logsink.IsTTY is the single source of the decision: a character-device
check, so no cgo, no termios and no new dependency on a CGO_ENABLED=0
musl-static binary. Under procd stderr is a pipe => no colour; an
interactive `shaterd run` from a shell keeps it.

The file half already stripped ANSI on the way out (emitLocked ->
stripANSI); that stays as the belt to this new braces, and the leak it
never covered — the syslog half — is now closed at the source.

Tests: the syslog half of the sink carries no 0x1b for any level with a
context ID set (the connection id is coloured by a separate branch of
log/format.go, so a level-only fix would still leak); the same for the
control-plane formatter and for a factory built from the REAL generated
log block. Each has a teeth check that a colouring formatter does emit
0x1b, so the guards cannot rot into passing for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:31:01 +03:00
omarandClaude Opus 5 893fdc500c fix(shaterd): nodes reports the real inventory instead of an empty list (B2)
`shaterd --help` promised "print nodes as JSON"; the verb answered `[]`
unconditionally — `cmdReadStub("nodes", "[]")` on the CLI side and a
hard-coded `writeLine(conn, "[]")` in the daemon's control-socket handler.
The data was never missing: on the live router /etc/shater/subs/*.json
held 315 subscription nodes and GET /api/config reported 340. An empty
array is indistinguishable from a truthful "nothing is configured", so
the verb did not fail loudly, it lied quietly — the same inverted-lie
class as 9dc954029 / aec82d444.

`nodes` now reads model.ReadUCI() — `uci export shater` merged with the
per-subscription JSON caches — which is literally the call GET
/api/config serves and generate builds the engine from, so the verb
cannot drift from the panel or from the running engine: there is no
second assembly here to drift. Both ends use the same nodesJSON():
the daemon answers over the control socket (like `stats`), and the CLI
falls back to reading the same on-disk state when no daemon is running
(like `status`). A read failure goes to stderr with a non-zero exit
instead of printing `[]`, so an empty list on stdout now means one thing.

Output is a purpose-built view rather than raw model.Node: the share-link
URI is a credential and CLI output ends up in tickets and cron mail, so
the view reports what the link decodes to (protocol/server/port) plus the
model's own facts (enabled/sub/egress/stale/fingerprint). Nodes whose URI
does not parse are still listed, with the reason in `parse_error` — the
engine skips exactly those, and hiding them would be the same lie smaller.

cmdReadStub keeps `stats`, where the default IS the truth (nothing was
counted without an engine), and now says so in its doc comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:30:27 +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 Opus 5 024e9308c9 fix(ci/apk): strip the SDK's generated per-package default m blocks
release / aarch64_cortex-a53 (push) Successful in 3m21s
release / x86_64 (push) Successful in 3m12s
release / apk aarch64_cortex-a53 (push) Successful in 5m32s
release / apk x86_64 (push) Successful in 2m32s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
Run 60 settled what runs 58/59 left open. The second pass wrote an explicit
`# CONFIG_PACKAGE_kmod-x is not set` for all 1126 selected kmods and re-ran
defconfig; the count came back 1078, unchanged. The same explicit form DID hold
for CONFIG_ALL/ALL_KMODS/ALL_NONSHARED in the same run.

The difference is prompts. kconfig honours a user value only for symbols that
have one — sym_calc_value ignores S_DEF_USER for a promptless symbol and falls
back to its `default`. ALL* carry prompts in the SDK's Config.in; the blocks
convert-config.pl generates are bare:

    config PACKAGE_kmod-mlx5-core
            tristate
            default m

No value written into .config can turn those off, so remove the `default m`
itself: drop every generated `config PACKAGE_*` block from Config-build.in
before the first defconfig. Nothing is lost — those blocks only replay which
packages the buildbot built. The packages stay declared, with prompts, by the
package tree (tmp/.config-package.in), which is what makes our four selectable
and what `select` acts on; KERNEL_*/LIBC/TOOLCHAIN blocks are untouched, so the
SDK still reproduces its own toolchain settings.

The .config second pass is kept as a cheap backstop (it no-ops once the count
is 0), as are both tripwires.

Verified: bash -n on the file and on the extracted INNER body; the paragraph
delete tested on a synthetic Config-build.in (3 PACKAGE blocks -> 0, KERNEL_*,
LIBC and TOOLCHAINOPTS preserved); the missing-file path exercised under set -eu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:27:37 +03:00
omarandClaude Opus 5 eab1db2c3f fix(ci/apk): second defconfig pass — deselect the SDK's per-kmod default m
release / aarch64_cortex-a53 (push) Successful in 3m19s
release / x86_64 (push) Successful in 3m14s
release / apk aarch64_cortex-a53 (push) Failing after 1m4s
release / apk x86_64 (push) Failing after 1m3s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
Turning ALL/ALL_KMODS/ALL_NONSHARED off (22d7161c0) provably worked — run 59
logs all three as `is not set` after defconfig — and changed the kmod count by
exactly zero, 1078 both times. The kmods never came from ALL_KMODS.

They come from the SDK itself. target/sdk/Makefile generates the SDK's
Config-build.in by running convert-config.pl over the BUILDBOT's .config, in
which ALL_KMODS=y had already expanded into one `CONFIG_PACKAGE_kmod-*=m` line
per module. convert-config.pl turns every `CONFIG_X=<val>` line into a symbol
with an unconditional `default <val>`; its `next if /^(# )?CONFIG_PACKAGE/`
filter sits in the `else` branch, which a line containing `=` never reaches.
The SDK therefore ships ~1078 verbatim blocks of `config PACKAGE_kmod-x /
tristate / default m`, none of which consult ALL_KMODS.

Fix: a second pass. The names only exist after kconfig has expanded the tree,
so after the first defconfig rewrite every selected kmod to `is not set` and
re-run defconfig. Two documented kconfig rules make this exact:
  - an explicit value in .config beats a `default` (same rule that kept our
    `# CONFIG_ALL* is not set` lines alive in run 59) -> the ~1078 stay off;
  - `select` is OR-ed in after the user value, so shater-core's
    `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` brings those (and their
    transitive kmods) back on their own.

Also correct the tripwire message, which still blamed CONFIG_ALL_KMODS: it now
prints the ALL* state AND the first few surviving kmods, so the two failure
modes are distinguishable at a glance.

Verified: bash -n on the file and on the extracted INNER heredoc body; the
rewrite simulated against a run-59-shaped .config (1078 -> 0 selected, our 4
packages, LOCALMIRROR and the ALL* lines untouched).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:16:05 +03:00
omarandClaude Opus 5 22d7161c08 fix(ci/apk): disable the SDK's ALL/ALL_KMODS mass-select
release / aarch64_cortex-a53 (push) Successful in 3m25s
release / x86_64 (push) Successful in 3m14s
release / apk aarch64_cortex-a53 (push) Failing after 1m3s
release / apk x86_64 (push) Failing after 1m3s
release / release (push) Successful in 8s
release / release apk (push) Successful in 6s
Run 58 proved the previous commit aimed at the wrong thing, and the
diagnostics it added are what showed it: "0 lines carried over" plus a
`grep: .config: No such file or directory`, then 1078 kmods selected
anyway (1109 on x86_64). So an SDK tarball ships no top-level .config at
all — there was never a buildbot config for us to be appending to.

The real source is the SDK's OWN top-level Config.in, target/sdk/files/
Config.in, which it carries instead of the main tree's:

    config ALL_NONSHARED ... default ALL
    config ALL_KMODS     ... default ALL
    config ALL           ... default y

In the main tree all three default to n; the SDK flips ALL to y so that
`make world` in a bare SDK builds something. `make defconfig` therefore
selects the whole kernel from ANY .config, empty or not. This is stock
OpenWrt rather than an ImmortalWrt quirk — openwrt/openwrt's copy is
identical, which also means the awg-openwrt reference builds every kmod
too; it just never meets a disk quota on GitHub's runners.

Fix: write all three out as `# CONFIG_X is not set` before defconfig.
They have prompts in the SDK's Config.in, so they are user-settable and
an explicit value beats the default; `CONFIG_X=n` is not reliably
honoured for bools, hence the `is not set` form. Setting all three, not
just the root ALL, keeps this working whichever symbol roots the chain
in a future SDK.

Drops the hand-rolled CONFIG_TARGET_*/CONFIG_KERNEL_* carry-over as
redundant: target/sdk/convert-config.pl bakes the buildbot's non-package
settings into the SDK's generated Config-build.in as kconfig defaults,
so defconfig reproduces them by itself. A soft branch keeps target
identity and CONFIG_USE_APK if some future SDK does ship a .config.

Diagnostics gain a post-defconfig readout of the three mass-select
symbols and, while the list is short, the actual kmods selected — a
count of 0 is not fatal (the router's base feed carries them) but is
worth seeing. Guards and the 200 threshold are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 00:57:19 +03:00
omarandClaude Opus 5 24c5a1615d fix(ci/apk): build .config from scratch — stop packing all 3593 kmods
release / aarch64_cortex-a53 (push) Successful in 3m19s
release / x86_64 (push) Successful in 3m18s
release / apk aarch64_cortex-a53 (push) Failing after 1m4s
release / apk x86_64 (push) Failing after 1m2s
release / release (push) Successful in 7s
release / release apk (push) Successful in 5s
Both apk jobs of v0.2.2 died with `Disk quota exceeded`. The SDK was
running `apk mkpkg` on 3593 kmod-* packages (mlx5, amdgpu, ata, isdn —
none of which we ship) before it ever got near our four.

Root cause: ci/sdk-build-apk.sh APPENDED our package selections to the
.config that ships inside the ImmortalWrt SDK tarball. That file is the
buildbot's fully-expanded config and carries CONFIG_ALL_KMODS=y plus
CONFIG_ALL_NONSHARED=y (see config.buildinfo next to the SDK), so
`make defconfig` re-selected every kernel module of the target as =m and
package/kernel/linux/compile — pulled in via shater-core's nft kmod
deps — packed the lot.

Fix, modelled on Slava-Shchipunov/awg-openwrt's "Setup SDK and feeds":
start the .config EMPTY so kconfig can only pull in what our packages
actually select. Carried over from the SDK's .config, nothing more:
the target choice and its BOARD/SUBTARGET/ARCH_PACKAGES identities (a
wrong guess here means silently cross-compiling for another arch),
CONFIG_USE_APK (decides .apk vs .ipk — the point of this lane), and
CONFIG_KERNEL_* verbatim (they generate the kernel .config; dropping one
makes the buildsystem reconfigure and rebuild the SDK's prebuilt kernel).

Also adds the diagnostics this lane never had, since a failed run leaves
a 27 MB log: the carried-over identity lines, the post-defconfig kmod
count and target readout, a hard check that all four of our packages
survived defconfig, an abort if the kmod count is back in the hundreds,
and du/df after compile.

opkg lane (ci/sdk-build.sh, ci/make-index.sh) untouched. LOCALMIRROR,
CONFIG_DOWNLOAD_FOLDER and every cache path are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 00:31:28 +03:00
omarandClaude Fable 5 4492f0599c ci: harden feed/packaging shell scripts
release / aarch64_cortex-a53 (push) Successful in 7m3s
release / x86_64 (push) Successful in 3m13s
release / apk aarch64_cortex-a53 (push) Failing after 4m34s
release / apk x86_64 (push) Failing after 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
- ci/make-index.sh: set -e → set -euo pipefail so a failing sha256sum|cut in
  the signed Packages index can't mask an empty SHA256. Script survives -u
  (all vars use :? or :- defaults).
- .github/deb2ipk.sh: quote $2/$DEB_NAME/output, derive the deb name from the
  copied file via basename instead of parsing `ls *.deb` (glob-fragile), add a
  trap-based tmpdir cleanup, and set -euo pipefail.

bash -n clean on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +03:00
omarandClaude Fable 5 bc2b53069a fix(security): audit remediation — file perms, const-time auth, leaks, CSPRNG
Backend audit fixes (upstream-file edits wrapped in // lx: markers):

- experimental/libbox oom_report.go/report.go: OOM reports + configuration.json
  (server secrets/keys) were written world-writable — 0o777 dirs / 0o666 files
  → 0o700 / 0o600. [sec-perms]
- daemon/server.go + experimental/libbox/command_server.go: gRPC auth secret
  compared with != (timing oracle) → crypto/subtle.ConstantTimeCompare.
  [sec-consttime]
- service/oomkiller/timer.go: network-extension cleanupTriggered logic was
  inverted, so FreeOSMemory was never called after a trigger; flip both
  assignments so a trigger schedules the deferred free and the next poll runs +
  clears it. [sec-oomcleanup]
- transport/v2rayxhttp/client.go (lx-native file): session id used math/rand →
  crypto/rand, matching Xray's uuid.New() entropy and removing the spoof surface.
- daemon/started_service_tailscale_ssh.go: forwardSSHAgentChannel leaked a
  goroutine + the ssh-agent fd on every closed session (second io.Copy blocked
  on an idle agent Read forever); tie both copies + the session ctx to a
  cancel that closes both ends. [sec-sshagent]
- daemon/managed_service.go: TriggerOOMReport had no gate — rate-limit to
  1/min so an authenticated client can't spin secret-bearing dumps. [sec-oomgate]
- route/reachability_lx.go (lx idle-suspend file): idle tick read r.idleStop in
  select while stopIdleSuspend niled it after close (race + goroutine leak on
  Close-during-tick); pass the stop channel to the loop by value.

go build ./... (default) and the D9 shaterd linux build (tags
with_quic,with_wireguard,with_utls,badlinkname,tfogo_checklinkname0,with_xhttp,
with_awg,with_lx_command) are green; go vet clean (2 pre-existing unsafe.Pointer
warnings in TriggerDebugCrash/debug.go, untouched); go test ./route/...
./daemon/... ./service/oomkiller/... green incl. -race with with_lx_idle_suspend
and v2rayxhttp with with_xhttp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +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
omarandClaude Fable 5 225cce5397 chore: purge working-session junk from tree; gitignore recurrence
Remove untracked-quality artifacts accidentally committed during work
sessions (all authored downstream, unreferenced anywhere in code/docs/CI):
- 5 session screenshots in repo root (devices-after-copy-fix.png,
  live-final-groups.png, profiles-*-active.png, profiles-final-vm-wan0.png)
- tmp/gen_linux_test (29 MB throwaway traffic-gen binary)

Guard against repeats: ignore /*.png (root screenshots) and /tmp/.
Upstream files (mkdocs.yml, .fpm_*) and the SPECS-020 research .log are
left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:23:04 +03:00
omarandClaude Fable 5 84d2766592 chore: remove stray committed test binary, ignore nested .idea, bump PKG_RELEASE
release / aarch64_cortex-a53 (push) Successful in 6m26s
release / x86_64 (push) Successful in 3m32s
release / apk aarch64_cortex-a53 (push) Successful in 4m58s
release / release (push) Has been cancelled
release / release apk (push) Has been cancelled
release / apk x86_64 (push) Has been cancelled
- drop c/Users/.../gen_linux_test (28MB binary accidentally committed in 129e31fbd)
- .gitignore: ignore .idea/ at any depth (shater/.idea from IDE)
- CLAUDE.md: orchestrator delegates to model fable
- bump shaterd/shater-core r2->r3, luci-app-shater r1->r2 for v0.2.1 release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:18:01 +03:00
472 changed files with 102501 additions and 7078 deletions
+267 -275
View File
@@ -1,51 +1,65 @@
# Shater v0.2 — build the 4-package signed opkg feed and publish it as a rolling
# Gitea release consumable as an `src/gz` feed.
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
# per-arch Gitea releases consumable as an apk repository.
#
# WHAT CHANGED FROM v0.1
# v0.1 shipped 3 packages: xrayctl (SDK-compiled Go) + shater-core +
# luci-app-shater (hand-packed data .ipk). v0.2 collapses the runtime into ONE
# forked binary and ships 4 packages, all built the canonical SDK way:
# WHAT WE SHIP
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
# SDK way:
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
# and staged into openwrt/shaterd/files/ BEFORE the SDK
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
# the arch-matched artifact. (arch-specific .ipk)
# the arch-matched artifact. (arch-specific .apk)
# - shater-core data glue, PKGARCH=all
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
# - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
#
# TARGET HARDWARE / ARCH MATRIX
# x86_64 -> the QEMU testbed VM (generic x86-64).
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 + BPI-R4, mediatek/filogic).
# Only shaterd + byedpi are arch-specific; shater-core + luci-app-shater are
# PKGARCH=all, so one build of each covers every device. opkg filters by
# Architecture at install time, so a single combined feed URL serves all.
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 mini + BPI-R4,
# mediatek/filogic), both on 25.12 with apk-tools 3.
# Only shaterd is arch-specific; shater-core + luci-app-shater are
# PKGARCH=all, so one build of each covers every device — but the RELEASES
# are still per-arch (see the release-apk job for why).
#
# FEED SIGNING (opkg / usign — OpenWrt 24.10 is opkg, not apk; apk lands at 25.12)
# The feed index (Packages) is usign-signed with the SECRET key in the Gitea
# repo secret KEY_BUILD; routers verify it with the committed public key
# dist/shater-feed.pub (fingerprint 5ac4b177689cb8e0). Do NOT regenerate the
# key — that invalidates every deployed router's trust.
# FORMAT: apk ONLY (25.12+)
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
# device we serve has an opkg binary at all, so building and signing a second
# feed served nobody.
#
# FEED SIGNING (EC / apk)
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
# secret KEY_APK; routers verify it with the committed public key
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
# invalidates every deployed router's trust.
#
# AUTO-RELEASE
# push a tag `vX.Y.Z` -> versioned release. workflow_dispatch / (optional) main
# -> rolling `latest` pre-release (always-fresh feed). Publish uses the Gitea
# API via curl (ci/gitea-release.sh) — no external action needed.
# The rolling per-arch `apk-latest-<arch>` is published on EVERY run — tag runs
# included — and then read back over the API to assert it really serves the
# version just built. A tag push `vX.Y.Z` publishes the pinnable per-arch
# `apk-vX.Y.Z-<arch>` IN ADDITION. It is not an either/or: it used to be, and
# the rolling pointer then froze at 0.2.0 while v0.2.9/v0.2.10 shipped (see the
# long comment above the `release-apk` job). Publish uses the Gitea API via curl
# (ci/gitea-release.sh) — no external action needed. NOTE: the apk release tags
# deliberately do NOT start with `v` so publishing them cannot re-trigger this
# workflow's `v*` filter.
#
# APK LANE (25.12+, ADDITIVE — T2)
# The fleet is migrating to BananaWRT 25.12-mtk-vendor (= ImmortalWrt 25.12
# base), where opkg is replaced by Alpine apk (.apk, binary packages.adb
# index, EC keys in /etc/apk/keys/). The `build-apk` + `release-apk` jobs
# below build the SAME 4 packages through the ImmortalWrt 25.12 apk-SDK and
# publish PER-ARCH apk repos as releases `apk-latest-<arch>` (rolling) /
# `apk-<tag>-<arch>` (versioned). Per-arch because apk filenames carry no
# architecture (shaterd-0.2.0-r1.apk would collide across arches in one flat
# release) and apk fetches packages relative to the packages.adb URL.
# Signed with the EC key in the Gitea secret KEY_APK; trust anchor
# dist/shater-apk.pem (ci/gen-apk-key.sh). The usign/opkg lane above is
# UNCHANGED and keeps serving the 24.10 fleet. NOTE: the apk release tags
# deliberately do NOT start with `v` so publishing them cannot re-trigger
# this workflow's `v*` tag filter.
# PACKAGE VERSIONING (bug B4)
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. 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, so `apk update` never saw
# a new version and routers could not be updated at all. Now `ci/version.sh`
# derives them from the git tag ONCE per job (the "Compute version" step,
# exported via $GITHUB_ENV):
# tag `vX.Y.Z` -> X.Y.Z-r1
# anything else -> <nearest tag>-r<commits since it + 1>
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
# built .apk really carry that version, so the failure can never be silent
# again. This is also why the build job checks out with fetch-depth: 0
# — `git describe` needs tags and ancestry. Every package this repo ships is
# versioned from the tag; there is no longer an exception to remember.
# CACHING (T3 — fast CI)
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
@@ -63,34 +77,33 @@
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
# prefix fallback is allowed.
# - Go module + build cache — key = hash of go.sum; shared by all 4 build
# - Go module + build cache — key = hash of go.sum; shared by both build
# jobs (each builds both GOARCHes).
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
# - apt .deb archives for the apk lane's debian:bookworm host-deps
# (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list is in it).
# - usign binary (.cache/tools) — static helper, fixed key.
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
# is in it).
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
# container from the workspace cache; `feeds update` on an existing clone
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
# always checks out feeds.conf's pins, and ci/sdk-build*.sh wipes the
# always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
# cache + re-clones fresh if update ever fails on a cached checkout.
# Key = lane + SDK release (shared across the two arch jobs of a lane —
# same release pins identical feed revs; the sequential runner means the
# second arch restores what the first saved). restore-keys lets an SDK
# version bump start from the old clones (git fetch delta, not re-clone).
# Key = lane + SDK release (shared across the two arch jobs — the same
# release pins identical feed revs; the sequential runner means the second
# arch restores what the first saved). restore-keys lets an SDK version
# bump start from the old clones (git fetch delta, not re-clone).
# Act_runner facts this design leans on (verified in run 51 logs):
# - the cache backend works: restores/saves confirmed, hashFiles() works;
# - docker images (openwrt/sdk, debian:bookworm, runner-images) live on the
# PERSISTENT host daemon — "Image is up to date" each run, no re-download;
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
# host daemon — "Image is up to date" each run, no re-download;
# - each actions/cache SAVE is followed by an exact 3-minute act_runner
# stall (node process lingers; hit→no-save→no stall). Steady state saves
# nothing, so adding cache entries is fine, but keys that change every
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
name: release
on:
@@ -108,41 +121,49 @@ concurrency:
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.arch }}
# ---------------------------------------------------------------------------
# THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
# stops the release instead of shipping with it.
#
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
# workflow cannot block another one — they run side by side and a red `test`
# workflow would have published anyway. Only a `needs:` edge inside THIS
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
# call the same scripts/run-tests.sh so they cannot drift.
#
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
# three named tests. 115 of the 116 test files under shater/** had never run in
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
#
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
# transport/wireguard compiles 1 test file without the tag set and 7 with it
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
# the named, reasoned exclusions are in scripts/run-tests.sh.
test:
name: test gate
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, sdk: x86_64-24.10.4 } # testbed VM (generic x86-64)
- { arch: aarch64_cortex-a53, sdk: mediatek-filogic-24.10.4 } # BPI-R3 + BPI-R4 (mediatek/filogic)
steps:
- name: Checkout
uses: actions/checkout@v4
# scripts/build-shaterd.sh builds the engine via a go.mod
# `replace => ./submodules/wireguard-go` (AmneziaWG fork), so that submodule
# must be present or `go build` dies with "no such file or directory".
# actions/checkout does not fetch submodules by default; init ONLY this one
# (clients/apple+android are large and unused here).
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
# this even `go list` fails. Same step/reason as in build-apk below.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
# Toolchain for scripts/build-shaterd.sh: Go (daemon), Node (Vite SPA), UPX.
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod # pins Go 1.24.7 (go.mod `go` line)
cache: false # explicit actions/cache@v3.3.2 below (setup-go's
# built-in cache uses the new API act_runner lacks)
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20' # Vite 5 needs Node 18+; 20 LTS
# ---- caches (see the header comment for keys + version pin rationale) ----
# Same cache key as build-apk: this job runs first, so it warms the module
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
@@ -153,94 +174,36 @@ jobs:
restore-keys: |
go-
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
# TypeScript run directly by `node --test`, and type stripping only exists
# from 22.6 — on node 20 `npm test` dies before running a single case.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
id: npm-cache
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
# NO restore-keys: node_modules must exactly match the lockfile;
# on any lockfile change this misses and `npm ci` runs fresh.
- name: Cache SDK dl/ (package sources)
uses: actions/cache@v3.3.2
with:
path: .cache/dl
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
restore-keys: |
dl-
- name: Panel tests
run: bash scripts/run-panel-tests.sh
# feeds git checkouts (see header): both 24.10.4 arch jobs share one entry
# (same release = same feeds.conf.default pins), so derive the release
# from the matrix sdk tag (x86_64-24.10.4 -> 24.10.4).
- name: Compute feeds cache key
id: feedskey
run: echo "ver=$(echo '${{ matrix.sdk }}' | sed 's/.*-//')" >> "$GITHUB_OUTPUT"
- name: Cache SDK feeds checkouts
uses: actions/cache@v3.3.2
with:
path: .cache/feeds
key: feeds-opkg-${{ steps.feedskey.outputs.ver }}
restore-keys: |
feeds-opkg-
- name: Cache CI tools (usign)
uses: actions/cache@v3.3.2
with:
path: .cache/tools
key: tools-usign-v1
- name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Build the SPA-embedded, static-musl, UPX'd shaterd for BOTH arches and
# stage dist/shaterd-<a>.upx into openwrt/shaterd/files/. MUST run before
# the SDK package build (the openwrt/shaterd package installs the staged
# artifact). VERSION is stamped into constant.Version. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact
env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
V="${GITHUB_REF#refs/tags/}"
else
V="v0.2.0-dev"
fi
FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh "$V" $FAST
# Compile the 4 packages through the arch-matched OpenWrt SDK and produce a
# signed per-arch opkg feed (Packages + Packages.gz + Packages.sig + .ipk).
- name: Build signed feed (SDK)
env:
KEY_BUILD: ${{ secrets.KEY_BUILD }}
run: bash ci/build-feed.sh "${{ matrix.arch }}" "${{ matrix.sdk }}" "out/${{ matrix.arch }}"
- name: Show feed
run: ls -l "out/${{ matrix.arch }}" && cat "out/${{ matrix.arch }}/Packages"
- name: Upload feed artifact
# v4 uses an artifact backend Gitea Actions does not implement
# (GHESNotSupportedError); v3 works on Gitea's act_runner.
uses: actions/upload-artifact@v3
with:
name: shater-${{ matrix.arch }}
path: out/${{ matrix.arch }}/*
if-no-files-found: error
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
# ---------------------------------------------------------------------------
# APK lane (additive): the same 4 packages through the ImmortalWrt 25.12
# apk-SDK for the 25.12/apk fleet (BananaWRT 25.12-mtk-vendor routers + the
# future 25.12 VM). Produces a per-arch apk repo dir: *.apk + EC-signed
# packages.adb + shater-apk.pem. Artifact prefix `apkfeed-` (NOT `shater-`)
# so the opkg release job's `artifacts/shater-*` glob never picks these up.
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
build-apk:
name: apk ${{ matrix.arch }}
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
# (with the guards in release-apk) leaves nothing published.
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -253,8 +216,14 @@ jobs:
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
steps:
# fetch-depth: 0 — the package version is DERIVED from the git tag
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
# shallow checkout has neither tags nor ancestry, so `git describe` would
# fail and every dispatch build would fall back to 0.0.0.
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
@@ -263,6 +232,13 @@ jobs:
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
# THE version step (bug B4). One computation, used by both the binary
# (constant.Version) and the three tag-versioned packages, exported to
# every later step of this job:
# tag vX.Y.Z -> X.Y.Z-r1 ; off-tag -> <last tag>-r<commits+1>
- name: Compute version from git tag
run: bash ci/version.sh --env >> "$GITHUB_ENV"
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -336,25 +312,36 @@ jobs:
restore-keys: |
feeds-apk-
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
# everything else in CI builds with the full upstream set, so without this
# step the one combination we actually ship is never exercised. That is how
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
# binary answered a WireGuard node with "gVisor is not included in this
# build" (2026-07-25). The check runs the declared-feature/tag comparison
# and then constructs one node of every declared protocol through box.New
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
# that breaks a feature fails the release instead of shipping.
- name: Verify the shipped build-tag set (D23)
run: bash scripts/check-router-tags.sh
- name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Same artifact-order contract as the opkg lane: the SPA-embedded shaterd
# binary is built OUT of the SDK and staged before the package build.
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
# the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
# (the openwrt/shaterd package only installs the staged artifact).
# $SHATER_VERSION (from the version step above) is stamped into
# constant.Version, so the binary and the package agree. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact
env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
V="${GITHUB_REF#refs/tags/}"
else
V="v0.2.0-dev"
fi
FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh "$V" $FAST
echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh $FAST
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
# sign the per-arch packages.adb with the EC key (secret KEY_APK).
@@ -377,120 +364,34 @@ jobs:
if-no-files-found: error
# ---------------------------------------------------------------------------
# Publish once both arches are built. Rolling `latest` on dispatch, a versioned
# release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API).
release:
name: release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all arch feeds
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Assemble release assets
id: assets
run: |
set -eu
mkdir -p release
# For each downloaded arch feed: one ready-to-serve tarball + loose ipks.
for d in artifacts/shater-*; do
[ -d "$d" ] || continue
arch="${d#artifacts/shater-}"
tar -C "$d" -czf "release/shater-feed-${arch}.tar.gz" .
# loose .ipk for direct `opkg install <url>` (dedupe shared _all ipks by name)
for ipk in "$d"/*.ipk; do
[ -e "$ipk" ] || continue
cp -n "$ipk" "release/$(basename "$ipk")"
done
done
# ship the feed's public key so routers can verify (see docs-shater/INSTALL.md)
cp -f dist/shater-feed.pub release/shater-feed.pub
ls -l release
echo "count=$(ls release | wc -l)" >> "$GITHUB_OUTPUT"
# restore the prebuilt usign binary (skips apt + cmake + clone + build)
- name: Cache CI tools (usign)
uses: actions/cache@v3.3.2
with:
path: .cache/tools
key: tools-usign-v1
- name: Install usign (feed signer)
run: bash ci/install-usign.sh
- name: Build & sign combined opkg feed index
# One Packages/Packages.gz over ALL loose .ipk (every arch + arch=all),
# with basename Filenames. opkg filters by Architecture, so a single
# release URL serves every device: BPI routers pick aarch64_cortex-a53 +
# all, the x86 testbed picks x86_64 + all. Signed with KEY_BUILD so
# routers keep check_signature on. This is what makes the release directly
# consumable as an `src/gz` feed (see docs-shater/INSTALL.md).
env:
KEY_BUILD: ${{ secrets.KEY_BUILD }}
run: bash ci/make-index.sh release
- name: Determine release identity
id: rel
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "name=shater ${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
echo "rolling=false" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
echo "name=shater latest (main)" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "rolling=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish Gitea release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
TAG: ${{ steps.rel.outputs.tag }}
NAME: ${{ steps.rel.outputs.name }}
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }}
BODY: |
Automated build. Packages: shaterd + byedpi (per-arch), shater-core +
luci-app-shater (arch=all).
Targets: x86_64 (testbed) and aarch64_cortex-a53 (BPI-R3 + BPI-R4, mediatek/filogic).
── Add as an opkg feed (recommended — then `opkg upgrade` just works) ──
This release is itself a SIGNED package feed; opkg filters by
architecture, so the same lines work on every device:
wget -O /etc/opkg/keys/5ac4b177689cb8e0 https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
opkg update
opkg install luci-app-shater # pulls shater-core + shaterd too
The public-key install is one-time; after it, `opkg update/upgrade`
verify the signature with check_signature left on. Full guide: docs-shater/INSTALL.md.
── Or install the loose .ipk directly / from the tarball feed ──
wget -O /tmp/f.tgz <this release>/shater-feed-aarch64_cortex-a53.tar.gz
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
opkg install /tmp/shater/luci-app-shater_*_all.ipk
run: bash ci/gitea-release.sh release/*
# ---------------------------------------------------------------------------
# Publish the apk lane: ONE release PER ARCH (apk package filenames carry no
# arch, and apk fetches `<name>-<ver>.apk` relative to the packages.adb URL —
# a flat multi-arch release would collide). Rolling `apk-latest-<arch>` on
# dispatch, `apk-<tag>-<arch>` on a version tag. The tags do NOT match the
# workflow's `v*` trigger, so publishing them cannot re-trigger the build.
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
# multi-arch release would collide). Every run refreshes the ROLLING pointer
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
# publishing them cannot re-trigger the build.
#
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
# it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
# while v0.2.9/v0.2.10 published fine, and every router whose
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
# successful, silent `apk update` with nothing new. Rolling is the whole point
# of that URL, so it is now written unconditionally and asserted afterwards.
release-apk:
name: release apk
needs: build-apk
needs: [test, build-apk]
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
if: ${{ !cancelled() }}
#
# `needs.test.result == 'success'` is the second half of the gate. Without
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
# skipped), this job runs with no artifacts at all, and — see the guard at
# the end of the publish step — used to exit 0 having published nothing. Red
# tests must SKIP this job, not "succeed" through it.
if: ${{ !cancelled() && needs.test.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -501,6 +402,9 @@ jobs:
with:
path: artifacts
# Identity of the VERSIONED release only. The rolling pointer is published
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
# from here.
- name: Determine release identity
id: rel
run: |
@@ -522,25 +426,113 @@ jobs:
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }}
run: |
set -eu
set -euo pipefail
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
# was the step's whole body: with no artifacts the glob stayed
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
# ended and the step exited 0 — "release apk" went GREEN having
# published absolutely nothing. Any upstream failure (all arches
# failing to build, an artifact-name change, a download-artifact
# hiccup) therefore looked like a successful release.
published=0
for d in artifacts/apkfeed-*; do
[ -d "$d" ] || continue
arch="${d#artifacts/apkfeed-}"
if [ "$VER" = latest ]; then TAG="apk-latest-$arch"; else TAG="apk-$VER-$arch"; fi
ROLL="apk-latest-$arch"
# The version we just built, read straight off the artifact
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
# this job checks out shallow, so it has no tags to describe from.
pkg=""
for a in "$d"/shaterd-*.apk; do
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
done
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
want="${pkg#shaterd-}"; want="${want%.apk}"
echo "[release-apk] arch=$arch built version=$want"
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
Packages: shaterd + byedpi (per-arch), shater-core + luci-app-shater (arch=all).
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
This build: \`$want\`.
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
── Add as an apk repository (auto-updates via \`apk upgrade\`) ──
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$TAG/shater-apk.pem
── Add as an apk repository (rolling — install once, then just update) ──
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
apk update
apk add luci-app-shater # pulls shater-core + shaterd too
apk add byedpi # optional: ByeDPI desync egress
Update: apk update && apk upgrade shaterd shater-core luci-app-shater byedpi
Full guide: docs-shater/INSTALL.md §6. The opkg/24.10 feed lives in the \`latest\` release."
echo "[release-apk] publishing $TAG from $d"
TAG="$TAG" NAME="shater apk $VER ($arch)" BODY="$BODY" \
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
assets, so the same repo line keeps serving the newest build. To pin a
version instead, point the repo line at
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
file must be edited by hand for each upgrade.
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
apk update
apk upgrade shaterd shater-core luci-app-shater
A bare \`apk upgrade\` reconciles EVERY installed package against every
configured repo and can downgrade unrelated system packages; naming them
upgrades only those (apk-tools 3: \"If list of packages is provided, only
those packages are upgraded along with needed dependencies\").
Full guide: docs-shater/INSTALL.md §5."
# 1) the pinnable versioned release (tag runs only)
if [ "$VER" != latest ]; then
echo "[release-apk] publishing apk-$VER-$arch from $d"
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
bash ci/gitea-release.sh "$d"/*
fi
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
# deletes the existing release before recreating it, so the old
# version's assets are REPLACED, never accumulated (two versions of
# one package in one index would let apk choose, not us).
echo "[release-apk] publishing $ROLL from $d"
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
PRERELEASE=true ROLLING=true \
bash ci/gitea-release.sh "$d"/*
# 3) ASSERT the rolling release really serves THIS build — same class
# of check as ci/sdk-build-apk.sh's package-version assert, and for
# the same reason: the previous failure mode was silent. Reads the
# published release back over the API and requires our three
# tag-versioned packages at $want, the index, the key — and NO
# left-over package asset at any other version.
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
| tr '{},' '\n\n\n' \
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
# here-string, NOT `printf | grep -q`: under `pipefail` the early
# exit of grep -q can SIGPIPE the writer and fail a passing check.
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
grep -qxF "$f" <<<"$got" || {
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
echo " A router pinned to the rolling URL would have silently"
echo " stayed on its old version with a successful apk update."
exit 13; }
done
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
-e "luci-app-shater-$want.apk" || true)"
[ -z "$stale" ] || {
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
printf ' %s\n' $stale
echo " Two versions of one package in one feed = apk picks by its"
echo " own rules, not by our intent."
exit 14; }
echo "[release-apk] OK — $ROLL serves $want"
published=$((published + 1))
done
# The assert the loop above never had. Zero feeds published is a failed
# release, not a quiet success — say so with a non-zero exit.
if [ "$published" -eq 0 ]; then
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
echo " NOTHING was published. Downloaded tree:"
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
exit 10
fi
echo "[release-apk] published $published arch feed(s)"
+85
View File
@@ -0,0 +1,85 @@
# Shater — the test gate, on every push to `main`.
#
# WHY THIS FILE EXISTS (2026-07-26)
# The fork had a full suite and no CI that ran it. Upstream's
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
# the 116 test files under shater/** had never once executed in CI, and
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
# releases.
#
# RELATIONSHIP TO release.yml
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
# gate: a separate workflow cannot block another one. The gate is the `test`
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
# drift apart.
name: test
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs-shater/**'
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: go + panel tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
# starts. Same step, same reason, as in release.yml's build job.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# v3.3.2 is the last release speaking the cache API act_runner implements
# (see the header of release.yml). Same key as the release build job, so
# whichever runs first warms the other.
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
# through `node --test`, and type stripping only exists from 22.6. On
# node 20 `npm test` dies with a syntax error before running anything.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
+20 -14
View File
@@ -1,28 +1,34 @@
#!/usr/bin/env bash
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
set -e -o pipefail
set -euo pipefail
ARCH=$1
DEB_SRC=$2
OUT_IPK=$3
PROJECT=$(dirname "$0")/../..
TMP_PATH=`mktemp -d`
cp $2 $TMP_PATH
pushd $TMP_PATH
TMP_PATH=$(mktemp -d)
trap 'rm -rf "$TMP_PATH"' EXIT
DEB_NAME=`ls *.deb`
ar x $DEB_NAME
cp "$DEB_SRC" "$TMP_PATH"/
pushd "$TMP_PATH" >/dev/null
# Derive the name from the file we copied — do not glob-parse `ls *.deb`.
DEB_NAME=$(basename "$DEB_SRC")
ar x "$DEB_NAME"
mkdir control
pushd control
pushd control >/dev/null
tar xf ../control.tar.gz
rm md5sums
sed "s/Architecture:\\ \w*/Architecture:\\ $1/g" ./control -i
rm -f md5sums
sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
cat control
tar czf ../control.tar.gz ./*
popd
popd >/dev/null
DEB_NAME=${DEB_NAME%.deb}
tar czf $DEB_NAME.ipk control.tar.gz data.tar.gz debian-binary
popd
tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
popd >/dev/null
cp $TMP_PATH/$DEB_NAME.ipk $3
rm -r $TMP_PATH
cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
+8 -1
View File
@@ -36,8 +36,15 @@ nul
# playwright MCP screenshots/snapshots
.playwright-mcp/
# working-session screenshots dropped in the repo root (not shipped docs)
/*.png
# throwaway build binaries / scratch staged under tmp/
/tmp/
# -- upstream sing-box-lx -----------------------------------------
/.idea/
.idea/
/vendor/
/*.json
/*.srs
@@ -56,7 +63,7 @@ nul
/venv/
/test/cache.db
# feed artifacts (tracked public key dist/shater-feed.pub is force-added)
# feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
/dist/
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
+7 -1
View File
@@ -7,4 +7,10 @@
[submodule "submodules/wireguard-go"]
path = submodules/wireguard-go
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
branch = lx
# The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
# conn/bind_std.go at all, so a `git submodule update --remote` against it
# would silently restore the bug where ClientBind/StdNetBind shred the
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
# the line the pin is actually on.
branch = lx-awg2-v005
+141 -47
View File
@@ -6,61 +6,155 @@
## Правила делегирования
1. ЛЮБАЯ реализация (код, тесты, конфиги, рефакторинг, отладка) выполняется
субагентами через инструмент Agent с `model: "opus"`. Сам ты правишь файлы
только в одном случае: тривиальная правка в 1–2 строки, где постановка
задачи дороже самой правки.
субагентами через инструмент Agent. Сам ты правишь файлы только в одном
случае: тривиальная правка в 1–2 строки, где постановка задачи дороже самой
правки.
2. Перед делегированием ты сам исследуешь код настолько, чтобы написать
точное ТЗ. В каждом задании субагенту обязательно указывай:
- контекст: что это за проект и над чем идёт работа;
- конкретные файлы и функции, которые нужно менять (пути, а не «найди сам»);
2. **Модель выбирает исполнитель задачи, а не привычка.** `fable` — быстрый и
дешёвый, годится для механической работы с ясным контрактом. `opus` — для
всего, где нужно рассуждение: поиск причины, аудит, дизайн, работа в чужом
коде. Если у `fable` кончилась квота — молча переходи на `opus`, это не повод
останавливать работу. Не спрашивай владельца, какую модель брать.
3. Перед делегированием ты сам исследуешь код настолько, чтобы написать точное
ТЗ. В каждом задании субагенту обязательно указывай:
- контекст: что за проект и над чем идёт работа;
- конкретные файлы и функции (пути, а не «найди сам»);
- контракт: сигнатуры, форматы данных, инварианты, что менять НЕЛЬЗЯ;
- definition of done: как проверить, что задача выполнена
(какие команды/тесты прогнать и какой ожидается результат);
- что вернуть в финальном ответе: список изменённых файлов, результаты
проверок, найденные проблемы и принятые решения.
- definition of done: какие команды прогнать и какой ждать результат;
- что вернуть: изменённые файлы, результаты проверок, найденные проблемы,
принятые решения.
3. Скиллы: при постановке задачи посмотри список доступных скиллов и ЯВНО
перечисли в ТЗ, какие скиллы субагент обязан вызвать через инструмент Skill
до начала работы (например: «сначала вызови Skill "openwrt-procd-services"
и следуй ему»). Субагент не видит наш диалог и сам не догадается — пиши
названия скиллов прямо в текст задания.
4. **Скиллы использовать по максимуму — и тебе, и агентам.** Это не
формальность: в них лежит выстраданное знание по ровно тем предметным
областям, в которых мы работаем, и игнорировать их — значит переоткрывать
чужие грабли. См. раздел «Скиллы» ниже.
4. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
сообщении, каждый с `model: "opus"`. Зависимые — последовательно, передавая
в следующее ТЗ результаты предыдущего.
5. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
сообщении. Зависимые — последовательно, передавая результаты предыдущего.
**Делишь файлы между параллельными агентами явно** и пишешь каждому, кто ещё
работает в дереве и что трогать нельзя. Запрещай им `git stash`,
`git checkout <файл>`, `git reset` — в этом проекте агент уже сносил правки
соседа через `git stash push`.
5. Приёмка: результат каждого субагента ты проверяешь сам (читаешь diff
ключевых мест, гоняешь проверки из definition of done). Если результат
не принят — не переделывай сам, а верни задачу: доработку заказывай тому же
агенту через SendMessage (у него сохранён контекст), а не новым спавном.
6. Приёмка: результат каждого субагента ты проверяешь сам — читаешь diff
ключевых мест, гоняешь проверки из definition of done. Не принимай отчёт на
слово: сегодня отчёт «тесты зелёные» дважды сопровождался тестом, который
ничего не прибивал. Если результат не принят — не переделывай сам, а верни
задачу тому же агенту через SendMessage (у него сохранён контекст).
6. Финальный отчёт пользователю: что сделано, кем (сколько агентов),
что проверено, что осталось.
7. Финальный отчёт владельцу: что сделано, сколько агентов, что проверено,
**что осталось непроверенным и почему** — последнее так же важно.
## Инженерные стандарты
Это не пожелания. Каждый пункт здесь появился после того, как его отсутствие
стоило рабочего дня.
- **Тест обязан быть проверен мутацией.** Откатить фикс → показать, что тест
падает, и с каким текстом → вернуть фикс. Тест, не падающий на сломанном коде,
не тест, а украшение.
- **Прибор без контроля не доказывает ничего.** Отрицательный результат чего-то
стоит, только если показано, что этот же прибор умеет дать положительный.
«Утечки не нашли» прибором, который не мог её увидеть, — это не результат.
- **Опровержение ценнее согласия.** В каждом ТЗ прямо разрешай субагенту
сказать «твоя версия неверна» и требуй доказательства, а не вежливости.
Лучшие результаты этого проекта приходили именно так.
- **Не обещать непроверенного.** Комментарий, предупреждение и текст в панели —
это утверждения о поведении. Если поведение не проверено, так и писать.
Формально верная фраза, которая читается как «работает», — тоже ложь.
- **Умолчание падает в восстановимую сторону.** Открытый `default:` в разборе
вариантов — источник целого класса дефектов: неучтённое значение уходит туда,
где дороже всего ошибиться. Списки делать положительными и закрытыми.
- **Проверка присутствия обязана покрывать всё, что ставит её Apply-двойник.**
Иначе идемпотентный быстрый путь становится ловушкой: «всё на месте» при
отсутствующем маршруте.
- **Никакого молчаливого скипа.** Тест, который не выполнился, обязан быть
назван поимённо в выводе гейта. Однажды CI гонял два теста из 116 файлов, и
все считали, что покрыто.
## Скиллы
**Правило: если задача касается области, по которой есть скилл, — скилл
вызывается ДО начала работы, а не после того, как что-то не заработало.**
Это относится и к тебе, и к каждому субагенту.
Субагент не видит наш диалог и сам не догадается, что скиллы существуют.
Поэтому **в каждом ТЗ перечисляй поимённо**, какие скиллы он обязан вызвать
через инструмент Skill: «сначала вызови Skill "openwrt-nftables" и Skill
"openwrt-networking", следуй им». Требуй в отчёте сказать, что именно из скилла
он применил, — так видно, вызвал он его или упомянул.
Соответствие областей этого проекта и скиллов:
| Трогаешь | Обязательные скиллы |
|---|---|
| `/etc/config/*`, `uci`, uci-defaults, парсер модели | `openwrt-uci` |
| nftables, fw4, зоны, метки, tproxy, kill-switch | `openwrt-nftables` |
| интерфейсы, мосты, VLAN, policy routing, `ip rule`, sysctl, dnsmasq | `openwrt-networking` |
| init-скрипты, procd, respawn, service triggers, boot armor | `openwrt-procd-services` |
| перехват трафика целиком (tproxy + маршрутизация + DNS) | `openwrt-transparent-proxy` |
| сборка пакетов, SDK, фид, CI, подпись, `apk`/`opkg` | `openwrt-package-build-ci`, `openwrt-native-packages` |
| LuCI-приложение, ubus/rpcd, ucode | `openwrt-luci-plugin`, `openwrt-ubus-rpcd`, `openwrt-ucode` |
| панель (React/TS) | `react-expert`, `frontend-design:frontend-design` |
| Go: конкурентность, каналы, профилирование, идиоматика | `fullstack-dev-skills:golang-pro` |
| TypeScript | `fullstack-dev-skills:typescript-pro` |
| стратегия тестирования, покрытие, тестовые данные | `fullstack-dev-skills:test-master` |
| поиск причины по логам и трассам | `fullstack-dev-skills:debugging-wizard` |
| проверка в браузере, скриншоты | `fullstack-dev-skills:playwright-expert` |
| ревью | `review`, `fullstack-dev-skills:code-reviewer` |
| безопасность | `security-review`, `fullstack-dev-skills:security-reviewer` |
| графики и визуализация данных | `dataviz` |
Список неполный — **смотри доступные скиллы под задачу**, а не только в эту
таблицу. Если скилл выглядит смежным, дешевле вызвать его и не воспользоваться,
чем не вызвать и потом отлаживать то, что там уже описано.
## Проверки
- **Гейт:** `bash scripts/run-tests.sh` — Linux в Docker, боевой набор тегов,
`-race`, и шаг, требующий вердикта по имени для привилегированных тестов.
Зелёный гейт — необходимое условие, но не достаточное: он не видит стыков с
ядром, procd и nftables.
- **Стенд:** сервер `local_openwrt` в ssh-manager — ImmortalWrt 25.12.1 той же
ревизии, что боевой роутер. Сюда — всё, что касается init-скриптов, nft,
policy routing, TUN.
- **Боевой роутер:** `mini_router` (BPI-R3), через него идёт весь домашний
трафик. Перед изменением конфигурации — резервная копия. Проверять приборно,
а не по логу: лог может печатать одно и то же в честном и в ложном случае.
## Релиз и деплой
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
`apk upgrade shaterd shater-core luci-app-shater`.
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
иначе.
## Фронтенд (admin panel)
Дизайн-направление ЗАФИКСИРОВАНО: **Faceplate** (панель сетевого железа).
Полная спека, токены, компоненты и ссылка на живой эталон — в
[`docs-shater/DESIGN.md`](docs-shater/DESIGN.md). Эталон:
https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
Спека, токены и компоненты — в [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md).
Эталон: https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
- **Стек:** Vite + React + TypeScript, лёгкий (SPA встраивается в бинарь —
без тяжёлых зависимостей). Расположение: папка `panel/` в корне.
- **Порядок работ:**
1. Сам (оркестратор) скаффолдишь `panel/`, переносишь токены из
`docs-shater/DESIGN.md` в `panel/src/tokens.css` один-в-один и задаёшь каркас
компонентов. Это фундамент — делай аккуратно сам или отдай ОДНОМУ агенту.
2. Дизайн-систему в компоненты: `<Faceplate> <Module> <Toggle> <Led>
<SegMeter> <QueryLog>` + кнопки — строго по эталону.
3. Страницы раздаёшь ПАРАЛЛЕЛЬНО Opus-агентам (`model: "opus"`), по одной на
агента: Overview, Nodes/Subscriptions, Routing rules, DNS/Blocklists,
Devices, Apply/Rollback.
- **В КАЖДОМ ТЗ агенту обязательно:** ссылка на `docs-shater/DESIGN.md` и на эталон;
требование сначала вызвать Skill `react-expert` и Skill
`frontend-design:frontend-design` и следовать им; список готовых компонентов,
которые он ДОЛЖЕН переиспользовать (не изобретать заново); какие токены и
семантические цвета применять; DoD — страница совпадает с языком эталона,
адаптив + фокус + reduced-motion соблюдены.
- **Не отходить от Faceplate.** Любой новый экран наследует ту же визуальную
систему. Оранжевый — только акцент; семантика good/warn/crit — отдельно.
- **Стек:** Vite + React + TypeScript в `panel/`. SPA встраивается в бинарь —
тяжёлые зависимости недопустимы.
- **Панель целиком на английском.** Ни одного символа кириллицы в `panel/src`.
- **В КАЖДОМ ТЗ на панель:** ссылка на `DESIGN.md` и на эталон; требование
сначала вызвать Skill `react-expert` и Skill
`frontend-design:frontend-design`; список существующих компонентов, которые
надо ПЕРЕИСПОЛЬЗОВАТЬ (`<Faceplate> <Module> <Toggle> <Led> <SegMeter>
<QueryLog>` и кнопки), а не изобретать заново; какие токены и семантические
цвета применять; DoD — совпадение с языком эталона, адаптив, фокус,
`prefers-reduced-motion`.
- Оранжевый — только акцент; семантика good/warn/crit — отдельно.
- **Панель не должна врать про состояние.** Значение, которое движок примет,
не может рисоваться как «never matches»; настройка, которой управляет другая
подсистема, не может описываться так, будто управляет ею.
+130
View File
@@ -0,0 +1,130 @@
<!-- Language: [Русский](README.md) · **English** -->
# shater
**A self-hosted internet-control appliance for OpenWrt routers.** One box turns a
home or office network into a transparent VPN gateway, a network-wide
ad/tracker/malware blocker, per-device parental control, and a live traffic
dashboard — all local, all configured from a rich built-in web panel.
> The primary README is Russian — [README.md](README.md). This is a condensed
> English mirror.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
## What it is
shater is a network proxy stack for **OpenWrt / ImmortalWrt / BananaWRT** routers
(Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic
through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and
is managed from a built-in web panel.
The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — exactly what
`shater/parse` can read and `shater/registry` registers in the engine.
A thin **LuCI launcher** (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
(default `:8088`).
## Highlights
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks
— `:53` interception is on by default and covers the queries a client sends to the
router itself, not just the ones aimed around it (`globals.dns_intercept`, D24).
- First-match routing by source / destination / list / geo / client → outbound /
selector / chain / direct / block; node groups with balancer/observatory;
multi-hop chains; per-rule egress.
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
`inet shater` nft table; atomic apply with `nft -c` validation. Commit-confirm
auto-rollback exists but **ships OFF** (`confirm_timeout=0`) — arm it yourself.
- **DNS filtering & blocklists** with flexible sources (inline / file / url /
geosite), compiled `.srs` matcher; Block-DoH/DoT to stop filter bypass.
- Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow,
schedules) and per-domain/client/device statistics from in-process DNS events.
Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
## Install
One signed **apk** feed (OpenWrt / ImmortalWrt / BananaWRT **25.12+**), one
release per arch. Verbatim commands, the manual `.apk` install and the
rolling-vs-pinned choice are in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
```sh
wget -O /etc/apk/keys/shater-apk.pem "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" > /etc/apk/repositories.d/shater.list
apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
shater ships **inert** (globals off) so install never breaks connectivity. After
configuring nodes/rules:
```sh
uci set shater.globals.enabled=1
uci set shater.globals.confirm_timeout=120 # commit-confirm ships OFF — arm it
uci commit shater
shaterd apply && shaterd confirm
```
Without that middle line `shaterd apply` arms no auto-rollback (and says so), so an
apply that costs you SSH/LuCI access has to be undone by hand.
Once an enabled, fail-closed config has been applied, `/etc/init.d/shater-armor`
loads a saved fail-closed plane at **boot**, before the daemon exists: LAN→WAN
forwarding is blocked until `shaterd` applies, while SSH/LuCI/the panel stay
reachable on purpose (the chain hooks `forward` only). What arms it, what refuses
to arm, and how to switch it off — `INSTALL.md` §4.
## Build from source
`scripts/build-shaterd.sh [VERSION] [--fast]` builds the SPA (Vite), embeds it via
`//go:embed`, cross-builds musl-static `{amd64, arm64}` and UPX-packs the artifact
into `openwrt/shaterd/files/`. Details in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
`bash scripts/run-tests.sh` is the test gate: the whole suite under the **shipped**
build tags (`scripts/router-tags.sh`), on linux (it re-execs in Docker from a
non-linux host), with `-race`, plus three machine checks against a silent skip —
the tag set may only add test files, every package with tests must report `ok` by
name, and every `TestIntegration*` must produce a verdict by name.
`scripts/check-router-tags.sh` separately proves no feature declared in
`FEATURES.md` lost a build tag it needs. A green gate is necessary but not
sufficient: it does not see the kernel, procd or nftables seams.
## Repository layout
| Path | What |
|------|------|
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Product documentation |
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
| `docs/`, `mkdocs.yml` | **Upstream** sing-box docs (mkdocs) — kept as-is |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | sing-box-lx engine tree |
## CI, upstream & license
CI (`.gitea/workflows/release.yml`) builds all 4 packages and publishes a signed
per-arch apk repo (EC key `shater-apk.pem`). A `vX.Y.Z` tag → the pinnable
`apk-vX.Y.Z-<arch>`; every run also refreshes the rolling `apk-latest-<arch>` and
asserts over the API that it really serves the version just built.
The engine is the **sing-box-lx** fork — a thin downstream of upstream sing-box that
lives by **rebase, never merge**; its constitution is
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md). Licensed under
[GPL-3.0](LICENSE), like upstream sing-box. Unofficial fork, not affiliated with
SagerNet.
+344 -51
View File
@@ -1,71 +1,364 @@
<!-- Язык: **Русский** · [English](README.en.md) -->
# shater
**A self-hosted internet-control appliance for OpenWrt.** One box turns your
network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker,
per-device parental control, and a live traffic dashboard — configured from a rich
web admin panel, all local.
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
трекеров и вредоносных доменов, средство родительского контроля по устройствам
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
настраивается из богатой веб-панели.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![status: v0.2 in development](https://img.shields.io/badge/status-v0.2%20in%20development-orange.svg)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
![feed: apk 25.12+](https://img.shields.io/badge/feed-apk%2025.12%2B-orange.svg)
> ⚠️ **v0.2 is under active development on a new foundation.** The previous,
> complete and VM-verified xray-based version lives on the **[`v0.1`](../../src/branch/v0.1)**
> branch and still installs from the signed feed.
---
## What v0.2 is
## Что это
shater v0.2 is built as a **fork of [sing-box](https://github.com/SagerNet/sing-box)
(via [sing-box-lx](https://github.com/Leadaxe/sing-box-lx))** with our whole
product embedded in the one binary: the proxy engine, a control plane, a DNS
filter, and a full admin panel. Riding sing-box gives a broad, up-to-date protocol
set — VLESS/VMess/Trojan/Shadowsocks, Reality, **AmneziaWG 2.0**, Hysteria2, TUIC —
without reinventing the anti-DPI arms race.
**shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
веб-панели.
The UI is split for both integration and a great experience: a **thin LuCI app**
(a small dashboard + an "Open panel" button) hands a short-lived token to a
**standalone admin panel** the daemon serves on its own port — so panel auth is
bootstrapped from LuCI's existing login, and the real UX is a modern SPA we fully
own.
Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — ровно то, что умеет разобрать
`shater/parse` и что регистрирует `shater/registry` в движке.
## Highlights (planned)
Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
поднятую демоном на собственном порту (по умолчанию `:8088`).
- Transparent TPROXY proxy (TCP+UDP), split by domain/geo/client, no DNS leaks.
- Broad protocols incl. **AmneziaWG 2.0**, Reality, Hysteria2, TUIC.
- Network-wide **DNS blocklists** with flexible sources (inline / file / url /
geosite) and an efficient matcher for million-entry lists.
- **Per-domain, per-client, per-device statistics** — fed by the engine's DNS
events in-process (no log scraping).
- **Per-device control**: block a site for one device or everyone; per-device
exit/proxy toggles; schedules; alerts.
- Fail-closed kill-switch, atomic apply with commit-confirm rollback, signed opkg
feed.
---
See **[`docs-shater/FEATURES.md`](docs-shater/FEATURES.md)** for the full list.
## Ключевые возможности
## Documentation
**Прозрачный прокси и маршрутизация**
- TPROXY data-plane для нескольких LAN-интерфейсов (TCP + UDP), сниффинг
SNI/Host/QUIC, без утечек DNS.
- Правила маршрутизации по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
(domain/suffix/keyword/geosite), спискам, порту, протоколу →
outbound / selector / chain / direct / block.
- Группы узлов с балансировщиком/обсерваторией (least-ping / failover /
round-robin), **мульти-хоп цепочки** и выбор egress по правилу.
| Doc | What |
|-----|------|
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Start here** — project context, v0.1→v0.2 history, decisions in brief, testbed/infra |
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Phased plan (Phase 1 = fork + embedding prototype) |
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Full feature list with MVP/T1/T2 tags |
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary design, auth handoff, data/DNS/apply flow (diagrams) |
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Why sing-box, why fork, why the panel split, license, etc. |
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Admin-panel visual system — the "Faceplate" direction, tokens, components, north-star prototype |
**Надёжность («железно»)**
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
трогаем.
- Атомарный apply с валидацией движком и `nft -c`. **Commit-confirm** с
авто-откатом к последней рабочей конфигурации есть, но **на стоковой установке
выключен**: `confirm_timeout` поставляется нулём, и apply не вооружает ничего,
пока вы не зададите окно (см. «Включение»).
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
(SSH/LuCI/LAN) всегда в обход.
## Status
**DNS, фильтрация, блокировки**
- Перехват `:53`, DNS движка sing-box в процессе; резолверы DoH/DoT/plain/FakeIP,
выбор резолвера по домену.
- **Блок-листы с гибкими источниками**: `inline` / `file` / `url` (авто-обновление) /
категория `geosite`; hosts-файл, plain-список или AdBlock-стиль `||domain^`
компилируются в локальный `.srs`. Эффективный компилированный матчер вместо
dnsmasq-мегасписков.
- **Block-DoH/DoT** — не даёт устройствам обходить фильтр через свой шифрованный DNS.
Foundation reset complete: v0.1 preserved on its branch, `main` reset for v0.2.
Next is **Phase 1** — fork sing-box-lx into `main` and stand up the embedding
prototype (prove AmneziaWG 2.0, measure binary size). Follow `docs-shater/ROADMAP.md`.
**Подписки и узлы**
- Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
интервал обновления + вручную + на загрузке; стабильная идентичность узла между
обновлениями; квоты/срок из `subscription-userinfo`.
- Ручные узлы: share-ссылки, импорт файла, `wg-quick`/AmneziaWG `.conf`.
- Health board: TCP + реальная проба через прокси-путь, exit-IP, «протестировать
все».
## Hardware
**Контроль по устройствам**
- Авто-обнаружение устройств (dhcp.leases + `ip neigh`), имена, живой статус/трафик.
- Тумблеры на устройство: прокси on/off, блок-листы on/off, страна/узел выхода;
блок/allow домена для одного устройства или для всех; расписания.
`aarch64_cortex-a53` covers Banana Pi **BPI-R3** (MT7986/Filogic 830) and **BPI-R4**
(MT7988/Filogic 880), both the OpenWrt `mediatek/filogic` target. `x86_64` is the
QEMU test VM.
**Статистика и видимость**
- Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
устройствам, таймлайны — из DNS-событий движка в процессе (без скрейпинга логов).
- Трафик по клиенту/узлу/правилу (байты) из nft-счётчиков; живой query-log.
## License
**Панель и профили**
- Встроенная SPA-панель (собственный порт, вшита в бинарь): overview, узлы и
подписки, правила маршрутизации, DNS/блок-листы, устройства, apply/rollback.
- Именованные профили/сцены и WAN-профили (условные оверрайды).
[GPL-3.0](LICENSE) (sing-box is GPL-3.0). See `docs-shater/DECISIONS.md` D6.
Полный список с тегами MVP/T1/T2 — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
---
## Архитектура
Один бинарь `shaterd` держит движок, control-plane, DNS-фильтр и веб-сервер панели
в одном процессе; OpenWrt-обвязка (тонкий LuCI + procd/system glue) оборачивает его.
Конфиг — UCI desired-state; демон рендерит его в конфиг движка и применяет;
телеметрия течёт обратно в панель.
```mermaid
flowchart TB
subgraph BIN["shaterd — один бинарь (форк sing-box-lx)"]
ENG["движок sing-box\nпротоколы · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
CTRL["control-plane (shater/)\nUCI-модель · генерация конфига · apply/rollback · nft/routing"]
FILT["DNS-фильтр + блок-листы + политика по устройствам (shater/)"]
STAT["агрегатор статистики (shater/)"]
PANEL["веб-сервер панели + вшитая SPA (свой порт, токен-auth)"]
end
subgraph WRT["OpenWrt-обвязка (openwrt/)"]
LUCI["тонкий LuCI — мини-дашборд + кнопка «Открыть панель»"]
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
end
LUCI -->|"ubus: mint token"| PANEL
PROCD --> BIN
CTRL --> ENG
FILT --> ENG
ENG --> STAT
STAT --> PANEL
```
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
(проксировано) · direct (обычный маршрут, без туннеля) · block. TPROXY несёт
только TCP и UDP; ICMP и остальные протоколы — через отдельные опциональные
механизмы (`l3_tunnel`, `untunnelable_egress`, ARCHITECTURE §3a). Подробные
диаграммы (auth-handoff, data-plane, DNS-flow, apply-flow) — в
[`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
---
## Установка
shater поставляется одним подписанным **apk-фидом** (OpenWrt / ImmortalWrt /
BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/apk/keys/`).
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
`shaterd` подтягивается автоматически как зависимость.
### Фид apk
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
```sh
# 1) доверяем ключу apk-фида (любое имя *.pem под /etc/apk/keys подходит).
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) добавляем репозиторий — строка указывает на сам ФАЙЛ-ИНДЕКС packages.adb.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
```
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
пакеты.
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater
# эквивалент, дополнительно закрепляющий пакеты в world:
# apk add -u shaterd shater-core luci-app-shater
```
Документация apk-tools 3 про `apk upgrade`: *«If list of packages is provided,
only those packages are upgraded along with needed dependencies»*. Проверить
установленные версии: `apk list -I shaterd shater-core luci-app-shater`.
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
> «поставил и забыл»: `apk update` сам видит новую сборку. `apk-vX.Y.Z-<arch>` —
> фиксация на конкретной сборке: роутер не получит ничего нового, пока
> `/etc/apk/repositories.d/shater.list` не отредактируют руками — на каждом
> роутере и на каждый релиз. На `mini_router` сознательно прописан
> версионированный URL, и ручная правка — его цена. Подробнее —
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §5.1.
> Версии пакетов CI берёт из git-тега (`vX.Y.Z` → `X.Y.Z-r1`, сборка вне тега →
> `X.Y.Z-r<коммитов+1>`), поэтому каждая новая сборка действительно видна
> менеджеру пакетов как новая. Подробности — `docs-shater/INSTALL.md` §2.1.
> Полные инструкции — ручная установка из `.apk`, фиксация версии
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Включение
shater ставится **инертным** (globals выключены), чтобы установка не рвала связь.
Настройте узлы/правила (через панель или `uci`), затем включите и примените:
```sh
uci set shater.globals.enabled=1
# Предохранитель: commit-confirm поставляется ВЫКЛЮЧЕННЫМ (confirm_timeout=0),
# и без этой строки apply ничем не подстрахован. 120 с — окно на проверку связи.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply # применить и вооружить авто-откат на 120 с
shaterd confirm # подтвердить в пределах окна (отменяет авто-откат)
```
`shaterd apply` печатает, вооружил ли он что-нибудь, и почему нет: при
`confirm_timeout=0` он прямо говорит, что автоматического отката НЕТ. Оставить
ноль — сознательный выбор: тогда apply, отрезавший вам SSH/LuCI, придётся
откатывать руками.
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
панель (`:8088` по умолчанию).
После первого же применённого включённого fail-closed конфига появляется
**загрузочная защита**: `/etc/init.d/shater-armor` (START=21) грузит сохранённый
fail-closed план ещё до старта демона, закрывая те секунды между поднятием LAN и
первым apply, когда роутер форвардил трафик в WAN открытым. Форвардинг LAN→WAN
заблокирован, пока `shaterd` не применит конфиг; SSH, LuCI и панель при этом
доступны **намеренно** — цепочка вешается только на `forward`. Чем защита
вооружается, когда отказывается вооружаться и как её снять —
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §4.
---
## Сборка из исходников
Ship-артефакт — бинарь `shaterd` со вшитой SPA. Собирается вне дерева SDK скриптом
`scripts/build-shaterd.sh`:
```sh
scripts/build-shaterd.sh [VERSION] [--fast]
```
Что он делает: (1) собирает панель — `cd panel && npm ci && npm run build` (Vite →
`panel/dist`); (2) копирует `panel/dist/*` в `shater/panel/webroot/`, откуда
`//go:embed` вшивает **реальную** SPA в бинарь; (3) кросс-собирает под `{amd64,
arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), stripped/trimmed;
(4) прогоняет UPX `--lzma --best` (~42 МБ → ~8–11 МБ); (5) стейджит артефакт в
`openwrt/shaterd/files/` для пакета.
Затем OpenWrt-пакеты из `openwrt/` собираются каноническим путём SDK. Детали
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Проверка
```sh
bash scripts/run-tests.sh # полный гейт
bash scripts/run-tests.sh --no-race # без -race, для локального цикла
```
Гейт гоняет весь набор **под теми же build-тегами, с которыми собирается
роутерный бинарь** (`scripts/router-tags.sh`), на Linux (с не-Linux хоста — сам
перезапускается в Docker), с `-race`, и содержит три машинные проверки против
молчаливого скипа: набор тегов может только ДОБАВЛЯТЬ тест-файлы; каждый пакет с
тестами обязан отчитаться `ok` поимённо; каждый `TestIntegration*` обязан выдать
вердикт по имени. Причина такая: до 2026-07 релизный тракт не гонял почти ничего
— 115 тест-файлов из 116 под `shater/**` в CI не исполнялись ни разу.
Отдельно `scripts/check-router-tags.sh` проверяет, что ни одна заявленная в
`FEATURES.md` фича не потеряла нужный ей build-тег.
Зелёный гейт — необходимое, но не достаточное условие: он не видит стыков с
ядром, procd и nftables. Это проверяется на стенде (см.
[`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md)).
---
## Структура репозитория
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
| Путь | Что это |
|------|---------|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанный apk-фид |
| `SPECS/` | Конституция форка движка и спеки (Spec Kit) |
| `docs-lx/` | Справочник конфигурации фич движка (`lx-config.md`, `.ru.md`) |
| `lx-test/`, `submodules/` | Примеры конфигов движка и submodule AmneziaWG-рантайма |
| `docs/`, `mkdocs.yml` | **Апстрим** документация sing-box (mkdocs) — как есть |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | Дерево движка sing-box-lx |
---
## CI и релизы
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
публикует **подписанные фиды**:
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
Gitea-secret `KEY_APK`).
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
развёрнутых роутерах.
---
## Связь с upstream и движок
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Это набор
самого форка, а не shater: MASQUE/CONNECT-IP мы намеренно **не регистрируем** —
`shater/generate` его не порождает, а отказ от него и остального незадействованного
зоопарка экономит ~6 МБ бинаря и столько же RAM на роутере (`shater/registry`). Форк
разрабатывается по Spec Kit; неизменяемые принципы — в
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md), справочник фич движка — в
[`docs-lx/lx-config.ru.md`](docs-lx/lx-config.ru.md).
История: **v0.1** (движок на xray-core, полностью рабочая и VM-проверенная версия)
сохранена на ветке **[`v0.1`](../../src/branch/v0.1)**. v0.2 схлопнула runtime в
один форкнутый бинарь.
---
## Документация
| Документ | О чём |
|----------|-------|
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка apk-фида (роллинг/фиксация) |
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary дизайн, auth-handoff, data/DNS/apply-потоки (диаграммы) |
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Фазовый план |
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия |
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Визуальная система панели — «Faceplate», токены, компоненты |
| [`docs-shater/PORTING.md`](docs-shater/PORTING.md) | Порт проверенных кусков из v0.1 |
Индекс папки — [`docs-shater/README.md`](docs-shater/README.md).
---
## Оборудование
Арка `aarch64_cortex-a53` покрывает Banana Pi **BPI-R3** (MT7986/Filogic 830) и
**BPI-R4** (MT7988/Filogic 880) — оба таргет OpenWrt `mediatek/filogic`. `x86_64` —
QEMU-стенд для тестов.
---
## Лицензия
[GPL-3.0](LICENSE) — как у upstream sing-box. Подробности — в
[`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) (D6). Неофициальный форк, не
аффилирован с SagerNet.
+15 -235
View File
@@ -1,239 +1,19 @@
[English](README.md) · **Русский**
# shater — этот файл переехал
# sing-box-lx
Лицо этого репозитория — продукт **shater** (управляемый интернет-шлюз для
роутеров на OpenWrt). Основной README на русском — **[README.md](README.md)**;
краткая английская версия — **[README.en.md](README.en.md)**.
> **Тонкий downstream-форк [SagerNet/sing-box](https://github.com/SagerNet/sing-box).**
> Небольшой набор клиентских фич поверх upstream — транспорт **XHTTP**, **AmneziaWG 2.0**, **MASQUE** (CONNECT-IP / Cloudflare WARP), расширения **наблюдаемости** (CommandClient) и балансировка нагрузки **round_robin** — каждая за своим build-tag.
> Набор может расти, философия — нет: жить ребейзом на каждый upstream-тег, а не отдельной жизнью.
Раньше здесь лежал README форка движка **sing-box-lx**, который shater
вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
> 📄 README самого upstream sing-box — **[на GitHub](https://github.com/SagerNet/sing-box/blob/main/README.md)** (всегда актуальный).
- **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)** — справочник конфигурации
фич движка (XHTTP, AmneziaWG 2.0, MASQUE).
- **[SPECS/CONSTITUTION.md](SPECS/CONSTITUTION.md)** — конституция тонкого форка
(принципы, build-tag изоляция, ребейз-модель).
- **[SPECS/README.md](SPECS/README.md)** — формат задач Spec Kit.
- Апстрим-README самого sing-box —
[на GitHub](https://github.com/Leadaxe/sing-box-lx).
Это не отдельный проект и не «улучшенный sing-box». Это upstream sing-box **плюс несколько фич**, реализованных так, чтобы их можно было переносить на новые версии sing-box годами, почти без конфликтов. Со временем фич может становиться больше — другие протоколы, новые возможности, — но каждая обязана жить по тем же правилам тонкого форка ([CONSTITUTION](SPECS/CONSTITUTION.md)).
---
## Уникальное позиционирование
В экосистеме sing-box форки, добавляющие XHTTP/AmneziaWG, делятся на два лагеря — и `sing-box-lx` не входит ни в один:
| Форк | Фичи | Подход | Синк с upstream |
|------|------|--------|-----------------|
| **SagerNet/sing-box** (upstream) | базовый | — | — |
| **shtorm-7/sing-box-extended** | десятки (WARP, MASQUE, MTProxy, XHTTP, AWG2, …) | «комбайн», правки повсюду | отдельная ветка, без ребейза на теги |
| **amnezia-vpn/amnezia-box**, **hoaxisr/amnezia-box** | только AWG | толстый форк, правки in-place | синк по веткам (`dev-next`/`stable-next`) |
| **➡ sing-box-lx** (этот репозиторий) | **малый набор (XHTTP, AWG2, наблюдаемость, round_robin)** | **тонкий: новые файлы за build-tag, минимум касаний upstream** | **ребейз атомарных `// lx`-коммитов на upstream-теги** |
**Чем мы отличаемся:**
- **Минимальная дивергенция.** Новый код живёт в новых файлах. Существующие upstream-файлы трогаются только в крошечных помеченных швах `// lx:begin … // lx:end`. → дешёвые ребейзы.
- **Изоляция за build-tag.** Фичи включаются тегами `with_xhttp` / `with_awg`. Сборка **без** них байт-в-байт повторяет поведение upstream — фичи ничего не ломают по умолчанию.
- **Идентичность сохранена.** Go-модуль остаётся `github.com/sagernet/sing-box`, бинарь называется `sing-box`. Суффикс `-lx` есть только в строке версии (`1.13.13-lx.N`).
- **Build-tag — родная конвенция sing-box**, а не наше изобретение (`with_quic`, `with_wireguard`, …). Мы просто применяем её с максимальной дисциплиной.
> Готовые форки-комбайны мы **не тянем как зависимость**, а используем только как референс wire-протокола.
---
## Фичи и статус
| # | Фича | Что это | Статус |
|---|------|---------|--------|
| **XHTTP** | клиентский транспорт | Xray-совместимый «splithttp» (режимы `auto`/`packet-up`/`stream-up`/`stream-one`) поверх Reality/TLS/h2c | ✅ **проверен живым Xray (3x-ui) сервером** (packet-up/auto): handshake + DNS + HTTPS + скачивание. `stream-one` — известный баг framing |
| **AmneziaWG 2.0** | клиентский endpoint | обфускация WireGuard: `Jc/Jmin/Jmax`, `S1–S4`, `H1–H4` + **2.0**: `I1–I5` (CPS — кастомные пакеты-приманки) | ✅ собирается, проходит `check`; зависимость **активирована** ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация); **проверено живым AWG2-сервером**: handshake + keepalive + трафик наружу |
| **Маскировка `id/ip/ib`** | сахар над AWG | WireSock-стиль: декларативная маскировка поверх `I1` — домен (`id`) + протокол (`ip`: `quic`/`dns`/`stun`/`sip`) + браузер (`ib`), ядро строит клиент-инициированную `I1`-приманку: `quic` = out-of-order фрагментированный Initial (i1+i2), `dns`/`stun`/`sip` = query/Binding-Request/INVITE | ✅ **`ip=quic` device-проверен на реальном LTE/WARP DPI** (~330 мс, упрощает Cloudflare WARP); `dns`/`stun`/`sip` собираются и проходят `check`, но режутся как класс протокола к WARP-edge — для других провайдеров |
| **Наблюдаемость** (расширения CommandClient) | live-стрим для UI | нативные расширения libbox gRPC за `with_lx_command`: `URLTestOutbound`, `GetRules`, `GetGroups`, `GetOutbounds`, `GetPool`, плюс `Connection.detourList` (хвост detour'а отдельным полем, SPEC 017) и `SubscribeDNSQueries` — структурный live-поток DNS (домен, qtype, rcode `-1`=ошибка, CNAME-цепочка, привязка к процессу, `dnsServer`/`dnsServerType`/`outbound`, SPEC 018) | ✅ в rc-серии, потребляется **LxBox**. SPEC 014–018: [`014`](SPECS/014-CLASH_API_TO_COMMANDCLIENT_MIGRATION/SPEC.md) · [`015`](SPECS/015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md) · [`017`](SPECS/017-CONNECTION_DETOUR_CHAIN/SPEC.md) · [`018`](SPECS/018-DNS_QUERY_STREAM/SPEC.md) |
| **round_robin** (балансировка нагрузки) | режим `urltest` | пул-балансировка на `urltest` за `with_lx_command` (для `GetPool`): `mode` `least_test` (дефолт) \| `round_robin`; `balancer{pool (дефолт 3), pool_tolerance (0=держать живые / >0=топ по задержке), sticky_hash}`. Sticky-ключ: пропущен/`[]` → дефолт `["process","domain"]`, `["none"]` → выкл; компоненты `process`/`domain`/`source_ip`/`dest_ip`/`dest_port`. Фиксированные слоты `slot[hash(key)%pool]` (FNV-64a), замена в слоте; `GetPool` отдаёт слоты | ✅ локально равномерно (10/10/10, sticky off); rc.15 починил схлопывание `domain`-ключа (теперь читается `metadata.Domain`, переживающий resolve домен→IP, а не пустой `destination.Fqdn`) — на устройстве равномерность 0.27 → 0.95+. SPEC [`019`](SPECS/019-URLTEST_MODE_STICKY/SPEC.md), конфиг — [docs/.../urltest.md](docs/configuration/outbound/urltest.md) |
| **MASQUE** (`type: masque`) | клиентский outbound | CONNECT-IP (RFC 9484) поверх HTTP/3 **или** HTTP/2 для **Cloudflare WARP** (SPEC 021): туннелирует целые IP-пакеты через userspace gVisor-стек; `profile` (`cloudflare`/`standard`), `network` (`h3`/`h2`), pinning ECDSA public key, idle-suspend + самовосстановление. h2 — ручной фреймер поверх `x/net/http2` (без доп. зависимостей); `connect-ip-go` вкопан | ✅ **device-verified на Wi-Fi и LTE** (`warp=on`, реальный трафик на `h3` и `h2`); на сетях, режущих входящий UDP:443, `h3`-handshake виснет — там `network: h2` (TCP:443) |
Подробные отчёты — в [`SPECS/002-…`](SPECS/002-XHTTP_CLIENT_TRANSPORT/IMPLEMENTATION_REPORT.md), [`SPECS/003-…`](SPECS/003-AWG2_CLIENT_ENDPOINT/IMPLEMENTATION_REPORT.md) и [`SPECS/009-…`](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/IMPLEMENTATION_REPORT.md). Полный справочник конфига — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**.
> **Не поддерживается (слой Reality, отложено):** post-quantum Reality (`pqv` / ML-DSA-65) и `spiderX` из Xray. Это Xray-специфичные фичи Reality, которых нет в sing-box, а Reality — upstream-слой TLS, который мы держим нетронутым (это не одна из наших фич). Классический X25519 Reality работает; сервер, который **требует** post-quantum Reality, не подключится. Это ограничение sing-box — правильнее решать в upstream (получим на ребейзе).
---
## Сборка
Сборка идёт через отдельный **`Makefile.lx`** (upstream `Makefile` не трогаем):
```bash
git clone --recurse-submodules https://github.com/Leadaxe/sing-box-lx
make -f Makefile.lx lx-build
# → бинарь ./sing-box с версией вида 1.13.13-lx.1
```
> `--recurse-submodules` обязателен для `with_awg`: рантайм AmneziaWG подключён submodule'ом `submodules/wireguard-go` → [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx).
Под капотом — стандартный `go build` с набором тегов (единственный источник истины — `make -f Makefile.lx lx-print-tags`):
```
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg
```
Это клиентский feature-set upstream **минус** серверные/нерелевантные теги — `with_acme` (серверный выпуск сертов), `with_tailscale`, `with_ccm`/`with_ocm` (AI-прокси) — **плюс** `with_purego` (CGO-free кросс-сборка, чтобы `with_naive_outbound`/cronet собирался при `CGO=0` на любом desktop-таргете, кроме Windows 7 / 32-бит legacy-сборки, где naive выкинут — у `cronet-go` нет windows/386) и наши фичи `with_xhttp` / `with_awg`. Всё остальное — ровно как upstream.
Проверка конфигов:
```bash
./sing-box check -c lx-test/config/xhttp_reality.json
./sing-box check -c lx-test/config/awg2_basic.json
```
> `lx-test/config/` — наши примеры (upstream `test/` — отдельный Go-модуль, его не используем).
**Android (`libbox.aar`).** `make lib_install && make lib_android` собирает gomobile-AAR — `libbox.aar` (SDK 23) + `libbox-legacy.aar` (SDK 21) — с зашитыми `with_xhttp`/`with_awg` (и без `tailscale`), для встраивания в Android-приложение-потребитель (нужны NDK r28 + OpenJDK 17). `Libbox.version()` отдаёт `…-lx.N`.
---
## Конфигурация фич
> Полные таблицы полей, дефолты и `awg-quick`→JSON маппинг — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**. Здесь — кратко.
### XHTTP (outbound transport)
```jsonc
"transport": {
"type": "xhttp",
"host": "example.com",
"path": "/xhttp",
"mode": "auto" // auto | packet-up | stream-up | stream-one
}
```
### AmneziaWG 2.0 (endpoint)
Поля AWG промотированы прямо в `WireGuardEndpointOptions`:
```jsonc
{
"type": "wireguard",
// … стандартные поля wireguard (private_key, address, peers, …) …
"jc": 10, "jmin": 50, "jmax": 100,
"s1": 20, "s2": 20, "s3": 60, "s4": 60,
"h1": 1, "h2": 2, "h3": 3, "h4": 4,
"i1": "<b 0x...><r 12>", "i2": "", "i3": "", "i4": "", "i5": "" // 2.0 CPS
}
```
> `I1–I5` — это конфиг (не согласуется по сети), значения должны **совпадать на клиенте и сервере**, регистрозависимы.
**Сахар-маскировка (`id`/`ip`/`ib`).** Вместо ручного `i1` задаёшь домен, протокол и
браузер — ядро само собирает `I1`-приманку (стиль WireSock). Удобно для упрощения
коннекта к **Cloudflare WARP**:
```jsonc
{
"type": "wireguard",
// … стандартные поля wireguard …
"id": "www.google.com", "ip": "quic", "ib": "chrome" // quic: id идёт как SNI в ClientHello
// или: "ip": "dns", "id": "www.google.com" // dns/sip: id идёт как QNAME/host
}
```
`ip` ∈ `quic|dns|stun|sip`; `id` обязателен только для `quic` (SNI); для `dns`/`sip` опционален (без него генерится псевдо-имя), `stun` игнорирует. Где задан — идёт на провод (SNI / QNAME / host)
и опционален для `sip` (без него генерится псевдо-host) и `stun`; `ib` ∈ `chrome|firefox|curl`
(только quic, эффект минимальный — без JA3-fingerprint). Взаимоисключается с явным `i1`.
Для **`quic`** ядро генерит out-of-order фрагментированный QUIC Initial (RFC 9001) — реальный
ClientHello, нарезанный на CRYPTO-фреймы в перемешанном порядке, так что line-rate DPI парсит
мусор и пропускает. Раскладка рандомизируется на каждый вызов (нет межюзерной сигнатуры), и
`ip=quic` теперь шлёт **два** независимых Initial (i1+i2) — поток читается как развивающаяся
QUIC-сессия. Это **единственный профиль, device-проверенный на реальном LTE/WARP DPI** (~330 мс).
`dns`/`stun`/`sip` реализованы как корректные клиент-инициированные запросы, но режутся как класс
протокола к WARP-edge (raw DNS/STUN/SIP к дата-центровому IP сам по себе аномален) — сохранены
для других провайдеров, чей DPI проверяет лишь корректность пакета. См.
[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) и [примеры SPECS/009](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/EXAMPLES.md).
### MASQUE (outbound — Cloudflare WARP)
Outbound `masque` туннелирует целые IP-пакеты через **CONNECT-IP (RFC 9484)**, HTTP/3 или HTTP/2,
к **Cloudflare WARP**. Не путать с AWG-сахаром *masquerade* `id/ip/ib` выше — разные фичи, одно слово.
```jsonc
{
"type": "masque",
"tag": "warp",
"server": "162.159.198.2",
"server_port": 443,
"profile": "cloudflare", // cloudflare (WARP) | standard (RFC 9484)
"network": "h3", // ТРАНСПОРТ: h3 (QUIC) | h2 (HTTP/2). НЕ tcp/udp — это network_list
"sni": "www.microsoft.com", // domain-fronting; endpoint аутентифицируется пиннингом public key, не по SNI
"private_key": "<base64 DER EC>",
"public_key": "<base64 DER PKIX>",
"ip": "172.16.0.2/32", "ipv6": "2606:4700:110:...::/128"
}
```
Ключевой материал (`private_key`/`public_key`/`ip`/`ipv6`) берётся готовым из конфига — регистрацию
устройства в WARP делает клиент. На сетях, режущих входящий UDP:443, `h3`-handshake виснет —
переключите узел на `network: h2` (TCP:443). Полный справочник —
[docs-lx/lx-config.ru.md §4](docs-lx/lx-config.ru.md) и [SPECS/021](SPECS/021-MASQUE_CONNECT_IP_OUTBOUND/CONFIG.md).
---
## Модель сопровождения
```
upstream tag (vX.Y.Z)
│
└─► ветка lx = upstream + N атомарных // lx-коммитов
├─ FORK_BOOTSTRAP (Makefile.lx, CI, версия)
├─ XHTTP client transport
├─ AWG2 client endpoint
└─ … (новые фичи — такими же атомарными // lx-коммитами)
```
- **Только ребейз, никогда merge.** На новый upstream-тег ветка `lx` ребейзится поверх него.
- Каждая фича — атомарный коммит(ы), помеченный `// lx`. Новые файлы конфликтов не дают; швы в upstream-файлах малы и переносятся вручную.
- Разработка ведётся по **Spec Kit** (`SPECS/NNN-T-S-NAME/`: SPEC → PLAN → TASKS → IMPLEMENTATION_REPORT).
### Remotes
```bash
origin git@github.com:Leadaxe/sing-box-lx.git # ветка по умолчанию: lx
upstream https://github.com/SagerNet/sing-box.git
```
---
## Структура lx-специфики
| Путь | Назначение |
|------|------------|
| `Makefile.lx` | сборка с lx-тегами и версией `-lx` |
| `.github/workflows/lx-ci.yml` | CI: матрица фич (baseline/xhttp/awg/full) + negative-check + кросс-платформа + android AAR |
| `.github/workflows/lx-release.yml` | релиз на `v*-lx.*`: desktop ×6 + `libbox.aar` → GitHub Release |
| `SPECS/` | Spec Kit (конституция, задачи, отчёты) |
| `lx-test/config/` | примеры конфигов для `sing-box check` |
| `transport/v2rayxhttp/` | XHTTP-клиент (новый пакет) |
| `transport/wireguard/device_awg.go` | AWG IpcSet-параметры (за `with_awg`) |
| `submodules/wireguard-go` | submodule: merged-форк AmneziaWG-рантайма ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx)) |
| `option/v2ray_xhttp.go`, `option/wireguard_awg.go` | опции фич |
| `include/v2rayxhttp.go` | регистрация транспорта за build-tag |
Поиск всех правок upstream-файлов: `grep -rn "// lx"`.
---
## Потребитель
Ядро собирается для десктоп-лаунчера **singbox-launcher** (бандлит `bin/sing-box`). На Android потребитель встраивает **`libbox.aar`** (gomobile) вместо бинаря — конфиг-JSON тот же. Маппинг `type=xhttp` и AWG-полей в визарде — задачи на стороне потребителя, не здесь.
---
## Ссылки
| | |
|---|---|
| Upstream | [SagerNet/sing-box](https://github.com/SagerNet/sing-box) · [документация](https://sing-box.sagernet.org/) |
| Этот форк | [Leadaxe/sing-box-lx](https://github.com/Leadaxe/sing-box-lx) |
| AmneziaWG-рантайм | [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация (3-way merge) |
| AmneziaWG upstream | [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) · [docs.amnezia.org](https://docs.amnezia.org/documentation/amnezia-wg/) |
| XHTTP (исток) | [XTLS/Xray-core](https://github.com/XTLS/Xray-core) — `transport/internet/splithttp` |
| Конфиг фич | [docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) |
| Spec Kit | [SPECS/](SPECS/) — [README](SPECS/README.md) · [CONSTITUTION](SPECS/CONSTITUTION.md) · [IMPLEMENTATION_PROMPT](SPECS/IMPLEMENTATION_PROMPT.md) |
---
## Лицензия
Наследует лицензию upstream sing-box (**GPL-3.0**). Все правки помечены `// lx` и распространяются под той же лицензией. Это неофициальный форк, не аффилирован с SagerNet.
> Файл оставлен как указатель, чтобы у репозитория был один основной русский
> README (`README.md`), а не два конкурирующих.
@@ -3,7 +3,33 @@
| Поле | Значение |
|------|----------|
| Тип | B (bug) |
| Статус | C (complete) |
| Статус | C (complete) — guard **снят** (см. баннер ниже) |
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
>
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
> возвращал управление). Android для shater не платформа и ей не станет —
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
> на его применение.
>
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
> отклонённая, а как «нода почему-то не работает».
>
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
> тест краснел.
>
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
+145
View File
@@ -0,0 +1,145 @@
// lx:begin l3-honest-drop
package adapter
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
// judgeFlowRouter answers PreMatch with a canned verdict; JudgeFlow reads
// nothing else off the Router.
type judgeFlowRouter struct {
Router
result PreMatchResult
}
func (r *judgeFlowRouter) PreMatch(InboundContext, []byte) PreMatchResult { return r.result }
// judgeFlowPort is the tun.Port half of a FlowOutbound. inet4 is what
// PortAddresses reports for IPv4 — the one field the two ICMP consumers in
// sing-tun disagree about (see the comment on
// TestJudgeFlowICMPToBoundPortStaysAFlow).
type judgeFlowPort struct {
Outbound
inet4 netip.Addr
}
func (o *judgeFlowPort) Tag() string { return "wg-out" }
func (o *judgeFlowPort) Type() string { return "wireguard" }
func (o *judgeFlowPort) PortAddresses() (netip.Addr, netip.Addr) {
return o.inet4, netip.Addr{}
}
func (o *judgeFlowPort) PortMTU() uint32 { return 1420 }
func (o *judgeFlowPort) AttachReturn(tun.Return) error { return nil }
func (o *judgeFlowPort) DetachReturn(tun.Return) error { return nil }
func (o *judgeFlowPort) WritePackets(packets [][]byte) error { return nil }
// judgeFlowNonPort is a FlowOutbound-shaped result that is NOT a tun.Port — the
// interface drift the second line of defense in JudgeFlow exists for.
type judgeFlowNonPort struct {
Outbound
}
func (o *judgeFlowNonPort) Tag() string { return "drifted" }
func (o *judgeFlowNonPort) Type() string { return "drifted" }
func judgeFlow(t *testing.T, protocol uint8, result PreMatchResult) tun.FlowVerdict {
t.Helper()
return JudgeFlow(
&judgeFlowRouter{result: result},
"l3-in", "tun", protocol,
netip.MustParseAddrPort("192.168.1.2:1234"),
netip.MustParseAddrPort("1.1.1.1:1234"),
nil,
)
}
const (
judgeFlowICMP = uint8(header.ICMPv4ProtocolNumber)
judgeFlowTCP = uint8(header.TCPProtocolNumber)
)
// TestJudgeFlowICMPToBoundPortStaysAFlow is the guard on the ONE fix that must
// not be made here.
//
// sing-tun has two ICMP consumers with different requirements on the port:
//
// - ForwardDispatcher.createFlow (flow_dispatch.go) needs only a VALID port
// address — it NATs the echo identifier and rewrites the source to that
// address. This is the path every unfragmented LAN ping takes, and it is
// what makes ping-through-WireGuard/AWG work at all.
// - ICMPForwarder.installFlow (stack_gvisor_icmp.go) additionally requires the
// address to be UNSPECIFIED, because it writes the packet to the port
// unmodified. A WireGuard endpoint reports its concrete interface address
// (transport/wireguard/port.go), so installFlow declines and HandlePacket
// falls through to forging the echo reply.
//
// The tempting fix — "for ICMP, refuse ActionFlow when PortAddresses() is not
// unspecified, so the verdict becomes a drop and the forgery is unreachable" —
// is applied HERE, in the one function both consumers share, with byte-identical
// arguments from either. It would therefore kill the working path too: every
// ping through WireGuard/AWG, fragmented or not, would drop, and l3_tunnel would
// carry nothing but `direct`. Keep this test failing loudly if anyone tries.
func TestJudgeFlowICMPToBoundPortStaysAFlow(t *testing.T) {
t.Parallel()
port := &judgeFlowPort{inet4: netip.MustParseAddr("10.2.0.2")}
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: port})
require.Equal(t, tun.ActionFlow, verdict.Action,
"ICMP to a WireGuard/AWG endpoint must stay a flow: the forward dispatcher NATs it by echo identifier and this is the whole point of l3_tunnel")
require.Same(t, tun.Port(port), verdict.Port)
}
// The `direct` shape: an unspecified port address. Both consumers accept it.
func TestJudgeFlowICMPToUnspecifiedPortStaysAFlow(t *testing.T) {
t.Parallel()
port := &judgeFlowPort{inet4: netip.IPv4Unspecified()}
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: port})
require.Equal(t, tun.ActionFlow, verdict.Action)
require.Same(t, tun.Port(port), verdict.Port)
}
// PreMatchDrop is the honest verdict and must arrive as ActionDrop: it is the
// only value (besides Reject) that stops ICMPForwarder.HandlePacket before the
// Echo -> EchoReply rewrite.
func TestJudgeFlowICMPDropReachesTheStackAsDrop(t *testing.T) {
t.Parallel()
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchDrop})
require.Equal(t, tun.ActionDrop, verdict.Action)
}
// The second line of defense: a PreMatchFlow whose outbound is not a tun.Port
// must not degrade ICMP to ActionAccept, because Accept is the forged reply.
func TestJudgeFlowICMPNonPortOutboundDrops(t *testing.T) {
t.Parallel()
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: &judgeFlowNonPort{}})
require.Equal(t, tun.ActionDrop, verdict.Action,
"FlowOutbound and tun.Port are distinct interfaces; a drift between them must not silently re-enable the echo forger")
}
func TestJudgeFlowTCPNonPortOutboundAccepts(t *testing.T) {
t.Parallel()
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchFlow, Outbound: &judgeFlowNonPort{}})
require.Equal(t, tun.ActionAccept, verdict.Action,
"for TCP, falling back to Accept is upstream behaviour and must stay untouched")
}
// TCP keeps every mapping it had, including the Continue -> Accept default that
// is a forgery only for ICMP.
func TestJudgeFlowTCPContinueStaysAccept(t *testing.T) {
t.Parallel()
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchContinue})
require.Equal(t, tun.ActionAccept, verdict.Action)
}
func TestJudgeFlowTCPBypassStaysBypass(t *testing.T) {
t.Parallel()
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchBypass})
require.Equal(t, tun.ActionBypass, verdict.Action)
}
// lx:end l3-honest-drop
-22
View File
@@ -45,30 +45,8 @@ type OutboundManager interface {
Default() Outbound
Remove(tag string) error
Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error
// lx:begin awg
// ConsumersOf returns the tags of outbounds that depend on (detour through)
// the given tag — the reverse of Dependencies(). Used by the selector guard to
// walk up to AmneziaWG consumers when a group switches to a WireGuard member.
ConsumersOf(tag string) []string
// lx:end awg
}
// lx:begin awg
// AmneziaWGSuspendable is implemented by an AmneziaWG endpoint so the selector
// guard can suspend it (bring its device down) when a group it detours through
// switches to a WireGuard member — AmneziaWG inside a WireGuard tunnel hangs the
// kernel on Android. The marker lives in adapter so protocol/group can act on it
// without importing protocol/wireguard.
type AmneziaWGSuspendable interface {
// IsAmneziaWG reports whether this endpoint runs AmneziaWG (has AWG params).
IsAmneziaWG() bool
// SuspendAmneziaWG brings the device down so no junk handshake is sent. It is
// idempotent and safe to call on a not-yet-started or already-suspended endpoint.
SuspendAmneziaWG()
}
// lx:end awg
// lx:begin idle-suspend
// IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick
// (SPEC 020) can suspend it when it is idle and unreachable, without importing
-15
View File
@@ -208,21 +208,6 @@ func (m *Manager) Outbound(tag string) (adapter.Outbound, bool) {
return m.endpoint.Get(tag)
}
// lx:begin awg
// ConsumersOf returns a copy of the tags that detour through tag (reverse of
// Dependencies()), built from the dependByTag ledger populated at Create time.
func (m *Manager) ConsumersOf(tag string) []string {
m.access.RLock()
defer m.access.RUnlock()
consumers := m.dependByTag[tag]
if len(consumers) == 0 {
return nil
}
return append([]string(nil), consumers...)
}
// lx:end awg
func (m *Manager) Default() adapter.Outbound {
m.access.RLock()
defer m.access.RUnlock()
+11
View File
@@ -75,7 +75,18 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8,
case PreMatchFlow:
port, isPort := result.Outbound.(tun.Port)
if !isPort {
// lx:begin l3-honest-drop
// Second line of defense behind route.(*Router).preMatchFlow: a
// PreMatchFlow result already implies the outbound is an
// adapter.FlowOutbound, but FlowOutbound and tun.Port are distinct
// interfaces, and a drift between them must not degrade ICMP to
// ActionAccept — the TUN stack would then forge the echo reply
// itself instead of admitting the tunnel cannot carry the packet.
if networkName == N.NetworkICMP {
return tun.FlowVerdict{Action: tun.ActionDrop}
}
return tun.FlowVerdict{Action: tun.ActionAccept}
// lx:end l3-honest-drop
}
verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker}
if result.Destination.IsValid() {
Binary file not shown.
+33 -17
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (the 25.12
# lane — additive next to ci/build-feed.sh, which stays the opkg/24.10 lane).
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (25.12+;
# the only packaging lane shater has — see docs-shater/DECISIONS.md D22).
#
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
# e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \
@@ -10,14 +10,14 @@
# This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls.
# It runs on the CI RUNNER and:
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
# scripts/build-shaterd.sh (same artifact-order contract as the opkg lane);
# 2. drives a plain `debian:bookworm` container (workspace shared via
# `--volumes-from`, same trick as ci/build-feed.sh) that downloads the
# ImmortalWrt 25.12 apk-SDK tarball and runs ci/sdk-build-apk.sh in it:
# compile the 4 packages as .apk, then `apk mkndx --sign` the per-arch
# `packages.adb` index. Unlike the usign lane (index signed on the runner),
# apk indexing NEEDS the SDK's host `apk` tool, so index+sign happen inside
# the container.
# scripts/build-shaterd.sh (the artifact-order contract);
# 2. drives a plain `debian:bookworm` container (the job's workspace volume is
# shared into it with `--volumes-from $(hostname)`; a bare `-v $PWD:...`
# points at a host path that does not exist under act_runner's DinD) that
# downloads the ImmortalWrt 25.12 apk-SDK tarball and runs
# ci/sdk-build-apk.sh in it: compile the 4 packages as .apk, then
# `apk mkndx --sign` the per-arch `packages.adb` index. Indexing NEEDS the
# SDK's host `apk` tool, so index+sign happen inside the container.
#
# Why the ImmortalWrt SDK (not openwrt/sdk images): the 25.12 fleet runs
# BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base (target mediatek/filogic,
@@ -25,9 +25,9 @@
# mediatek-filogic 25.12 tag — hence the official SDK tarball.
#
# Env:
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret — the apk analog
# of KEY_BUILD). If set, packages.adb carries an embedded signature
# verifiable by dist/shater-apk.pem (routers: /etc/apk/keys/).
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret). If set,
# packages.adb carries an embedded signature verifiable by
# dist/shater-apk.pem (routers: /etc/apk/keys/).
# If unset, an UNSIGNED index is produced (warning; not shippable —
# apk signatures are effectively mandatory).
set -eu
@@ -55,6 +55,15 @@ fi
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# --- 0.4) package version from the git tag ------------------------------------
# The workflow puts these in the job env via `ci/version.sh --env >>
# $GITHUB_ENV`; recompute here when run standalone. Passed into the container
# below and re-exported to the unprivileged build user in ci/sdk-build-apk.sh.
if [ -z "${SHATER_PKG_VERSION:-}" ] || [ -z "${SHATER_PKG_RELEASE:-}" ]; then
eval "$(sh "$REPO/ci/version.sh" --env)"
fi
echo "[apk-feed] package version: ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
# --- 0.5) runner-side caches --------------------------------------------------
# All under $REPO/.cache so (a) actions/cache in the workflow can persist them
# between runs and (b) the nested container sees them via --volumes-from.
@@ -63,7 +72,7 @@ chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# SDK; PKG_HASH still verifies every file, so stale = re-downloaded.
# apt/ debian:bookworm .deb archives for the host-deps install.
# The nested container runs the build as an unprivileged user -> must be writable
# (same reason as the chmod 0777 "$OUT" in ci/build-feed.sh).
# (same reason as the chmod 0777 "$OUT" above).
CACHE="$REPO/.cache"
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
@@ -86,20 +95,27 @@ sh "$REPO/ci/fetch-sdk.sh" "$SDK_URL" "$SDK_TAR"
# --- 1) SDK build + index + sign inside a debian container -------------------
# `--volumes-from $(hostname)` shares THIS job container's workspace volume into
# the nested container (see ci/build-feed.sh for why a bare -v does not work on
# the act_runner DinD setup).
# the nested container: a bare `-v $PWD:...` points at a host path that does not
# exist under the act_runner DinD setup.
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
docker pull -q debian:bookworm
docker run --rm --volumes-from "$(hostname)" \
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e SDK_URL="$SDK_URL" \
-e SDK_TAR="$SDK_TAR" -e DL_DIR="$CACHE/dl" -e APT_CACHE="$CACHE/apt" \
-e FEEDS_CACHE="$FEEDS_CACHE" -e KEY_APK="${KEY_APK:-}" \
-e SHATER_PKG_VERSION="$SHATER_PKG_VERSION" \
-e SHATER_PKG_RELEASE="$SHATER_PKG_RELEASE" \
debian:bookworm bash "$REPO/ci/sdk-build-apk.sh"
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
[ -s "$OUT/packages.adb" ] || { echo "[apk-feed] ERROR: $OUT/packages.adb missing/empty" >&2; exit 4; }
apks=$(find "$OUT" -maxdepth 1 -name '*.apk' | wc -l)
[ "$apks" -ge 4 ] || { echo "[apk-feed] ERROR: expected >=4 .apk in $OUT, found $apks" >&2; exit 5; }
# Three, since D29 removed byedpi: shaterd, shater-core, luci-app-shater. The
# count lives in TWO scripts — sdk-build-apk.sh asserts what it collected out of
# bin/, this one asserts what reached the feed dir. v0.2.22 shipped with only the
# first one updated and the aarch64 lane died here on `found 3`, so if the set of
# packages ever changes again, change it in both.
[ "$apks" -ge 3 ] || { echo "[apk-feed] ERROR: expected >=3 .apk in $OUT, found $apks" >&2; exit 5; }
if [ -n "${KEY_APK:-}" ] && [ ! -s "$OUT/shater-apk.pem" ]; then
echo "[apk-feed] ERROR: signed feed but shater-apk.pem missing from $OUT" >&2; exit 6
fi
-92
View File
@@ -1,92 +0,0 @@
#!/bin/sh
# ci/build-feed.sh — build the signed opkg feed for ONE arch.
#
# Usage: ci/build-feed.sh <ARCH> <SDK_DOCKER_TAG> <OUTDIR>
# e.g. ci/build-feed.sh x86_64 x86_64-24.10.4 out/x86_64
# ci/build-feed.sh aarch64_cortex-a53 mediatek-filogic-24.10.4 out/aarch64_cortex-a53
#
# This is the reusable per-arch entrypoint the Gitea workflow calls. It runs on
# the CI RUNNER and:
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
# scripts/build-shaterd.sh (into openwrt/shaterd/files/) — proving artifact
# order: SPA+shaterd build BEFORE the SDK package build;
# 2. drives the arch-matched `openwrt/sdk` docker image to compile all 4
# packages (ci/sdk-build.sh) and collect their .ipk into OUTDIR;
# 3. builds + usign-signs the opkg `Packages` index over OUTDIR
# (ci/install-usign.sh + ci/make-index.sh; signs iff $KEY_BUILD is set).
#
# Env:
# KEY_BUILD usign SECRET key (Gitea repo secret). If set, the feed index is
# signed and verifiable by dist/shater-feed.pub (fp 5ac4b177689cb8e0).
# If unset, an UNSIGNED feed is produced (make-index warns).
set -eu
ARCH="${1:?arch required (x86_64 | aarch64_cortex-a53)}"
SDK_TAG="${2:?sdk docker tag required (e.g. x86_64-24.10.4)}"
OUT="${3:?output dir required}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$OUT"; OUT="$(cd "$OUT" && pwd)"
# $OUT is created here as ROOT on the runner, but the nested `openwrt/sdk`
# container runs as the unprivileged `buildbot` (uid 1000) — so it must be able
# to write the collected .ipk into $OUT. World-writable is set HERE (a chmod
# from inside the container, as buildbot, cannot fix a root-owned dir).
chmod 0777 "$OUT"
# --- 0) the prebuilt shaterd binary must already be staged for this arch ------
case "$ARCH" in
x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;;
*) echo "[feed] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
esac
if [ ! -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" ]; then
echo "[feed] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
echo " Run scripts/build-shaterd.sh BEFORE ci/build-feed.sh." >&2
exit 3
fi
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# --- 0.5) persistent dl/ (package source tarballs) ----------------------------
# Workspace dir restored/saved by actions/cache in the workflow and shared into
# the nested SDK container via --volumes-from; becomes CONFIG_DOWNLOAD_FOLDER
# there (ci/sdk-build.sh). PKG_HASH still verifies every file, so a stale cache
# can never produce a wrong build. Must be writable by the container's
# unprivileged buildbot user (same reason as the $OUT chmod above).
DL_DIR="$REPO/.cache/dl"
mkdir -p "$DL_DIR"
chmod -R a+rwX "$DL_DIR" 2>/dev/null || true
# --- 0.6) persistent feeds/ git checkouts -------------------------------------
# Workspace dir restored/saved by actions/cache (key: feeds-opkg-<release>) and
# symlinked over the SDK's feeds/ inside the container (ci/sdk-build.sh), so
# `scripts/feeds update -a` fetches deltas instead of re-cloning base+packages+
# luci from scratch (~7 min/run on this runner's slow github.com link).
# Top-level chmod only: the contents are created by the container's uid-1000
# build user and restored with the same ownership (tar-as-root preserves it).
FEEDS_CACHE="$REPO/.cache/feeds/opkg"
mkdir -p "$FEEDS_CACHE"
chmod a+rwX "$REPO/.cache" "$REPO/.cache/feeds" "$FEEDS_CACHE" 2>/dev/null || true
# --- 1) SDK package build (4 packages) in the arch-matched SDK image ----------
# We drive the `openwrt/sdk` docker image directly (not openwrt/gh-action-sdk):
# on a self-hosted Gitea act_runner the marketplace action fetch can be
# unavailable, and we need a CLEAN single-feed layout. `--volumes-from
# $(hostname)` shares THIS job container's workspace volume into the nested SDK
# container — a bare `-v $PWD:...` points at a host path that does not exist
# under the act_runner DinD setup. (Requires the job to run inside a container,
# which Gitea Actions does by default.)
echo "[feed] SDK build arch=$ARCH image=openwrt/sdk:$SDK_TAG"
docker pull "openwrt/sdk:$SDK_TAG"
docker run --rm --volumes-from "$(hostname)" \
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e DL_DIR="$DL_DIR" \
-e FEEDS_CACHE="$FEEDS_CACHE" \
"openwrt/sdk:$SDK_TAG" \
sh "$REPO/ci/sdk-build.sh"
# --- 2) index + sign the per-arch feed (usign, KEY_BUILD passed through) -------
sh "$REPO/ci/install-usign.sh"
KEY_BUILD="${KEY_BUILD:-}" bash "$REPO/ci/make-index.sh" "$OUT"
echo "[feed] done arch=$ARCH -> $OUT"
ls -l "$OUT"
+7 -10
View File
@@ -2,24 +2,21 @@
# ci/gen-apk-key.sh — generate the Shater **apk** feed signing keypair (25.12 lane).
#
# apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys
# (prime256v1 PEM), NOT usign — the existing usign identity
# (dist/shater-feed.pub, fp 5ac4b177689cb8e0) keeps signing the opkg/24.10 feed
# and is NOT touched by this script. This generates a SEPARATE, second identity:
# (prime256v1 PEM). This is the ONLY feed identity shater has since the opkg
# lane was removed (D22) — the old usign key is history, not a second lane.
#
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
# Paste its full PEM contents into the Gitea repo secret
# KEY_APK (the apk analog of the usign secret KEY_BUILD).
# Then delete the local file (or keep it in a password
# manager as the offline backup — losing it means every
# deployed router must re-trust a new key).
# dist/shater-apk.pem PUBLIC key. Commit it next to shater-feed.pub:
# KEY_APK. Then delete the local file (or keep it in a
# password manager as the offline backup — losing it
# means every deployed router must re-trust a new key).
# dist/shater-apk.pem PUBLIC key. Commit it:
# git add -f dist/shater-apk.pem
# (-f because /dist/ is gitignored). Routers install it
# as /etc/apk/keys/shater-apk.pem.
#
# Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of
# every router that already installed shater-apk.pem (same rule as D7 for the
# usign key).
# every router that already installed shater-apk.pem (see D22).
set -eu
REPO="$(cd "$(dirname "$0")/.." && pwd)"
-60
View File
@@ -1,60 +0,0 @@
#!/bin/bash
# Make `usign` available on the CI runner so ci/make-index.sh can sign the opkg
# feed index. The OpenWrt SDK ships usign, but the index/signing step runs on the
# bare runner (outside the SDK container), so we build the tiny standalone tool
# from source (no libubox — it is intentionally dependency-free so it can
# bootstrap a build system). No-op if usign is already on PATH.
#
# Ported unchanged from Shater v0.1 (ci/install-usign.sh): usign is
# format-agnostic and the signing story is identical for the v0.2 4-package feed.
#
# CI cache: a previously-built binary is reused from $USIGN_CACHE (default:
# <repo>/.cache/tools — a workspace dir the workflow persists via actions/cache),
# skipping the apt + cmake + clone + build (~1 min). After a fresh build the
# binary is copied there so the NEXT run hits the cache. usign is a tiny static
# helper with no versioned protocol — a stale cached binary cannot mis-sign.
set -eu
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TOOLS="${USIGN_CACHE:-$REPO_ROOT/.cache/tools}"
# place <binary> — install onto PATH (system-wide if we can, else ~/bin)
place() {
local SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
if $SUDO install -m0755 "$1" /usr/local/bin/usign 2>/dev/null; then
:
else
mkdir -p "$HOME/bin"
install -m0755 "$1" "$HOME/bin/usign"
echo "$HOME/bin" >> "${GITHUB_PATH:-/dev/null}"
export PATH="$HOME/bin:$PATH"
fi
}
if command -v usign >/dev/null 2>&1; then
echo "[usign] already present: $(command -v usign)"
exit 0
fi
if [ -x "$TOOLS/usign" ]; then
place "$TOOLS/usign"
echo "[usign] restored from cache: $(command -v usign || echo "$HOME/bin/usign")"
exit 0
fi
SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
if ! command -v cmake >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq cmake gcc git
fi
tmp="$(mktemp -d)"
# Canonical source; fall back to the GitHub mirror if git.openwrt.org is flaky.
git clone --depth 1 https://git.openwrt.org/project/usign.git "$tmp/usign" \
|| git clone --depth 1 https://github.com/openwrt/usign.git "$tmp/usign"
( cd "$tmp/usign" && cmake -DCMAKE_BUILD_TYPE=Release . >/dev/null && make >/dev/null )
place "$tmp/usign/usign"
# seed the cache for the next run (best-effort)
mkdir -p "$TOOLS" 2>/dev/null && install -m0755 "$tmp/usign/usign" "$TOOLS/usign" 2>/dev/null || true
echo "[usign] built: $(command -v usign || echo "$HOME/bin/usign")"
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# Build the opkg feed index (Packages + Packages.gz) with SHA256 for a dir of
# .ipk files, then optionally usign-sign it if $KEY_BUILD (the Gitea repo secret)
# is set and usign is present. Arg $1 = feed dir.
#
# Ported from Shater v0.1 (ci/make-index.sh), unchanged. It is package-count and
# package-name agnostic: it indexes whatever .ipk are in the dir, so it serves
# BOTH the per-arch feed built by ci/build-feed.sh AND the combined release feed
# assembled in the release job (shaterd + byedpi per-arch, shater-core +
# luci-app-shater = _all). opkg filters by Architecture at install time, so one
# combined URL serves every device.
#
# Feed format: opkg `src/gz` (.ipk + text Packages index, usign signature).
# OpenWrt 24.10 (our SDK) still uses opkg; apk arrives at 25.12. The committed
# trust anchor dist/shater-feed.pub is a usign (Ed25519) key, matching this.
set -e
OUT="${1:?feed dir required}"; cd "$OUT"
: > Packages
for ipk in *.ipk; do
[ -e "$ipk" ] || continue
ctrl=$(tar -xzOf "$ipk" ./control.tar.gz | tar -xzO ./control)
sz=$(wc -c < "$ipk"); sha=$(sha256sum "$ipk" | cut -d' ' -f1)
printf '%s\n' "$ctrl" | sed '/^[[:space:]]*$/d' >> Packages
printf 'Filename: %s\nSize: %s\nSHA256sum: %s\n\n' "$ipk" "$sz" "$sha" >> Packages
done
gzip -kf Packages
if [ -n "${KEY_BUILD:-}" ]; then
# Signing was requested — a missing/broken signer must FAIL the build, not
# silently ship an unsigned feed that routers with check_signature on reject.
command -v usign >/dev/null 2>&1 || { echo "[index] ERROR: KEY_BUILD set but usign not found" >&2; exit 1; }
umask 077; printf '%s\n' "$KEY_BUILD" > /tmp/usign.sec
usign -S -m Packages -s /tmp/usign.sec || { rm -f /tmp/usign.sec; echo "[index] ERROR: usign signing failed" >&2; exit 1; }
rm -f /tmp/usign.sec
echo "[index] signed -> Packages.sig ($(head -1 Packages.sig))"
else
echo "[index] no KEY_BUILD -> UNSIGNED feed (opkg needs check_signature off, or set the secret)"
fi
echo "[index] contents:"; ls -l
+254 -11
View File
@@ -10,7 +10,6 @@
# the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its
# distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is
# ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves.
# Same --volumes-from workspace-sharing pattern as ci/sdk-build.sh (opkg lane).
#
# The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs
# as an unprivileged `build` user created here.
@@ -28,11 +27,16 @@ SDK_URL="${SDK_URL:?SDK_URL env required}"
echo "[apk-sdk] arch=$ARCH repo=$REPO out=$OUT"
echo "[apk-sdk] sdk=$SDK_URL"
# Package version derived from the git tag by ci/version.sh (bug B4). Forwarded
# to the unprivileged build user on the `su` line at the bottom of this file;
# openwrt/{shaterd,shater-core,luci-app-shater}/Makefile pick it up from the
# environment. All three are versioned from the tag — there is no exception.
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
test -f "$REPO/openwrt/shaterd/Makefile" || {
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
# The prebuilt shaterd artifact must already be staged for this arch (same
# contract as the opkg lane — scripts/build-shaterd.sh runs first).
# The prebuilt shaterd artifact must already be staged for this arch
# (artifact-order contract — scripts/build-shaterd.sh runs first).
case "$ARCH" in
x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;;
@@ -108,7 +112,7 @@ export HOME=/home/build
cd "$SDKDIR"
# Register this repo's openwrt/ as a src-link feed named `shater` (absolute
# path required) — identical to the opkg lane (ci/sdk-build.sh).
# path required).
cp -f feeds.conf.default feeds.conf
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
@@ -131,9 +135,104 @@ if ! ./scripts/feeds update -a; then
./scripts/feeds update -a
fi
echo "[apk-sdk] feeds install (prefer shater feed)"
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
./scripts/feeds install -p shater shaterd shater-core luci-app-shater
for p in shaterd shater-core byedpi luci-app-shater; do
# --- strip the SDK's generated per-package `default m` blocks ----------------
# Run 60 settled the question that runs 58 and 59 left open. Writing an explicit
# `# CONFIG_PACKAGE_kmod-x is not set` for all 1126 of them and re-running
# defconfig deselected exactly nothing: the count came back 1078, unchanged.
# Meanwhile the very same explicit form DID stick for CONFIG_ALL/ALL_KMODS/
# ALL_NONSHARED. The difference is prompts. kconfig only honours a user value for
# a symbol that has one (sym_calc_value ignores S_DEF_USER for a promptless
# symbol and falls back to its `default`), and the ALL* symbols carry prompts in
# the SDK's own Config.in while these generated blocks are bare:
#
# config PACKAGE_kmod-mlx5-core
# tristate
# default m
#
# So no value we write into .config can ever turn them off — the fix has to
# remove the `default m` itself. That is what this does: drop every generated
# `config PACKAGE_*` block from the SDK's Config-build.in before the first
# defconfig. Nothing is lost by it — these blocks only replay which packages the
# BUILDBOT happened to build; the packages themselves are still declared, with
# prompts, by the package tree (tmp/.config-package.in), which is what makes our
# four selectable and what `select` acts on. KERNEL_*/LIBC/TOOLCHAIN blocks are
# left untouched, so the SDK still reproduces its own toolchain settings.
CB=$(find . -maxdepth 2 -name 'Config-build.in' -print -quit 2>/dev/null || true)
if [ -n "$CB" ] && command -v perl >/dev/null 2>&1; then
pkg_before=$(grep -c '^config PACKAGE_' "$CB" || true)
# Paragraph-wise delete: a block is `config PACKAGE_x`, its indented body, and
# the blank line that ends it. Anchored per-line (/m) so nothing else matches.
perl -0777 -pi -e 's/^config PACKAGE_\S+\n(?:[ \t]+\S[^\n]*\n)+\n//gm' "$CB"
pkg_after=$(grep -c '^config PACKAGE_' "$CB" || true)
echo "[apk-sdk] $CB: stripped $((pkg_before - pkg_after)) generated PACKAGE default blocks ($pkg_before -> $pkg_after)"
else
echo "[apk-sdk] WARNING: no Config-build.in found (or no perl) — per-package"
echo "[apk-sdk] 'default m' blocks stay; the kmod tripwire will catch it"
fi
# --- .config: turn OFF the SDK's mass-select defaults ------------------------
# Symptom (v0.2.2, and still v0.2.3 run 58): the SDK ran `apk mkpkg` on ~1100
# kmod-* packages — mlx5, amdgpu, ata, isdn, none of which we ship — and died
# with `Disk quota exceeded` on the runner's 64 GB ZFS quota. Our kmod deps pull
# in `package/kernel/linux/compile`, which packs every module marked =m.
#
# Why they are =m has nothing to do with anything we write here. An OpenWrt SDK
# carries its OWN top-level Config.in (target/sdk/files/Config.in), and it reads:
#
# config ALL_NONSHARED
# bool "Select all target specific packages by default"
# default ALL
# config ALL_KMODS
# bool "Select all kernel module packages by default"
# default ALL
# config ALL
# bool "Select all userspace packages by default"
# default y <-- y, not n, and ONLY inside the SDK
#
# In the main tree those three default to n; the SDK flips ALL to y so that
# `make world` in a bare SDK builds something useful. So `make defconfig` on ANY
# .config — empty or not — selects the entire kernel. This is stock OpenWrt, not
# an ImmortalWrt quirk: openwrt/openwrt's target/sdk/files/Config.in is identical.
# (It also means the reference we copied, Slava-Shchipunov/awg-openwrt, builds
# every kmod too — it just never hits a disk quota on GitHub's runners.)
#
# Fix: state all three explicitly. They carry prompts in the SDK's Config.in, so
# they are user-settable and an explicit value beats the `default`. Note the FORM:
# kconfig writes a false bool as `# CONFIG_X is not set` and `CONFIG_X=n` is not
# reliably honoured, so `is not set` is the only form used here. All three are set
# rather than just the root `ALL`, so this keeps working whichever symbol a future
# SDK makes the root of the chain.
# Stash anything the SDK shipped (see below — today there is nothing) and start
# from a known-empty file, so what we build here is exactly what we intended.
if [ -s .config ]; then mv -f .config .config.sdk; fi
: > .config
for s in ALL ALL_KMODS ALL_NONSHARED; do
echo "# CONFIG_$s is not set" >> .config
done
# About that stash: an SDK tarball ships NO top-level .config (run 58 logged
# `grep: .config: No such file or directory` — the only `.config` inside the
# tarball is the prebuilt KERNEL's, under the linux dir). This is also why the
# first version of this fix was aimed at the wrong thing: there was never a
# buildbot .config here to append to. Nothing needs carrying over from it either,
# because
# target/sdk/Makefile bakes the buildbot's non-package settings — every
# CONFIG_KERNEL_* included — into the SDK's generated Config-build.in as kconfig
# `default`s (target/sdk/convert-config.pl). defconfig therefore reproduces the
# exact toolchain/kernel settings the SDK was built with, on its own; an earlier
# attempt to copy those lines by hand was redundant and is gone.
# Should a future SDK start shipping a .config, this keeps the two things that
# would then be worth honouring — the target identity and the package format —
# and still lets the lines above override the mass-select.
if [ -s .config.sdk ]; then
echo "[apk-sdk] SDK shipped a .config — carrying over target identity + format:"
grep -E '^CONFIG_TARGET_[a-z0-9_]+=y$|^CONFIG_TARGET_(BOARD|SUBTARGET|ARCH_PACKAGES)=|^CONFIG_USE_APK=' \
.config.sdk | tee -a .config | sed 's/^/[apk-sdk] /' || true
fi
for p in shaterd shater-core luci-app-shater; do
echo "CONFIG_PACKAGE_$p=m" >> .config
done
# Route source downloads through OpenWrt's fast CDN mirror FIRST. Some upstreams
@@ -151,28 +250,172 @@ fi
echo "[apk-sdk] defconfig"
make defconfig >/dev/null
for p in shaterd shater-core byedpi luci-app-shater; do
# --- second pass: deselect the kernel, keep only what our packages select -----
# Turning ALL/ALL_KMODS/ALL_NONSHARED off (above) provably worked — run 59 shows
# all three as `is not set` after defconfig — and changed the kmod count by
# exactly zero, 1078 both times. The kmods are not selected through ALL_KMODS at
# all. They are selected one by one, and here is where from:
#
# target/sdk/Makefile:
# ./convert-config.pl $(TOPDIR)/.config > $(SDK_BUILD_DIR)/Config-build.in
#
# The SDK's Config-build.in is GENERATED from the buildbot's .config — a config
# in which ALL_KMODS=y had already expanded into a `CONFIG_PACKAGE_kmod-*=m` line
# per module. convert-config.pl turns every `CONFIG_X=<val>` line into a kconfig
# symbol carrying an unconditional `default <val>`; its `next if
# /^(# )?CONFIG_PACKAGE/` filter sits in the `else` branch, which a line with an
# `=` in it never reaches. So the SDK ships, verbatim, 1078 blocks of:
#
# config PACKAGE_kmod-mlx5-core
# tristate
# default m
#
# Nothing there consults ALL_KMODS, which is why switching it off was inert.
#
# Fix: give those symbols an explicit user value. We cannot do it before the
# first defconfig — the list of names only exists once kconfig has expanded the
# tree — so this is a second pass: rewrite every selected kmod to `is not set`
# and re-run defconfig. Two kconfig rules make the result exactly what we want,
# and both are already demonstrated in our own logs:
# * an explicit value in .config beats a `default` (this is precisely why the
# `# CONFIG_ALL* is not set` lines survived defconfig in run 59), so the
# ~1078 kmods we do not need stay off;
# * `select` is a reverse dependency, OR-ed into the symbol's value AFTER the
# user value in sym_calc_value(), so it cannot be overridden by an explicit
# `n`. shater-core's `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` becomes
# `select PACKAGE_kmod-nft-tproxy` (scripts/package-metadata.pl: a `+` flag
# sets `$m = "select"`, and it re-emits the dependency's own depends too, so
# transitive kmods follow). Those come back on their own.
# Net effect: we build the handful of kmods our packages actually pull in.
#
# Rejected alternatives:
# * limiting what `package/kernel/linux/compile` packs — that target has no
# such knob; it iterates the selected set, so the selection IS the knob;
# * `package/kernel/linux/clean` + a targeted build — the kernel package would
# simply be rebuilt in full as a dependency of shater-core, same cost;
# * copying OpenWrt's own feed CI (openwrt/gh-action-sdk) — it does nothing
# about this; it just runs `make defconfig` and builds. Its one disk-related
# setting, CONFIG_AUTOREMOVE=y, is already the SDK's default;
# * editing the SDK's generated Config-build.in to strip the offending blocks —
# it would work, but it means parsing a generated kconfig file by hand and a
# format change would corrupt it silently. The two-pass approach uses only
# kconfig's documented semantics and leaves the evidence in .config.
kmods_all=$(grep -c '^CONFIG_PACKAGE_kmod-[^=]*=[my]$' .config || true)
if [ "$kmods_all" -gt 0 ]; then
echo "[apk-sdk] deselecting $kmods_all kmod packages, then defconfig again"
sed -i -E 's/^CONFIG_(PACKAGE_kmod-[^=]*)=[my]$/# CONFIG_\1 is not set/' .config
make defconfig >/dev/null
fi
# --- post-defconfig sanity + disk-cost readout -------------------------------
# A failed run leaves a ~27 MB log; digging the cause out of it is miserable, so
# print the handful of numbers that decide whether this run survives the
# runner's disk quota BEFORE anything is compiled.
kmods=$(grep -c '^CONFIG_PACKAGE_kmod.*=m' .config || true)
echo "[apk-sdk] target: board=$(sed -n 's/^CONFIG_TARGET_BOARD=//p' .config)" \
"subtarget=$(sed -n 's/^CONFIG_TARGET_SUBTARGET=//p' .config)" \
"arch_packages=$(sed -n 's/^CONFIG_TARGET_ARCH_PACKAGES=//p' .config)"
echo "[apk-sdk] kmod packages selected (=m): $kmods"
# Proof the mass-select stayed off: these three must come back out of defconfig
# as `is not set`. If any reads `=y`, the SDK's `default ALL`/`default y` won and
# the kmod count above will be in the four digits.
echo "[apk-sdk] mass-select symbols after defconfig:"
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/[apk-sdk] /' || true
# After the second pass the only kmods left are the ones shater-core's
# `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` turns into kconfig `select`s, plus
# whatever those select in turn — a handful. Worth printing verbatim while the
# list is short. A count of 0 is NOT fatal: those kmods ship in the router's own
# base feed, so apk resolves them there; but it would mean the selects did not
# fire, and that is something we want to see in the log rather than guess at.
if [ "$kmods" -le 30 ]; then
grep '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/[apk-sdk] /' || true
fi
# The two cache knobs are written before the first defconfig and have to survive
# both of them — losing DOWNLOAD_FOLDER silently costs us the dl/ cache, and
# losing LOCALMIRROR brings back the sourceware.org stalls. Cheap to just look.
echo "[apk-sdk] cache settings after defconfig:"
grep -E '^CONFIG_(LOCALMIRROR|DOWNLOAD_FOLDER)=' .config | sed 's/^/[apk-sdk] /' || true
echo "[apk-sdk] our packages after defconfig:"
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|luci-app-shater)=' .config \
| sed 's/^/[apk-sdk] /' || true
# Each of our 3 must have SURVIVED defconfig. If kconfig dropped one, it is
# because a symbol it `select`s (a DEPENDS entry) does not exist in the installed
# feeds — with the old append-everything .config that was masked by the SDK
# pre-selecting half the distro. `make package/<p>/compile` would then die with a
# cryptic "No rule to make target", far from the real cause.
for p in shaterd shater-core luci-app-shater; do
grep -q "^CONFIG_PACKAGE_$p=m" .config || {
echo "[apk-sdk] ERROR: $p is NOT selected after defconfig."
echo " kconfig dropped it -> one of its DEPENDS is missing from the"
echo " installed feeds (check the 'feeds install' step above)."; exit 10; }
done
# Only our two nft kmods (+ whatever they themselves depend on) have any business
# being selected here — a dozen at the very most. A count in the hundreds means an
# ALL_KMODS-style mass-select crept back in, and the run would spend ~40 min
# packing the kernel before dying on `Disk quota exceeded`. Fail now instead.
[ "$kmods" -le 200 ] || {
echo "[apk-sdk] ERROR: $kmods kmod packages selected — that is the whole kernel."
echo " Aborting before this fills the runner's disk. Two causes are"
echo " possible, and the lines below tell them apart:"
echo " (a) the mass-select is back on -> a CONFIG_ALL* line reads =y;"
echo " (b) the second pass did not take -> ALL* are 'is not set' but the"
echo " kmods returned anyway, i.e. the per-kmod 'default m' from the"
echo " SDK's generated Config-build.in outlived our explicit 'n'."
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/ /' || true
echo " first few kmods still selected:"
grep -m5 '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/ /' || true
exit 11; }
for p in shaterd shater-core luci-app-shater; do
echo "[apk-sdk] === build $p ==="
make "package/$p/compile" V=s -j"$(nproc)"
done
# What the build actually cost on disk. The runner's 64 GB ZFS quota is the
# binding constraint on this lane, so record it while the tree still exists.
echo "[apk-sdk] disk usage after compile:"
du -sh build_dir staging_dir bin 2>/dev/null || true
df -h /home/build || true
# A 25.12 apk-SDK must emit .apk — finding only .ipk means a wrong SDK was fed in.
anyapk=$(find bin -type f -name '*.apk' | wc -l)
[ "$anyapk" -gt 0 ] || {
echo "[apk-sdk] ERROR: no .apk produced under bin/ (wrong/older SDK? found $(find bin -type f -name '*.ipk' | wc -l) .ipk)";
find bin -maxdepth 4 -type d || true; exit 6; }
# Collect ONLY our 4 packages' .apk (apk filenames carry NO arch:
# Collect ONLY our 3 packages' .apk (apk filenames carry NO arch:
# `<name>-<ver>-r<rel>.apk`). NOT a blanket `*.apk` copy — the SDK bin/ can hold
# prebuilt base/kmod .apk that would bloat the index and be signed under our key.
found=0
for p in shaterd shater-core byedpi luci-app-shater; do
for p in shaterd shater-core luci-app-shater; do
for a in $(find bin -type f -name "${p}-*.apk"); do
cp -f "$a" "$OUT/"; found=$((found+1))
done
done
[ "$found" -ge 4 ] || { echo "[apk-sdk] ERROR: expected >=4 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
[ "$found" -ge 3 ] || { echo "[apk-sdk] ERROR: expected >=3 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
echo "[apk-sdk] collected $found of our .apk"
# --- assert the tag-derived version actually reached the packages -------------
# B4's failure mode is a wrong-but-plausible version shipping silently, so the
# env -> make hand-off is verified, not trusted: each of our three tag-versioned
# packages must be named `<name>-<ver>-r<rel>.apk`. Every package this repo ships
# is tag-versioned, so the check covers all of them with no exception to
# remember. This runs BEFORE `apk mkndx`, so a stale version can never even
# reach the index.
if [ -n "${SHATER_PKG_VERSION:-}" ] && [ -n "${SHATER_PKG_RELEASE:-}" ]; then
want="${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
for p in shaterd shater-core luci-app-shater; do
[ -f "$OUT/${p}-${want}.apk" ] || {
echo "[apk-sdk] ERROR: $p was not built as version '$want'."
echo " SHATER_PKG_VERSION/SHATER_PKG_RELEASE did not reach the package"
echo " Makefile — the build would have shipped a stale version (bug B4)."
echo "[apk-sdk] collected:"; ls -1 "$OUT" | sed 's/^/ /'
exit 12; }
done
echo "[apk-sdk] version check OK — our 3 packages are $want"
fi
# --- index + sign: exactly how the OpenWrt 25.12 buildsystem does it ---------
# apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \
# --output packages.adb *.apk
@@ -204,7 +447,7 @@ INNER
chmod 0644 /home/build/inner.sh
su build -s /bin/bash -c \
"ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' bash /home/build/inner.sh"
"ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' SHATER_PKG_VERSION='${SHATER_PKG_VERSION:-}' SHATER_PKG_RELEASE='${SHATER_PKG_RELEASE:-}' bash /home/build/inner.sh"
chmod -R a+rwX "$OUT" 2>/dev/null || true
echo "[apk-sdk] OK arch=$ARCH — apk feed dir:"
-116
View File
@@ -1,116 +0,0 @@
#!/bin/sh
# Runs INSIDE an `openwrt/sdk:<target>-<ver>` container (CWD = SDK root
# /builder). The job's workspace is shared into this container via
# `docker run --volumes-from`, so the repo is visible at $REPO and output goes
# to $OUT (a dir under the repo, hence also visible to the runner afterwards).
#
# Unlike Shater v0.1 (which compiled ONLY xrayctl in the SDK and hand-packed the
# pure-data packages with tar), v0.2 builds ALL FOUR packages the canonical way,
# via the SDK feed + `make package/<p>/compile`:
#
# shaterd prebuilt binary — Build/Compile only VALIDATES that
# openwrt/shaterd/files/shaterd-<amd64|arm64>.upx was staged
# by scripts/build-shaterd.sh on the runner BEFORE this ran.
# (arch-specific .ipk: RSTRIP/STRIP disabled — packed ELF.)
# shater-core PKGARCH=all data glue (procd init, sysctl, uci-defaults).
# luci-app-shater PKGARCH=all LuCI thin launcher — its Makefile does
# `include $(TOPDIR)/feeds/luci/luci.mk`, so the `luci` feed
# MUST be updated first (that is what creates feeds/luci/luci.mk).
# byedpi arch-specific C — the SDK cross-compiles ciadpi from the
# upstream tarball (needs network for PKG_SOURCE_URL).
#
# Env (required): ARCH, REPO, OUT.
set -eu
ARCH="${ARCH:?ARCH env required}"
REPO="${REPO:?REPO env required}"
OUT="${OUT:?OUT env required}"
mkdir -p "$OUT"
echo "[sdk] arch=$ARCH repo=$REPO out=$OUT"
test -f "$REPO/openwrt/shaterd/Makefile" || {
echo "[sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
# The prebuilt shaterd artifact must already be staged for this arch.
case "$ARCH" in
x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;;
*) echo "[sdk] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
esac
test -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" || {
echo "[sdk] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
echo " scripts/build-shaterd.sh must run on the runner before the SDK build."; exit 3; }
# --- register this repo's openwrt/ as a src-link feed named `shater` ---------
# src-link REQUIRES an absolute path; $REPO/openwrt is exactly a feed root (it
# contains the 4 package dirs and nothing else that looks like a package).
cp -f feeds.conf.default feeds.conf
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
# Update metadata for ALL feeds: our `shater` feed + the SDK defaults (base,
# luci, packages, routing, telephony). We need `luci` for feeds/luci/luci.mk and
# `base`/`packages` for the runtime deps (kmod-nft-tproxy, kmod-nft-socket,
# ip-full, rpcd, luci-base) to resolve.
#
# Persistent feeds checkouts: $FEEDS_CACHE (a workspace dir the runner restores
# via actions/cache, shared into this container via --volumes-from) replaces
# the SDK's ephemeral feeds/ dir, so `feeds update` git-fetches deltas instead
# of re-cloning base+packages+luci every run (~7 min on the runner's slow
# github.com link). Correctness-safe: update always checks out feeds.conf's
# pinned revisions; if it ever fails on a cached checkout (e.g. a force-pushed
# upstream), the cache is wiped and the update retried with fresh clones.
if [ -n "${FEEDS_CACHE:-}" ] && mkdir -p "$FEEDS_CACHE" 2>/dev/null; then
rm -rf feeds
ln -s "$FEEDS_CACHE" feeds
echo "[sdk] feeds/ -> $FEEDS_CACHE (persistent cache)"
fi
echo "[sdk] feeds update -a"
if ! ./scripts/feeds update -a; then
[ -L feeds ] || { echo "[sdk] ERROR: feeds update failed"; exit 8; }
echo "[sdk] WARNING: feeds update failed on cached checkouts — wiping cache, cloning fresh"
find "$FEEDS_CACHE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
./scripts/feeds update -a
fi
echo "[sdk] feeds install (prefer shater feed)"
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
# Select our packages, then defconfig. `make package/<p>/compile` builds the
# explicit target regardless, but selecting first makes deps visible to defconfig.
for p in shaterd shater-core byedpi luci-app-shater; do
echo "CONFIG_PACKAGE_$p=m" >> .config
done
# Route source downloads through OpenWrt's fast CDN mirror FIRST — sourceware.org
# (elfutils) and other upstreams intermittently stall mid-transfer, and curl's
# --connect-timeout doesn't cover a stalled stream, so the SDK download hangs the
# build. LOCALMIRROR is tried before each package's own PKG_SOURCE_URL. (lx CI)
echo 'CONFIG_LOCALMIRROR="https://sources.cdn.openwrt.org"' >> .config
# Persistent dl/ across runs: $DL_DIR is a workspace dir the runner restores via
# actions/cache (see ci/build-feed.sh). Correctness-safe: the buildroot verifies
# PKG_HASH on every file already in dl/ and re-downloads on mismatch, so a stale
# cache can never leak a wrong source into the build.
if [ -n "${DL_DIR:-}" ]; then
echo "CONFIG_DOWNLOAD_FOLDER=\"$DL_DIR\"" >> .config
fi
echo "[sdk] defconfig"
make defconfig >/dev/null
# --- compile the 4 packages --------------------------------------------------
for p in shaterd shater-core byedpi luci-app-shater; do
echo "[sdk] === build $p ==="
make "package/$p/compile" V=s -j"$(nproc)"
done
# --- collect ONLY our 4 packages' .ipk (per-arch shaterd/byedpi + _all core/luci)
# NOT `find bin -name '*.ipk'`: the openwrt/sdk image ships HUNDREDS of prebuilt
# kmod/base .ipk under bin/, which a blanket copy would pull into the feed and
# get signed under OUR key. Match each package's own `<name>_<ver>_<arch>.ipk`.
found=0
for p in shaterd shater-core byedpi luci-app-shater; do
for ipk in $(find bin -type f -name "${p}_*.ipk"); do
cp -f "$ipk" "$OUT/"; found=$((found+1))
done
done
[ "$found" -ge 4 ] || { echo "[sdk] ERROR: expected >=4 of OUR .ipk, collected $found"; echo "[sdk] (all .ipk under bin/:)"; find bin -type f -name '*.ipk' | head -20; exit 4; }
chmod -R a+rwX "$OUT" 2>/dev/null || true
echo "[sdk] OK arch=$ARCH — collected $found of our .ipk:"
ls -l "$OUT"
Executable
+133
View File
@@ -0,0 +1,133 @@
#!/bin/sh
# ci/version.sh — the SINGLE source of truth for "what version is this build?".
#
# WHY THIS EXISTS (bug B4)
# -----------------------
# PKG_VERSION/PKG_RELEASE used to be hand-written literals in the four package
# Makefiles, and nobody remembered to bump 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
# vs r2's 5 488 336 B). Since apk offers an upgrade only when the feed's version
# string differs from the installed one, `apk update` saw nothing new and the
# routers could not be updated through the normal path at all.
#
# So the version is now DERIVED, in CI, from the git tag, and the package
# Makefiles only carry a fallback for manual/offline builds.
#
# THE SCHEME
# ----------
# tag push `vX.Y.Z` -> PKG_VERSION=X.Y.Z PKG_RELEASE=1
# any other build -> PKG_VERSION=X.Y.Z of the NEAREST reachable tag,
# (workflow_dispatch, PKG_RELEASE=<commits since that tag> + 1
# rolling `latest`)
# no tag / no git at all -> PKG_VERSION=0.0.0 PKG_RELEASE=1 (+ warning)
#
# apk compares `<upstream>-r<rel>` as: the dotted upstream part first
# (numerically, component by component), the `r<rel>` only as a tie-break.
# Verified against the real tool, not from memory —
# apk-tools 3.0.3 (`apk version -t`) and apk-tools 2.14.6:
# 0.2.6-r1 > 0.2.0-r3 0.2.6-r12 > 0.2.6-r1
# 0.2.7-r1 > 0.2.6-r12 0.0.0-r1 < 0.2.0-r3
# That is exactly the ordering this scheme needs:
# * a release always outranks every rolling build that preceded it
# (0.2.7-r1 > 0.2.6-rN for any N — the dotted part decides), and
# * rolling builds between two releases grow monotonically (r2 < r10 < r11),
# so a rolling build can never look newer than the next release, and the
# `latest` feed still moves forward on every dispatch.
#
# +1 on the commit count (rather than the raw count) only avoids `-r0` and makes
# a dispatch build of the tagged commit itself identical to the release build of
# that same commit — which is the truth: same tree, same binary.
#
# Every package this repo ships is versioned from the tag. There used to be one
# exception (an external tool carrying its upstream's own version); it is gone
# with the package, and nothing here has to remember it any more.
#
# USAGE
# ci/version.sh # or --env: eval-able / $GITHUB_ENV-able lines
# ci/version.sh --pkg-version # X.Y.Z
# ci/version.sh --pkg-release # R
# ci/version.sh --binary # vX.Y.Z-rR[-g<sha>] for constant.Version
#
# Env:
# SHATER_REF / GITHUB_REF when it is `refs/tags/<tag>` that tag wins and no
# git history is needed (the tag-push path is exact
# even on a shallow checkout).
set -eu
REPO="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
TAG=""
EXACT=0
N=0
SHA=""
# --- 1) an explicit tag ref is authoritative (and needs no git) --------------
REF="${SHATER_REF:-${GITHUB_REF:-}}"
case "$REF" in
refs/tags/*) TAG="${REF#refs/tags/}"; EXACT=1 ;;
esac
# --- 2) otherwise ask git for the nearest reachable release tag --------------
# `--match 'v[0-9]*'` keeps non-release tags (latest, sdk-cache, apk-latest-*,
# musl-toolchain-cache) out. This repo is a sing-box FORK and therefore also
# carries upstream's v1.x tags — `git describe` picks the CLOSEST tag by commit
# distance, so our own v0.2.x (a handful of commits back) always wins over
# upstream's v1.x (thousands of commits back). The tag it picked is logged
# below, so a surprise is visible in the CI log rather than silently shipped.
if [ "$EXACT" -eq 0 ]; then
if D="$(git -C "$REPO" describe --tags --long --match 'v[0-9]*' 2>/dev/null)"; then
# `v0.2.6-1-g02c266188` -> TAG=v0.2.6 N=1 SHA=g02c266188.
# `%` strips the SHORTEST matching suffix, so a tag that itself contains a
# dash (`v0.2.0-healthplan`) survives intact.
TAG="${D%-*-g*}"
REST="${D#"$TAG"-}"
N="${REST%%-*}"
SHA="${REST#*-}"
if [ "$N" -eq 0 ]; then EXACT=1; fi
fi
fi
# --- 3) tag -> numeric PKG_VERSION ------------------------------------------
# Keep the leading dotted-numeric run only: `v0.2.0-healthplan` -> `0.2.0`.
VER=""
if [ -n "$TAG" ]; then
VER="$(printf '%s' "${TAG#v}" | sed -n 's/^\([0-9][0-9.]*\).*/\1/p' | sed 's/\.*$//')"
fi
if [ -z "$VER" ]; then
# No release tag anywhere (shallow clone with no tags, a tarball export, a
# fresh fork). 0.0.0 is BELOW every version we have ever published, so such a
# build can never masquerade as an upgrade on a real router; the commit count
# still makes successive dev builds distinguishable.
VER="0.0.0"
EXACT=0
N="$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 0)"
SHA="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '')"
[ -z "$SHA" ] || SHA="g$SHA"
echo "[version] WARNING: no reachable vX.Y.Z tag (and/or no git) -> $VER" >&2
fi
# --- 4) PKG_RELEASE + the string stamped into the binary --------------------
if [ "$EXACT" -eq 1 ]; then
REL=1
FULL="v${VER}-r${REL}"
else
REL=$((N + 1))
FULL="v${VER}-r${REL}${SHA:+-$SHA}"
fi
echo "[version] tag='${TAG:-none}' commits_since=$N exact=$EXACT -> ${VER}-r${REL} (binary: $FULL)" >&2
case "${1:---env}" in
--env|"")
printf 'SHATER_PKG_VERSION=%s\n' "$VER"
printf 'SHATER_PKG_RELEASE=%s\n' "$REL"
printf 'SHATER_VERSION=%s\n' "$FULL"
;;
--pkg-version) printf '%s\n' "$VER" ;;
--pkg-release) printf '%s\n' "$REL" ;;
--binary|--version) printf '%s\n' "$FULL" ;;
*)
echo "usage: $0 [--env|--pkg-version|--pkg-release|--binary]" >&2
exit 2 ;;
esac
@@ -0,0 +1,35 @@
//go:build darwin
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
// (control.DisableUDPFragment sets IP_DONTFRAG=1 on darwin).
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
ctrlErr error
)
ctrlErr = rawConn.Control(func(fd uintptr) {
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_DONTFRAG)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Fatal(sockErr)
}
return value != 0
}
@@ -0,0 +1,36 @@
//go:build linux
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
// (control.DisableUDPFragment sets IP_MTU_DISCOVER=IP_PMTUDISC_DO on linux,
// the same flag the user-visible failure was traced to on android).
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
ctrlErr error
)
ctrlErr = rawConn.Control(func(fd uintptr) {
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU_DISCOVER)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Fatal(sockErr)
}
return value == unix.IP_PMTUDISC_DO
}
@@ -0,0 +1,14 @@
//go:build !darwin && !linux && !windows
package dialer
import (
"syscall"
"testing"
)
func udpSocketDFSet(t *testing.T, _ syscall.Conn) bool {
t.Helper()
t.Skip("DF socket-flag introspection implemented for darwin, linux and windows only")
return false
}
@@ -0,0 +1,43 @@
//go:build windows
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/windows"
)
// IP_MTU_DISCOVER on windows (ws2ipdef.h); control.DisableUDPFragment sets it to
// IP_PMTUDISC_DO, the same "don't fragment" state the linux helper checks.
const (
windowsIPMTUDiscover = 71
windowsPMTUDiscDo = 1
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on.
// shater addition: upstream ships linux + darwin only, so the whole suite
// skipped on the dev host — where it is the one platform we can actually run it
// on before the router build.
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
)
ctrlErr := rawConn.Control(func(fd uintptr) {
value, sockErr = windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, windowsIPMTUDiscover)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Skip("IP_MTU_DISCOVER is not readable on this host: ", sockErr)
}
return value == windowsPMTUDiscDo
}
+99
View File
@@ -0,0 +1,99 @@
// lx: regression tests for the udp_fragment / UDPFragmentDefault
// plumbing. The WireGuard endpoint (and MASQUE outbound) rely on
// UDPFragmentDefault=true reaching the real UDP socket as "DF clear": with DF
// set, an outer datagram larger than the path MTU is silently dropped instead
// of fragmented, which blackholes nested tunnels (AWG-over-AWG, MASQUE-over-AWG)
// and AWG s4 transport junk. These tests assert the socket flag itself, on both
// paths a WireGuard bind can take: the dialer (ClientBind, detour case) and the
// listener control (StdNetBind via WireGuardControl, no-detour case).
package dialer
import (
"context"
"net"
"syscall"
"testing"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func dialUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
t.Helper()
d, err := NewDefault(context.Background(), options)
if err != nil {
t.Fatal(err)
}
conn, err := d.DialContext(context.Background(), N.NetworkUDP, M.ParseSocksaddr("127.0.0.1:9"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
sysConn, isSysConn := conn.(syscall.Conn)
if !isSysConn {
t.Fatalf("dialed UDP conn %T does not expose SyscallConn", conn)
}
return sysConn
}
func listenUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
t.Helper()
d, err := NewDefault(context.Background(), options)
if err != nil {
t.Fatal(err)
}
// WireGuardControl() is the listener control conn.StdNetBind installs on the
// socket a no-detour WireGuard endpoint sends its outer datagrams from — the
// exact socket the DF default decides the fate of.
listenConfig := net.ListenConfig{Control: d.WireGuardControl()}
packetConn, err := listenConfig.ListenPacket(context.Background(), "udp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = packetConn.Close() })
sysConn, isSysConn := packetConn.(syscall.Conn)
if !isSysConn {
t.Fatalf("listened UDP conn %T does not expose SyscallConn", packetConn)
}
return sysConn
}
// Upstream default: no UDPFragmentDefault, no udp_fragment → DF is set on both
// the dial and listener paths. Pins the baseline the endpoint fix opts out of.
func TestUDPFragmentDFByDefault_LX(t *testing.T) {
if !udpSocketDFSet(t, dialUDPForDF(t, option.DialerOptions{})) {
t.Fatal("default dialer must set DF on dialed UDP sockets")
}
if !udpSocketDFSet(t, listenUDPForDF(t, option.DialerOptions{})) {
t.Fatal("default dialer must set DF on listener-control UDP sockets")
}
}
// UDPFragmentDefault=true (what the WireGuard endpoint and MASQUE outbound now
// set) → DF clear on both paths, so oversize outer datagrams fragment instead
// of vanishing.
func TestUDPFragmentDefaultClearsDF_LX(t *testing.T) {
options := option.DialerOptions{UDPFragmentDefault: true}
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("UDPFragmentDefault=true must leave DF clear on dialed UDP sockets")
}
if udpSocketDFSet(t, listenUDPForDF(t, options)) {
t.Fatal("UDPFragmentDefault=true must leave DF clear on listener-control UDP sockets")
}
}
// Explicit user config always wins over the protocol default, in both
// directions.
func TestUDPFragmentExplicitOverride_LX(t *testing.T) {
fragmentOff := false
options := option.DialerOptions{UDPFragment: &fragmentOff, UDPFragmentDefault: true}
if !udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("udp_fragment=false must set DF even when the protocol default allows fragmentation")
}
fragmentOn := true
options = option.DialerOptions{UDPFragment: &fragmentOn}
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("udp_fragment=true must leave DF clear even without a protocol default")
}
}
+223
View File
@@ -0,0 +1,223 @@
//go:build with_quic
package httpclient
import (
"context"
stdTLS "crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
sbTLS "github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
// raceProbePayload is large enough that it cannot ride along in the response
// headers: the caller has to read the body off the QUIC stream AFTER
// roundTripHTTP3Race has returned. That is the whole point of the test.
const raceProbePayload = 64 * 1024
var _ N.Dialer = (*plainDialer)(nil)
type plainDialer struct{}
func (d *plainDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
}
func (d *plainDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return net.ListenUDP("udp", nil)
}
// splitDialer sends the HTTP/3 racer and the HTTP/2 racer to two different
// listeners, so a test can decide which one of them wins without having to bind
// a TCP and a UDP socket on the same port number.
type splitDialer struct {
udp M.Socksaddr
tcp M.Socksaddr
}
func (d *splitDialer) DialContext(ctx context.Context, network string, _ M.Socksaddr) (net.Conn, error) {
destination := d.tcp
if network == N.NetworkUDP {
destination = d.udp
}
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
}
func (d *splitDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return net.ListenUDP("udp", nil)
}
func startH3Server(t *testing.T, handler http.Handler) M.Socksaddr {
t.Helper()
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
if err != nil {
t.Fatal(err)
}
listener, err := quic.ListenAddrEarly("127.0.0.1:0", &stdTLS.Config{
Certificates: []stdTLS.Certificate{*certificate},
NextProtos: []string{http3.NextProtoH3},
MinVersion: stdTLS.VersionTLS13,
}, nil)
if err != nil {
t.Fatal(err)
}
server := &http3.Server{Handler: handler}
go server.ServeListener(listener)
t.Cleanup(func() {
server.Close()
listener.Close()
})
return M.ParseSocksaddr(listener.Addr().String())
}
func newRaceProbeTransport(t *testing.T, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
return newRaceProbeTransportWithDialer(t, &plainDialer{}, serverAddr)
}
func newRaceProbeTransportWithDialer(t *testing.T, dialer N.Dialer, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
t.Helper()
baseTLSConfig, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
Enabled: true,
Insecure: true,
ServerName: "localhost",
})
if err != nil {
t.Fatal(err)
}
h2Fallback, err := newHTTP2FallbackTransport(dialer, baseTLSConfig, option.HTTP2Options{})
if err != nil {
t.Fatal(err)
}
inner, err := newHTTP3FallbackTransport(dialer, baseTLSConfig, h2Fallback, option.QUICOptions{}, 300*time.Millisecond)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { inner.Close() })
return inner.(*http3FallbackTransport), "https://" + serverAddr.String() + "/probe"
}
// TestHTTP3RaceWinnerBodyStaysReadable pins that the response handed back by the
// HTTP/3 race is a LIVE response: its body must still be readable after
// roundTripHTTP3Race returns. Cancelling the context the winner was issued on
// resets its QUIC stream, so a "successful" round trip would hand the caller a
// response it can never read.
func TestHTTP3RaceWinnerBodyStaysReadable(t *testing.T) {
payload := make([]byte, raceProbePayload)
for i := range payload {
payload[i] = byte(i)
}
serverAddr := startH3Server(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Write(payload)
}))
transport, url := newRaceProbeTransport(t, serverAddr)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
// No cached HTTP/3 connection yet and a bodyless GET is replayable, so this
// takes the racing path.
response, err := transport.RoundTrip(request)
if err != nil {
t.Fatal("round trip: ", err)
}
defer response.Body.Close()
if response.ProtoMajor != 3 {
t.Fatalf("expected the HTTP/3 racer to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("the race winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
}
if len(body) != len(payload) {
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
}
}
// TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled covers the
// other half of the race: the HTTP/2 fallback wins, so its body must survive the
// race, and the HTTP/3 racer that lost must be torn down instead of being left
// to run to completion on the caller's behalf.
func TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled(t *testing.T) {
payload := make([]byte, raceProbePayload)
for i := range payload {
payload[i] = byte(i)
}
h3Started := make(chan struct{}, 1)
h3Cancelled := make(chan struct{}, 1)
// The HTTP/3 handler never answers, so the fallback wins on the timer.
h3Addr := startH3Server(t, http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
select {
case h3Started <- struct{}{}:
default:
}
<-request.Context().Done()
select {
case h3Cancelled <- struct{}{}:
default:
}
}))
h2Server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Write(payload)
}))
h2Server.EnableHTTP2 = true
h2Server.StartTLS()
t.Cleanup(h2Server.Close)
transport, _ := newRaceProbeTransportWithDialer(t, &splitDialer{
udp: h3Addr,
tcp: M.ParseSocksaddr(h2Server.Listener.Addr().String()),
}, h3Addr)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://localhost:443/probe", nil)
if err != nil {
t.Fatal(err)
}
response, err := transport.RoundTrip(request)
if err != nil {
t.Fatal("round trip: ", err)
}
if response.ProtoMajor != 2 {
t.Fatalf("expected the HTTP/2 fallback to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("the fallback winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
}
response.Body.Close()
if len(body) != len(payload) {
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
}
select {
case <-h3Started:
case <-time.After(5 * time.Second):
t.Fatal("the HTTP/3 racer never reached the server, the test proves nothing about cancelling it")
}
select {
case <-h3Cancelled:
case <-time.After(5 * time.Second):
t.Fatal("the losing HTTP/3 request was left running after the fallback won")
}
}
+67 -19
View File
@@ -6,6 +6,7 @@ import (
"context"
stdTLS "crypto/tls"
"errors"
"io"
"net/http"
"sync"
"time"
@@ -168,32 +169,65 @@ func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Re
return t.roundTripHTTP3Race(request, authority)
}
// cancelOnBodyClose releases a racer's context when the caller is done with the
// response it won. The race cannot release it on the way out: the body is read
// after RoundTrip returns, and the context the request was issued on is what
// keeps its stream alive.
type cancelOnBodyClose struct {
io.ReadCloser
cancel context.CancelFunc
cancelOnce sync.Once
}
func (b *cancelOnBodyClose) Close() error {
err := b.ReadCloser.Close()
b.cancelOnce.Do(b.cancel)
return err
}
func withCancelOnBodyClose(response *http.Response, cancel context.CancelFunc) *http.Response {
if response == nil || response.Body == nil {
cancel()
return response
}
response.Body = &cancelOnBodyClose{ReadCloser: response.Body, cancel: cancel}
return response
}
func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) {
ctx, cancel := context.WithCancel(request.Context())
defer cancel()
type result struct {
response *http.Response
err error
h3 bool
}
results := make(chan result, 2)
startRoundTrip := func(request *http.Request, useH3 bool) {
request = request.WithContext(ctx)
var (
response *http.Response
err error
)
if useH3 {
response, err = t.h3Transport.RoundTrip(request)
} else {
response, err = t.h2FallbackRoundTrip(request)
}
results <- result{response: response, err: err, h3: useH3}
// Each racer runs on a context of its own. A context shared by both cannot be
// cancelled when one of them wins: quic-go and net/http reset the winner's
// stream on cancellation, so the caller would be handed a response whose body
// stops mid-read with H3_REQUEST_CANCELLED. Only losers are cancelled here;
// the winner's cancel travels with its body and fires on Close.
startRoundTrip := func(useH3 bool) context.CancelFunc {
ctx, cancel := context.WithCancel(request.Context())
raceRequest := cloneRequestForRetry(request).WithContext(ctx)
go func() {
var (
response *http.Response
err error
)
if useH3 {
response, err = t.h3Transport.RoundTrip(raceRequest)
} else {
response, err = t.h2FallbackRoundTrip(raceRequest)
}
results <- result{response: response, err: err, h3: useH3}
}()
return cancel
}
goroutines := 1
received := 0
var fallbackCancel context.CancelFunc
h3Cancel := startRoundTrip(true)
drainRemaining := func() {
cancel()
for range goroutines - received {
go func() {
loser := <-results
@@ -203,7 +237,6 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
}()
}
}
go startRoundTrip(cloneRequestForRetry(request), true)
timer := time.NewTimer(t.fallbackDelay)
defer timer.Stop()
var (
@@ -215,20 +248,28 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
case <-timer.C:
if goroutines == 1 {
goroutines++
go startRoundTrip(cloneRequestForRetry(request), false)
fallbackCancel = startRoundTrip(false)
}
case raceResult := <-results:
received++
if raceResult.err == nil {
winnerCancel := fallbackCancel
if raceResult.h3 {
t.clearH3Broken(authority)
winnerCancel = h3Cancel
if fallbackCancel != nil {
fallbackCancel()
}
} else {
h3Cancel()
}
drainRemaining()
return raceResult.response, nil
return withCancelOnBodyClose(raceResult.response, winnerCancel), nil
}
if raceResult.h3 {
t.markH3Broken(authority)
h3Err = raceResult.err
h3Cancel()
if goroutines == 1 {
goroutines++
if !timer.Stop() {
@@ -237,14 +278,21 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
default:
}
}
go startRoundTrip(cloneRequestForRetry(request), false)
fallbackCancel = startRoundTrip(false)
}
} else {
fallbackErr = raceResult.err
if fallbackCancel != nil {
fallbackCancel()
}
}
if received < goroutines {
continue
}
h3Cancel()
if fallbackCancel != nil {
fallbackCancel()
}
drainRemaining()
switch {
case h3Err != nil && fallbackErr != nil:
+227 -18
View File
@@ -6,6 +6,8 @@ import (
"encoding/binary"
"math/rand"
"net"
"net/netip"
"slices"
"strings"
"time"
@@ -47,6 +49,33 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}()
serverName := IndexTLSServerName(b)
if serverName != nil {
// The SNI extension carries a LIST of names; MyServerName.Length is
// the length of the FIRST entry while MyServerName.ServerName is
// everything left in the extension. Plan the cuts over the first
// entry only: a second entry would otherwise be handed to the
// public suffix list as if it were part of the name.
name := serverName.ServerName
if serverName.Length >= 0 && serverName.Length < len(name) {
name = name[:serverName.Length]
}
// Packet fragmentation pays half a second per cut, record
// fragmentation pays microseconds — see the budget constants.
budget := recordCutBudget
if c.splitPacket {
budget = packetCutBudget
}
splitIndexes := cutOffsets(name, budget, rand.Intn)
if len(splitIndexes) == 0 {
// Nothing inside this name can be cut — it is empty or a single
// byte, so there is no offset that leaves a non-empty piece on
// both sides. Write the ClientHello as it stands: the loop
// below reads b[:splitIndexes[0]] unconditionally and would
// panic on an empty plan.
return c.Conn.Write(b)
}
for i := range splitIndexes {
splitIndexes[i] += serverName.Index
}
if c.splitPacket {
if c.tcpConn != nil {
err = c.tcpConn.SetNoDelay(true)
@@ -55,24 +84,6 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}
}
}
splits := strings.Split(serverName.ServerName, ".")
currentIndex := serverName.Index
if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" {
splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
}
if len(splits) > 1 && splits[0] == "..." {
currentIndex += len(splits[0]) + 1
splits = splits[1:]
}
var splitIndexes []int
for i, split := range splits {
splitAt := rand.Intn(len(split))
splitIndexes = append(splitIndexes, currentIndex+splitAt)
currentIndex += len(split)
if i != len(splits)-1 {
currentIndex++
}
}
var buffer bytes.Buffer
for i := 0; i <= len(splitIndexes); i++ {
var payload []byte
@@ -133,6 +144,204 @@ func (c *Conn) Write(b []byte) (n int, err error) {
return c.Conn.Write(b)
}
// labelSpan is the half-open byte range [start, end) of one DNS label inside a
// server name, relative to the first byte of that name.
type labelSpan struct {
start int
end int
}
// How many cuts Write may spend on one ClientHello. The two numbers differ
// because the two modes cost completely different things per cut — MEASURED
// 2026-07-27 on this tree, loopback peer, product default fallbackDelay
// (500 ms), one ClientHello per row:
//
// cuts tls_fragment (*net.TCPConn) tls_fragment (proxy conn) tls_record_fragment
// 1 502 ms 500 ms <1 ms
// 2 1.004 s 1.001 s <1 ms
// 4 2.008 s 2.002 s <1 ms
// 8 4.015 s 4.003 s 539 µs
// 21 10.540 s 10.509 s 525 µs
//
// So a cut in the PACKET modes costs half a second of connection setup, and it
// costs that on BOTH branches: writeAndWaitAck sleeps the whole fallbackDelay
// anyway whenever the ACK comes back inside 20 ms (its "under transparent
// proxy" case), and N.UnwrapReader only reaches the *net.TCPConn when nothing
// in the chain transforms the stream — which a proxy protocol conn always does,
// so a proxied egress takes the flat-500 ms branch regardless of RTT. The
// number of labels is chosen by whoever picked the hostname, so "a cut in every
// label" made a 253-byte SNI worth ~42 s of one connection's setup.
//
// In tls_record_fragment nothing waits at all: the whole ClientHello leaves in
// ONE write, split into more TLS records. 21 cuts cost 525 µs and 105 bytes of
// record headers, and a real server (1.1.1.1) completed the handshake with the
// ClientHello in 22 records in the same 77 ms it took with 2. That mode is
// where "cut every label" was always affordable — and it is the mode the field
// measurement that started this was taken in.
//
// recordCutBudget is therefore not a cost limit but a shape limit: real names
// have one to three labels outside the public suffix, so 4 never binds on real
// traffic, while a hostile 253-byte name cannot turn one ClientHello into 85
// records that no ordinary client would ever emit.
const (
packetCutBudget = 1
recordCutBudget = 4
)
// cutOffsets plans where the ClientHello must be cut, in byte offsets relative
// to the FIRST BYTE OF THE SERVER NAME, spending at most budget cuts. randIntn
// is math/rand's Intn in production; a test hands in its own to make the plan
// deterministic.
//
// A cut is only a cut if it lands STRICTLY INSIDE a label. Offset 0 of a label
// is that label's own boundary: it leaves the label — the very string the DPI
// box matches on — whole in the following segment. That is not theory. The old
// code drew rand.Intn(len(label)), so a one-byte label could only ever produce
// offset 0, and on the measured provider (blocks by name in the handshake)
// m.youtube.com, tv.youtube.com and www.youtube.com were all blocked with the
// cut sitting uselessly at the start of "m"/"tv"/"www", while the name itself
// travelled intact in one segment. Hence a label shorter than two bytes carries
// no cut at all.
func cutOffsets(name string, budget int, randIntn func(n int) int) []int {
var offsets []int
for _, span := range cutCandidates(name) {
if span.end-span.start < 2 {
continue // no interior offset exists
}
offsets = append(offsets, cutInside(span, randIntn))
if len(offsets) >= budget {
break
}
}
if len(offsets) == 0 && len(name) >= 2 {
// No candidate label was long enough to cut on its own (a.b.co.uk).
// Cut the name somewhere rather than hand it over in one piece: a
// matcher looking for the whole FQDN still fails across the split, even
// though no single label was severed.
offsets = append(offsets, cutInside(labelSpan{start: 0, end: len(name)}, randIntn))
}
slices.Sort(offsets) // candidates are returned by priority, the wire wants order
return offsets
}
// cutInside draws an offset strictly inside span, from its MIDDLE THIRD.
//
// Every interior offset severs the label, but not equally well: a cut one byte
// in leaves "outube" of "youtube", and a matcher keyed on a suffix or on a
// six-byte substring still reads it. The middle leaves two short, unremarkable
// halves. The draw stays random inside that third — a fixed point (say, exactly
// the middle of the longest label) would be a constant a middlebox vendor can
// special-case in one line, and the whole family of fragmentation tricks lives
// on making reassembly the only counter.
func cutInside(span labelSpan, randIntn func(n int) int) int {
lo, hi := span.start+1, span.end-1 // the interior offsets, both inclusive
if margin := (span.end - span.start - 1) / 3; margin > 0 {
lo += margin
hi -= margin
}
return lo + randIntn(hi-lo+1)
}
// cutCandidates returns the labels of name that a cut may land in, MOST WORTH
// CUTTING FIRST — which matters because the budget above is small.
//
// First is the registrable label: the one immediately left of the public
// suffix. That is the label a name-based blocklist keys on ("youtube" of
// youtube.com, www.youtube.com and studio.youtube.com alike, "ytimg" of
// i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also breaks
// any match on the whole FQDN, so one cut covers both matchers. It is chosen by
// STRUCTURE, from the public suffix list — not by length, which is the trap the
// old code fell into from the other side: in cdn-static-assets.youtube.com the
// longest label is not the blocked one.
//
// The rest follow longest-first: among labels we have no structural reason to
// rank, a long one is likelier to be a distinctive token than "www", "m" or
// "tv". They are only reached when the budget allows more than one cut, or when
// the registrable label is too short to cut.
//
// The public suffix itself is dropped because it is shared by everything under
// it and carries none of the blocked word. WIDENING this set needs no proof,
// NARROWING it does, so an input the public suffix list has no opinion about (a
// trailing dot, an unmanaged TLD, a name that IS a suffix) keeps every label.
// No branch here ends up with nothing to cut except the empty name, which has
// nothing to cut by construction.
func cutCandidates(name string) []labelSpan {
spans := labelSpans(name)
suffix := publicsuffix.List.PublicSuffix(name)
switch {
case len(spans) == 0:
// name == "". Nothing to cut; Write sends the ClientHello unchanged.
case isIPLiteral(name):
// An IP literal is not a name (RFC 6066 forbids it in SNI) and its dots
// do not separate labels, so the public suffix list has nothing to say
// about it — it returns the literal itself. Treat the whole literal as
// one token: there is no name for a DPI box to read here, but the
// caller asked for a fragmented handshake and gets one.
return []labelSpan{{start: 0, end: len(name)}}
case suffix != "" && len(suffix) < len(name) && strings.HasSuffix(name, "."+suffix):
// The ordinary case, and the one the old arithmetic got wrong: it
// subtracted the number of dots in the WHOLE NAME, which — labels being
// always one more than dots — left exactly one label, the FIRST, for
// every name in existence. Subtract the number of labels in the SUFFIX
// instead: "com" is one ("www.youtube.com" keeps www + youtube),
// "co.uk" is two ("a.b.co.uk" keeps a + b).
if keep := len(spans) - strings.Count(suffix, ".") - 1; keep > 0 {
spans = spans[:keep]
}
// Everything else — suffix == "" (a trailing dot, which the list
// declines to parse), suffix == name (the name IS a public suffix:
// "com", "co.uk", "localhost"), or a suffix that is somehow not a tail
// of the name — keeps every label. Cutting inside a suffix costs a
// segment and hides nothing that was not already hidden; NOT cutting is
// the expensive mistake.
}
return byCutPriority(spans)
}
// byCutPriority puts the registrable label first and orders the rest
// longest-first. It never drops a span, so the budget — not this — decides how
// many labels are actually cut.
func byCutPriority(spans []labelSpan) []labelSpan {
if len(spans) < 2 {
return spans
}
out := make([]labelSpan, 0, len(spans))
out = append(out, spans[len(spans)-1])
rest := make([]labelSpan, len(spans)-1)
copy(rest, spans[:len(spans)-1])
slices.SortStableFunc(rest, func(a, b labelSpan) int {
return (b.end - b.start) - (a.end - a.start)
})
return append(out, rest...)
}
// labelSpans splits name on '.' and returns the byte range of each label.
// Empty labels (a leading, trailing or doubled dot) come back as zero-width
// spans and are dropped by cutOffsets, which is what keeps a name like
// ".youtube.com" away from rand.Intn(0) — that combination panicked.
func labelSpans(name string) []labelSpan {
if name == "" {
return nil
}
var spans []labelSpan
start := 0
for i := 0; i <= len(name); i++ {
if i == len(name) || name[i] == '.' {
spans = append(spans, labelSpan{start: start, end: i})
start = i + 1
}
}
return spans
}
func isIPLiteral(name string) bool {
_, err := netip.ParseAddr(name)
return err == nil
}
func (c *Conn) ReaderReplaceable() bool {
return true
}
+742
View File
@@ -0,0 +1,742 @@
package tf
// Cut planning: which label of the SNI gets a cut, where inside it, and how
// many cuts one ClientHello is allowed to cost.
//
// WHY THIS FILE EXISTS (2026-07-27)
// Conn.Write used to compute the labels to cut as
//
// splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
//
// which is identically splits[:1] for EVERY name, labels being always one
// more than dots. Exactly one label was ever cut — the LEFTMOST — so on a
// provider that blocks by the name in the handshake, youtube.com passed (its
// first label IS the blocked word) while m./tv./www./music./studio.youtube.com
// were all blocked, the cut sitting inside "m"/"tv"/"www" while "youtube"
// travelled whole in the next segment. Measured on the router.
//
// Two more halves of the same defect:
// - the offset came from rand.Intn(len(label)), whose 0 is the label's own
// boundary and severs nothing. For a one-byte label that is the ONLY
// value it can take;
// - an EMPTY label reached rand.Intn(0) and panicked the process. Reachable
// from the LAN: route/conn.go wraps the outbound with this and the
// ClientHello it fragments is the client's. See
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN.
//
// Every test below fails on the old expressions — see the mutation log.
import (
"crypto/tls"
"encoding/binary"
"io"
"math/rand"
"net"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- deterministic draws -----------------------------------------------------
// minRand takes the lowest offset a label allows, maxRand the highest. Between
// them they pin BOTH ends of the range, which is where the interesting failures
// live: it is the ends that decide whether the label is severed or only touched.
func minRand(int) int { return 0 }
func maxRand(n int) int { return n - 1 }
func fixedRand(v int) func(int) int {
return func(n int) int {
if v >= n {
return n - 1
}
return v
}
}
// severedLabel names the label that a cut at offset o splits in two, or says
// why it splits none. This is the assertion vocabulary of the whole file: the
// question is never "which number came out" but "which word did we break".
func severedLabel(name string, o int) string {
switch {
case o <= 0 || o >= len(name):
return "!outside the name"
case name[o] == '.' || name[o-1] == '.':
return "!a label boundary, nothing severed"
}
start := strings.LastIndexByte(name[:o], '.') + 1
end := len(name)
if i := strings.IndexByte(name[o:], '.'); i >= 0 {
end = o + i
}
return name[start:end]
}
func severedLabels(name string, offsets []int) []string {
var out []string
for _, o := range offsets {
out = append(out, severedLabel(name, o))
}
return out
}
// --- which label is cut ------------------------------------------------------
func TestCutOffsetsCutTheRegistrableLabel(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
packet []string // labels severed with the packet budget (1 cut)
record []string // ... and with the record budget (4 cuts), in wire order
why string
}{
{name: "youtube.com", packet: []string{"youtube"}, record: []string{"youtube"}},
{name: "www.youtube.com", packet: []string{"youtube"}, record: []string{"www", "youtube"},
why: "THE regression: the old code cut www and shipped youtube whole"},
{name: "m.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "a one-byte label has no interior offset and carries no cut"},
{name: "tv.youtube.com", packet: []string{"youtube"}, record: []string{"tv", "youtube"}},
{name: "music.youtube.com", packet: []string{"youtube"}, record: []string{"music", "youtube"}},
{name: "studio.youtube.com", packet: []string{"youtube"}, record: []string{"studio", "youtube"}},
{name: "cdn-static-assets.youtube.com", packet: []string{"youtube"}, record: []string{"cdn-static-assets", "youtube"},
why: "the LONGEST label is not the blocked one — structure decides, not length"},
{name: "foo.bar.baz.youtube.com", packet: []string{"youtube"}, record: []string{"foo", "bar", "baz", "youtube"},
why: "four candidates, four cuts, and the budget stops there"},
{name: "a.b.c.d.e.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "five one-byte labels: the budget is never even reached"},
{name: "i9.ytimg.com", packet: []string{"ytimg"}, record: []string{"i9", "ytimg"}},
{name: "example.co.uk", packet: []string{"example"}, record: []string{"example"},
why: "co.uk is TWO labels of public suffix"},
{name: "a.b.example.co.uk", packet: []string{"example"}, record: []string{"example"}},
{name: "example.com.br", packet: []string{"example"}, record: []string{"example"}},
{name: "site.pp.ru", packet: []string{"site"}, record: []string{"site"},
why: "pp.ru is a private two-label suffix"},
{name: "localhost", packet: []string{"localhost"}, record: []string{"localhost"},
why: "unmanaged TLD: the list returns the whole name, so cut it"},
{name: "com", packet: []string{"com"}, record: []string{"com"}},
{name: "co.uk", packet: []string{"uk"}, record: []string{"co", "uk"},
why: "the name IS the suffix: keep every label rather than cut nothing"},
{name: ".youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "leading dot: the empty label is skipped, NOT fed to rand.Intn(0)"},
{name: "youtube.com.", packet: []string{"youtube"}, record: []string{"youtube", "com"},
why: "trailing dot: the list declines to parse it, so every label stays a candidate"},
{name: "WWW.YouTube.COM", packet: []string{"YouTube"}, record: []string{"WWW", "YouTube"}},
{name: "ab", packet: []string{"ab"}, record: []string{"ab"}},
} {
t.Run(tc.name, func(t *testing.T) {
for _, draw := range []struct {
label string
fn func(int) int
}{{"lowest", minRand}, {"highest", maxRand}} {
got := severedLabels(tc.name, cutOffsets(tc.name, packetCutBudget, draw.fn))
require.Equal(t, tc.packet, got, "%s draw, packet budget: %s", draw.label, tc.why)
got = severedLabels(tc.name, cutOffsets(tc.name, recordCutBudget, draw.fn))
require.Equal(t, tc.record, got, "%s draw, record budget: %s", draw.label, tc.why)
}
})
}
}
// TestCutOffsetsSeverTheBlockedLabel is the field measurement turned into an
// instrument. On the measured provider these names differ only in the label in
// front of "youtube", and five of the six were blocked. What has to hold — for
// every draw and both budgets, not for most of them — is that the byte range of
// the blocked word straddles a cut.
func TestCutOffsetsSeverTheBlockedLabel(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, blocked string }{
{"youtube.com", "youtube"},
{"m.youtube.com", "youtube"},
{"tv.youtube.com", "youtube"},
{"www.youtube.com", "youtube"},
{"music.youtube.com", "youtube"},
{"studio.youtube.com", "youtube"},
{"cdn-static-assets.youtube.com", "youtube"},
{"i9.ytimg.com", "ytimg"},
{"a.b.example.co.uk", "example"},
} {
t.Run(tc.name, func(t *testing.T) {
start := strings.Index(tc.name, tc.blocked)
require.GreaterOrEqual(t, start, 0)
end := start + len(tc.blocked)
// Every draw the label can take, not a sample: the range is small
// enough to enumerate, so there is no "it passed 1000 times" here.
for draw := 0; draw < len(tc.name); draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(tc.name, budget, fixedRand(draw))
severed := false
for _, o := range offsets {
if o > start && o < end {
severed = true
}
}
require.True(t, severed,
"draw %d, budget %d: %q got cuts at %v (%v), none inside %q [%d,%d)",
draw, budget, tc.name, offsets, severedLabels(tc.name, offsets), tc.blocked, start, end)
}
}
})
}
}
// TestCutOffsetsStayInTheMiddleThird: every interior offset severs the label,
// but not equally well — one byte in leaves "outube" of "youtube", which a
// matcher keyed on a substring still reads. Both halves must keep at least
// (width-1)/3 + 1 bytes.
func TestCutOffsetsStayInTheMiddleThird(t *testing.T) {
t.Parallel()
for _, name := range []string{
"youtube.com", "www.youtube.com", "cdn-static-assets.youtube.com",
"music.youtube.com", "example.co.uk", "ab.example.com", "localhost",
} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < 64; draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
for _, o := range cutOffsets(name, budget, fixedRand(draw)) {
label := severedLabel(name, o)
require.NotContains(t, label, "!", "draw %d: cut at %d in %q severed nothing", draw, o, name)
start := strings.Index(name, label)
width := len(label)
margin := (width-1)/3 + 1
require.GreaterOrEqual(t, o-start, margin,
"draw %d: cut at %d leaves only %d byte(s) of %q on the left", draw, o, o-start, label)
require.GreaterOrEqual(t, start+width-o, margin,
"draw %d: cut at %d leaves only %d byte(s) of %q on the right", draw, o, start+width-o, label)
}
}
}
})
}
}
// TestCutOffsetsRespectTheBudget: the budget is what bounds a hostile name's
// cost — a measured 500 ms of connection setup per cut in the packet modes.
func TestCutOffsetsRespectTheBudget(t *testing.T) {
t.Parallel()
var long strings.Builder
for i := 0; i < 40; i++ {
long.WriteString("lb.")
}
long.WriteString("example.com") // 40 cuttable labels plus the registrable one
for _, budget := range []int{1, 2, 3, 4} {
require.Len(t, cutOffsets(long.String(), budget, rand.Intn), budget, "budget %d", budget)
}
require.Len(t, cutOffsets(long.String(), packetCutBudget, rand.Intn), 1,
"a 253-byte SNI must not be able to buy more than one 500 ms wait")
require.Len(t, cutOffsets(long.String(), recordCutBudget, rand.Intn), 4,
"nor more than five records")
}
// TestCutOffsetsFallBackWhenNoLabelCanBeCut covers the names where NO candidate
// label has an interior offset. Severing a label is impossible there, so what
// is checked is that a cut still happens and still lands inside the buffer: a
// matcher keyed on the whole FQDN fails across it.
func TestCutOffsetsFallBackWhenNoLabelCanBeCut(t *testing.T) {
t.Parallel()
for _, name := range []string{"a.b.co.uk", "x.pp.ru", "a.b.c.d", "1.2.3.4", "::1", "x.com"} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < len(name)+4; draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(name, budget, fixedRand(draw))
require.NotEmpty(t, offsets, "%q went out in one piece", name)
require.Greater(t, offsets[0], 0)
require.Less(t, offsets[len(offsets)-1], len(name))
}
}
})
}
}
// TestCutOffsetsAreOrderedAndDistinct: the write loop slices b between
// consecutive offsets, so anything out of order or repeated is an empty or
// negative segment on the wire. Candidates come back in PRIORITY order, which
// is not wire order — this is the test that the sort is not forgotten.
func TestCutOffsetsAreOrderedAndDistinct(t *testing.T) {
t.Parallel()
for _, name := range []string{
"www.youtube.com", "foo.bar.baz.youtube.com", "cdn-static-assets.youtube.com",
"a.bb.ccc.dddd.example.com", "youtube.com.", ".youtube.com", "co.uk",
} {
t.Run(name, func(t *testing.T) {
for i := 0; i < 200; i++ {
offsets := cutOffsets(name, recordCutBudget, rand.Intn)
prev := 0
for _, o := range offsets {
require.Greater(t, o, prev, "%q: offsets %v are not strictly increasing", name, offsets)
prev = o
}
require.Less(t, prev, len(name))
}
})
}
}
// --- the arithmetic must not panic on anything ------------------------------
// adversarialNames is the closed list of shapes that reach the arithmetic from
// outside: empty and one-byte names, every position a dot can take, names that
// are nothing but dots, names at the 253-byte limit, and bytes that are not
// ASCII at all. The unit test, the fuzz seed corpus and the end-to-end test all
// draw from it, so all three see the same inputs.
func adversarialNames() []string {
return []string{
"", "a", ".", "..", "...", "....",
".com", "com.", ".com.", ".youtube.com", "youtube.com.", ".youtube.com.",
"a..b.example.com", "..youtube..com..", "-.-.-.-", "-", "--",
"xn--p1ai", "test.xn--p1ai", "xn--", ".xn--p1ai.",
"\xff\xfe.example.com", "\x00\x00.com", "пример.рф", "\xff",
strings.Repeat("a", 253),
strings.Repeat("ab.", 84) + "a", // 253 bytes, 85 labels
strings.Repeat(".", 253),
strings.Repeat("a.", 126) + "a",
"1.2.3.4", "::1", "::ffff:1.2.3.4", "fe80::1%eth0", "0.0.0.0",
}
}
func TestCutOffsetsSurviveEveryAdversarialName(t *testing.T) {
t.Parallel()
for _, name := range adversarialNames() {
t.Run(strings.ToValidUTF8(name, "?"), func(t *testing.T) {
for i := 0; i < 100; i++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(name, budget, rand.Intn) // must not panic
require.LessOrEqual(t, len(offsets), budget)
if len(name) >= 2 {
require.NotEmpty(t, offsets, "%q is long enough to cut and was not cut", name)
} else {
require.Empty(t, offsets, "%q has no offset that leaves bytes on both sides", name)
}
prev := 0
for _, o := range offsets {
require.Greater(t, o, prev)
require.Less(t, o, len(name))
prev = o
}
}
}
})
}
}
// FuzzCutOffsets is the open half of the audit above: the closed list says what
// we thought of, this says whether anything else reaches rand.Intn with a
// non-positive argument or produces an offset the write loop cannot slice at.
// Under plain `go test` it runs the seed corpus, which is that closed list.
func FuzzCutOffsets(f *testing.F) {
for _, name := range adversarialNames() {
f.Add(name, packetCutBudget)
f.Add(name, recordCutBudget)
}
for _, name := range []string{"www.youtube.com", "a.b.example.co.uk", "localhost"} {
f.Add(name, 1)
f.Add(name, 4)
}
f.Fuzz(func(t *testing.T, name string, budget int) {
if budget < 0 {
budget = -budget
}
budget = budget%recordCutBudget + 1 // 1..4, never zero or negative
offsets := cutOffsets(name, budget, rand.Intn)
if len(offsets) > budget {
t.Fatalf("%q: %d offsets for a budget of %d", name, len(offsets), budget)
}
if len(name) >= 2 && len(offsets) == 0 {
t.Fatalf("%q (%d bytes) was handed over in one piece", name, len(name))
}
prev := 0
for _, o := range offsets {
if o <= prev || o >= len(name) {
t.Fatalf("%q: offsets %v are not strictly increasing inside [1,%d)", name, offsets, len(name))
}
prev = o
}
})
}
// --- end to end: what actually goes out on the wire -------------------------
// fakeConn records every Write. It is deliberately NOT a *net.TCPConn, which is
// also the common production case (the outbound is usually a proxy stream whose
// reader transforms the bytes, so N.UnwrapReader stops there), so Conn.Write
// takes the sleep-instead-of-ACK path — hence the 1ns fallback delay the tests
// below pass to NewConn.
type fakeConn struct {
writes [][]byte
}
func (c *fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
func (c *fakeConn) SetReadDeadline(time.Time) error { return nil }
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
func (c *fakeConn) Write(b []byte) (int, error) {
c.writes = append(c.writes, append([]byte(nil), b...))
return len(b), nil
}
// clientHelloFor produces a real ClientHello for serverName by letting
// crypto/tls build one and capturing the first write.
func clientHelloFor(t *testing.T, serverName string) []byte {
t.Helper()
rec := &fakeConn{}
_ = tls.Client(rec, &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}).Handshake()
require.NotEmpty(t, rec.writes, "crypto/tls wrote no ClientHello for %q", serverName)
hello := rec.writes[0]
// Control on the instrument: the parser this package ships must find the
// name we asked for, otherwise the assertions below prove nothing.
sni := IndexTLSServerName(hello)
require.NotNil(t, sni, "IndexTLSServerName found no SNI in the generated ClientHello")
require.Equal(t, serverName, sni.ServerName)
return hello
}
// buildClientHello assembles a ClientHello by hand around a server_name_list of
// the given entries. crypto/tls will not emit a name with a leading or trailing
// dot, an IP literal, an empty name or a second list entry — hostnameInSNI
// rewrites or refuses all of them — and those are exactly the shapes a
// forwarded ClientHello from a LAN client can carry.
func buildClientHello(t *testing.T, entries ...string) []byte {
t.Helper()
var list []byte
for _, e := range entries {
list = append(list, sniNameDNSHostnameType)
list = binary.BigEndian.AppendUint16(list, uint16(len(e)))
list = append(list, e...)
}
extBody := binary.BigEndian.AppendUint16(nil, uint16(len(list)))
extBody = append(extBody, list...)
ext := binary.BigEndian.AppendUint16(nil, sniExtensionType)
ext = binary.BigEndian.AppendUint16(ext, uint16(len(extBody)))
ext = append(ext, extBody...)
extensions := binary.BigEndian.AppendUint16(nil, uint16(len(ext)))
extensions = append(extensions, ext...)
body := []byte{0x03, 0x03} // client_version TLS 1.2
body = append(body, make([]byte, 32)...) // random
body = append(body, 0x00) // session_id length
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites
body = append(body, 0x01, 0x00) // compression_methods
body = append(body, extensions...)
handshake := []byte{handshakeType, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
handshake = append(handshake, body...)
record := []byte{contentType, 0x03, 0x01}
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
record = append(record, handshake...)
// Control on the instrument: this hand-built record must parse the way a
// real one does, or the tests below are testing a straw man.
sni := IndexTLSServerName(record)
require.NotNil(t, sni, "hand-built ClientHello did not parse")
require.Equal(t, len(entries[0]), sni.Length, "Length must be the FIRST entry")
require.Equal(t, entries[0], string(record[sni.Index:sni.Index+sni.Length]))
return record
}
// patchSNI rewrites the server name inside a ClientHello in place. from and to
// must be the same length, so every length field in the record stays valid.
func patchSNI(t *testing.T, hello []byte, from, to string) []byte {
t.Helper()
require.Equal(t, len(from), len(to), "patchSNI cannot change the length")
at := IndexTLSServerName(hello)
require.NotNil(t, at)
require.Equal(t, from, at.ServerName)
out := append([]byte(nil), hello...)
copy(out[at.Index:], to)
sni := IndexTLSServerName(out)
require.NotNil(t, sni)
require.Equal(t, to, sni.ServerName)
return out
}
// segments returns, for one recorded run, the payload of every segment written
// and the absolute offsets in hello at which the cuts fell.
func segments(t *testing.T, hello []byte, writes [][]byte, recordFragment bool) ([][]byte, []int) {
t.Helper()
var payloads [][]byte
for _, w := range writes {
if !recordFragment {
payloads = append(payloads, w)
continue
}
// A record-fragmented write is one or more TLS records: 3 bytes of the
// original header, a 2-byte length, then the payload.
for len(w) > 0 {
require.GreaterOrEqual(t, len(w), recordLayerHeaderLen, "truncated record header")
require.Equal(t, hello[:3], w[:3], "record header is not the ClientHello's own")
n := int(binary.BigEndian.Uint16(w[3:5]))
require.LessOrEqual(t, recordLayerHeaderLen+n, len(w), "record length runs past the write")
payloads = append(payloads, w[recordLayerHeaderLen:recordLayerHeaderLen+n])
w = w[recordLayerHeaderLen+n:]
}
}
require.NotEmpty(t, payloads, "Write returned without putting anything on the wire")
// Cut offsets are the cumulative payload lengths, shifted past the record
// header that the first fragment drops.
offset := 0
if recordFragment {
offset = recordLayerHeaderLen
}
var cuts []int
for _, p := range payloads[:len(payloads)-1] {
offset += len(p)
cuts = append(cuts, offset)
}
return payloads, cuts
}
type writeMode struct {
name string
splitPacket bool
splitRecord bool
recordFraming bool
segmentPerCall bool // one Write call per segment
budget int
}
var writeModes = []writeMode{
{name: "tls_fragment", splitPacket: true, segmentPerCall: true, budget: packetCutBudget},
{name: "tls_record_fragment", splitRecord: true, recordFraming: true, budget: recordCutBudget},
{name: "both", splitPacket: true, splitRecord: true, recordFraming: true, segmentPerCall: true, budget: packetCutBudget},
}
// TestWriteSeversTheBlockedLabelOnTheWire is the end-to-end control: not "the
// planner returned nice numbers" but "the bytes that left the socket have the
// blocked label straddling a segment boundary", for every mode the presets
// expose, over many real random draws.
func TestWriteSeversTheBlockedLabelOnTheWire(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, tc := range []struct{ name, blocked string }{
{"youtube.com", "youtube"}, // the ONE name the old code got right
{"www.youtube.com", "youtube"}, // the regression
{"m.youtube.com", "youtube"}, // one-byte label in front
{"music.youtube.com", "youtube"},
{"cdn-static-assets.youtube.com", "youtube"},
{"a.b.example.co.uk", "example"}, // two-label public suffix
} {
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, tc.name)
sniAt := IndexTLSServerName(hello).Index
start := sniAt + strings.Index(tc.name, tc.blocked)
end := start + len(tc.blocked)
for i := 0; i < 100; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n, "Write must report the length of the buffer it was given")
_, cuts := segments(t, hello, out.writes, mode.recordFraming)
require.NotEmpty(t, cuts, "the ClientHello went out in one piece")
require.LessOrEqual(t, len(cuts), mode.budget, "more cuts than this mode's budget")
severed := false
for _, c := range cuts {
if c > start && c < end {
severed = true
}
}
require.True(t, severed,
"run %d: %q left with cuts at %v, none inside %q [%d,%d)",
i, tc.name, cuts, tc.blocked, start, end)
}
})
}
}
}
// TestWriteReassemblesToTheOriginalClientHello: cutting may change how the
// bytes are packaged and nothing else. Byte-for-byte, plus the length Write
// reports, plus the segment count implied by the plan.
func TestWriteReassemblesToTheOriginalClientHello(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, serverName := range []string{
"www.youtube.com", "youtube.com", "a.b.example.co.uk", "localhost", "a",
"foo.bar.baz.youtube.com",
} {
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, serverName)
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p, "empty segment: a cut of zero length went out on the wire")
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
// The record header is re-emitted per fragment, so what
// must survive is the handshake body.
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "run %d: the reassembled ClientHello differs from the original", i)
if mode.segmentPerCall {
require.Len(t, out.writes, len(cuts)+1, "one Write call per segment")
} else {
require.Len(t, out.writes, 1, "record fragmentation without packet fragmentation is a single write")
}
}
})
}
}
}
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN is the regression for a
// PROCESS DEATH that shipped in v0.2.21.
//
// route/conn.go wraps the outbound connection with this Conn and fragments the
// ClientHello the LAN client sent, so the server name is chosen by the client,
// not by us. An empty label made cutOffsets call rand.Intn(0) — "panic: invalid
// argument to Intn" — and a Go panic in a connection goroutine takes the whole
// daemon with it. Two ordinary ways to produce one:
//
// "youtube.com." a fully qualified name with the root dot, which curl and
// every browser will happily send, and for which the public
// suffix list returns "" so the trailing empty label survived;
// ".youtube.com" a leading dot, which nothing legitimate sends but nothing
// stops a client from writing into its own ClientHello.
//
// With the kill switch armed the daemon's death is not a slow connection, it is
// a dark LAN until procd restarts it — into the same request.
func TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ from, to, why string }{
{from: "youtube.comx", to: "youtube.com.", why: "the FQDN root dot — a legitimate name"},
{from: "xyoutube.com", to: ".youtube.com", why: "leading dot"},
{from: "xyoutube.comx", to: ".youtube.com.", why: "both"},
{from: "ax.example.com", to: "a..example.com", why: "a doubled dot mid-name"},
{from: "xxxxxxxxxxxx", to: "............", why: "nothing but dots"},
{from: "1x2x3x4", to: "1.2.3.4", why: "an IP literal, which RFC 6066 forbids in SNI"},
{from: "xxx", to: "::1", why: "an IPv6 literal"},
{from: "a", to: "a", why: "one byte: no cut exists, and the empty plan must not be indexed"},
{from: "ab", to: "ab", why: "two bytes: exactly one interior offset"},
{from: "\xff\xfe.example.com", to: "\xff\xfe.example.com", why: "bytes that are not ASCII"},
} {
t.Run(strings.ToValidUTF8(tc.to, "?"), func(t *testing.T) {
t.Parallel()
hello := patchSNI(t, clientHelloFor(t, tc.from), tc.from, tc.to)
for _, mode := range writeModes {
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err, "%s: %s", mode.name, tc.why)
require.Equal(t, len(hello), n, "%s: %s", mode.name, tc.why)
payloads, _ := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p)
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "%s run %d: %s", mode.name, i, tc.why)
}
}
})
}
}
// TestWriteHandlesServerNameListsCryptoTLSWillNotEmit reaches the shapes that
// need a hand-built record: a zero-length name, a name at the 253-byte limit,
// and a list carrying a SECOND entry — which MyServerName.ServerName includes
// and MyServerName.Length does not, so cut planning must run on the first entry
// alone or it feeds the public suffix list bytes that belong to no name.
func TestWriteHandlesServerNameListsCryptoTLSWillNotEmit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
title string
entries []string
cut bool // must the ClientHello leave in more than one piece?
}{
{title: "empty name", entries: []string{""}, cut: false},
{title: "one byte", entries: []string{"a"}, cut: false},
{title: "253 bytes, 85 labels", entries: []string{strings.Repeat("ab.", 84) + "a"}, cut: true},
{title: "253 bytes, one label", entries: []string{strings.Repeat("a", 253)}, cut: true},
{title: "two entries", entries: []string{"www.youtube.com", "evil.example.com"}, cut: true},
{title: "two entries, first empty", entries: []string{"", "www.youtube.com"}, cut: false},
{title: "trailing dot", entries: []string{"youtube.com."}, cut: true},
} {
t.Run(tc.title, func(t *testing.T) {
t.Parallel()
hello := buildClientHello(t, tc.entries...)
for _, mode := range writeModes {
for i := 0; i < 20; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err, mode.name)
require.Equal(t, len(hello), n, mode.name)
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
require.Equal(t, tc.cut, len(cuts) > 0, "%s: expected cut=%v, got %d cut(s)", mode.name, tc.cut, len(cuts))
require.LessOrEqual(t, len(cuts), mode.budget, mode.name)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p)
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "%s run %d", mode.name, i)
}
}
})
}
}
// TestWriteCutsTheFirstEntryOfTheServerNameList: with two entries the cut must
// land inside "youtube" of the FIRST one. Planning over the whole remainder of
// the extension would hand the public suffix list a string that is not a name
// and put the cut somewhere else entirely.
func TestWriteCutsTheFirstEntryOfTheServerNameList(t *testing.T) {
t.Parallel()
hello := buildClientHello(t, "www.youtube.com", "cdn-static-assets.example.com")
sni := IndexTLSServerName(hello)
start := sni.Index + strings.Index("www.youtube.com", "youtube")
end := start + len("youtube")
for i := 0; i < 200; i++ {
out := &fakeConn{}
_, err := NewConn(out, t.Context(), true, false, time.Nanosecond).Write(hello)
require.NoError(t, err)
_, cuts := segments(t, hello, out.writes, false)
require.Len(t, cuts, 1)
require.Greater(t, cuts[0], start, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
require.Less(t, cuts[0], end, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
}
}
// TestWriteWithoutSNIIsUntouched: the fast path must stay a straight pass, and
// the second and later writes must never be re-planned.
func TestWriteWithoutSNIIsUntouched(t *testing.T) {
t.Parallel()
payload := []byte("not a tls record at all")
out := &fakeConn{}
conn := NewConn(out, t.Context(), true, true, time.Nanosecond)
n, err := conn.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.Len(t, out.writes, 1)
require.Equal(t, payload, out.writes[0])
hello := clientHelloFor(t, "www.youtube.com")
n, err = conn.Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
require.Len(t, out.writes, 2, "a ClientHello after the first write must not be fragmented")
require.Equal(t, hello, out.writes[1])
}
+18
View File
@@ -25,6 +25,21 @@ func requireRoot(t *testing.T) {
}
}
// requireTCPDump skips when tcpdump is not installed.
//
// The same honesty this package's callers demand of a health reading: a missing
// INSTRUMENT is "not checked", never "broken". Without it every test in this
// file fails on `cmd.Start()` — sixteen red results that say nothing about the
// code and hide any real failure among them — on a machine where the only thing
// wrong is that a capture tool is absent. requireRoot has always drawn that line
// for privileges; this draws it for the tool.
func requireTCPDump(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("tcpdump"); err != nil {
t.Skip("integration test requires tcpdump on PATH; install it to run this suite")
}
}
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
t.Helper()
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
@@ -36,6 +51,9 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
// the wire.
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
t.Helper()
// Every capture in this file funnels through here, so one guard covers the
// whole suite and no future test can forget it.
requireTCPDump(t)
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
+141
View File
@@ -0,0 +1,141 @@
// lx:begin health-board
package urltest
import (
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
)
// captureEvictions swaps the eviction notice sink for the duration of a test and
// returns a func that reads back everything reported.
func captureEvictions(t *testing.T) func() []string {
t.Helper()
var (
mu sync.Mutex
msgs []string
)
orig := boardEvictionLog
boardEvictionLog = func(m string) {
mu.Lock()
msgs = append(msgs, m)
mu.Unlock()
}
t.Cleanup(func() { boardEvictionLog = orig })
return func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), msgs...)
}
}
// TestBoardHoldsAGenerationWithoutEvicting is the "what it holds" half of the
// bound. A live generation on this box is ~1200 tags (≈380 nodes plus their
// per-group egress copies and chain hops); the board must carry that — and a
// second generation's worth of overlap during a subscription rename — with no
// eviction at all, or the ceiling would be silently degrading real health data.
func TestBoardHoldsAGenerationWithoutEvicting(t *testing.T) {
read := captureEvictions(t)
s := NewHistoryStorage()
const generation = 1200
for gen := 0; gen < 2; gen++ {
for i := 0; i < generation; i++ {
s.StoreURLTestHistory("gen"+strconv.Itoa(gen)+"-node-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 20})
}
}
if got := s.Evicted(); got != 0 {
t.Fatalf("two full generations (%d tags) evicted %d entries; the board must hold them",
2*generation, got)
}
if msgs := read(); len(msgs) != 0 {
t.Fatalf("unexpected eviction notices: %v", msgs)
}
// Everything is still readable.
if s.LoadURLTestHistory("gen0-node-0") == nil {
t.Fatalf("the first tag of the first generation was lost without an eviction")
}
}
// TestBoardEvictsOldestAndSaysSo is the "what happens when it overflows" half.
// Overflow must (a) actually bound the map, (b) drop the LEAST RECENTLY MEASURED
// tags — on this box, exactly the ones no config names any more — and (c) be
// audible: a silent eviction is a health board quietly forgetting nodes it is
// still being asked about.
func TestBoardEvictsOldestAndSaysSo(t *testing.T) {
read := captureEvictions(t)
s := NewHistoryStorage()
base := time.Now().Add(-24 * time.Hour)
// Stale generation first: measured a day ago, nothing since.
const stale = 1500
for i := 0; i < stale; i++ {
s.StoreURLTestHistory("stale-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: base.Add(time.Duration(i) * time.Millisecond), Delay: 30})
}
if s.Evicted() != 0 {
t.Fatalf("evicted before the ceiling was reached")
}
// Now push past the ceiling with fresh measurements.
for i := 0; i <= maxBoardEntries; i++ {
s.StoreURLTestHistory("fresh-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 15})
}
if got := s.Evicted(); got == 0 {
t.Fatalf("board grew past %d entries without evicting anything — it is still unbounded", maxBoardEntries)
}
s.access.RLock()
size := len(s.delayHistory)
s.access.RUnlock()
if size > maxBoardEntries {
t.Fatalf("board holds %d entries, above the %d ceiling", size, maxBoardEntries)
}
// The day-old generation is what went, not the fresh one.
if s.LoadURLTestHistory("stale-0") != nil {
t.Fatalf("the oldest observation survived while newer ones were dropped")
}
if s.LoadURLTestHistory("fresh-"+strconv.Itoa(maxBoardEntries)) == nil {
t.Fatalf("the newest measurement was evicted")
}
msgs := read()
if len(msgs) == 0 {
t.Fatalf("entries were evicted with no notice — eviction must never be silent")
}
m := msgs[0]
for _, want := range []string{"health board full", "evicted", "re-probed"} {
if !strings.Contains(m, want) {
t.Fatalf("eviction notice %q does not say %q", m, want)
}
}
}
// TestBoardEvictionThroughMarkFailed pins the OTHER write path. MarkFailed is how
// a dead node is recorded, and a flood of dead renamed nodes is exactly the shape
// of the leak — so it has to prune too, not just the success path.
func TestBoardEvictionThroughMarkFailed(t *testing.T) {
captureEvictions(t)
s := NewHistoryStorage()
for i := 0; i <= maxBoardEntries; i++ {
s.MarkFailed("dead-" + strconv.Itoa(i))
}
s.access.RLock()
size := len(s.delayHistory)
s.access.RUnlock()
if size > maxBoardEntries {
t.Fatalf("MarkFailed grew the board to %d, above the %d ceiling", size, maxBoardEntries)
}
if s.Evicted() == 0 {
t.Fatalf("MarkFailed never prunes — the failure path is still unbounded")
}
}
// lx:end health-board
+118
View File
@@ -10,11 +10,128 @@
package urltest
import (
"sort"
"strconv"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
)
// --- board capacity ---------------------------------------------------------
//
// The board is the one structure in the daemon whose key space is chosen by
// somebody else. Its keys are outbound TAGS, and on this box a tag is a node
// NAME straight out of the subscription — plus the derived per-group egress
// copies ("group-<g>-m<i>-<node>") and per-chain hop copies the probe planner
// creates for the same nodes. Providers rename their nodes freely, so a daily
// subscription refresh introduces a whole new generation of keys, while the
// store itself is pinned to the ENGINE's context (shater/engine.New) and so
// outlives every generation and every Apply — by design, so health survives a
// config change.
//
// Nothing ever removed a key. DeleteURLTestHistory exists but no shater path
// calls it (only daemon/ and clashapi/, which this fork does not run), so the
// map was strictly append-only for the life of the process — and the process is
// expected to live for months.
//
// The arithmetic: ~380 nodes, and a config with a couple of egress-bound groups
// plus a handful of chains puts a LIVE generation at roughly 380 base tags +
// 2x380 group copies + ~100 chain copies ≈ 1200 keys. One new generation per day
// is ~440k keys a year, at ~200 B per entry (map bucket + a tag string that is
// routinely 30-50 B with flag emoji, + a 56 B URLTestHistory) ≈ 88 MB of a
// 512 MB box — spent entirely on nodes that no longer exist.
const (
// maxBoardEntries is the hard ceiling. 4096 is ~3.4 live generations, so the
// board comfortably holds the current config plus the overlap while a
// subscription refresh swaps names, and still costs under a megabyte. A tighter
// bound would start evicting tags the running config actually uses; a looser one
// would stop being a bound in any useful sense.
maxBoardEntries = 4096
// keepBoardEntries is the prune target: drop a quarter at a time so the
// O(n log n) selection is amortised over ~1024 inserts instead of running on
// every probe once the board is full.
keepBoardEntries = 3072
)
// boardEvictionLog reports an eviction. A package var so tests can capture it;
// production leaves it writing to the process log, which under procd is the same
// syslog/logsink stream every other daemon line lands in.
//
// Eviction is NEVER silent. It is not free either: an evicted tag reverts to
// "untested" and its next probe re-measures it, so a board that evicts entries
// belonging to the LIVE config is a board whose ceiling is too low — and the only
// way anyone finds that out is this line.
var boardEvictionLog = func(msg string) { boardLogger().Warn(msg) }
// pruneLocked drops the least-recently-OBSERVED entries when the board exceeds
// maxBoardEntries. "Least recently observed" is max(LastOK, LastFail): the entry
// nothing has measured for the longest is, on this box, precisely a tag that no
// longer exists in any config — a renamed node, a removed group copy, a retired
// chain hop. Caller holds access.
func (s *HistoryStorage) pruneLocked() {
if len(s.delayHistory) <= maxBoardEntries {
return
}
type kv struct {
tag string
seen time.Time
}
all := make([]kv, 0, len(s.delayHistory))
for tag, h := range s.delayHistory {
seen := h.LastOK
if h.LastFail.After(seen) {
seen = h.LastFail
}
all = append(all, kv{tag, seen})
}
sort.Slice(all, func(i, j int) bool { return all[i].seen.Before(all[j].seen) })
drop := len(all) - keepBoardEntries
var oldest time.Time
for i := 0; i < drop; i++ {
if i == 0 {
oldest = all[i].seen
}
delete(s.delayHistory, all[i].tag)
}
s.evicted += uint64(drop)
msg := "urltest: health board full (" + strconv.Itoa(maxBoardEntries) + " tags) — evicted " +
strconv.Itoa(drop) + " least-recently-measured entries (" + strconv.FormatUint(s.evicted, 10) +
" total since start); they revert to untested and will be re-probed"
if !oldest.IsZero() {
msg += "; oldest observation was " + time.Since(oldest).Truncate(time.Second).String() + " ago"
}
boardEvictionLog(msg)
}
// Evicted reports how many entries the capacity bound has dropped since the store
// was created. Nonzero means the board reached maxBoardEntries at least once.
func (s *HistoryStorage) Evicted() uint64 {
if s == nil {
return 0
}
s.access.RLock()
defer s.access.RUnlock()
return s.evicted
}
// boardLogger is the process-wide fallback logger for eviction notices. The store
// is built from a plain constructor with no logger in sight (box.New, the daemon,
// shater/engine all call NewHistoryStorage()), so rather than change that
// signature everywhere the notice goes to the standard logger — which on the
// router is the daemon's own stderr, i.e. the same sink logsink owns.
var (
boardLogOnce sync.Once
boardLog log.ContextLogger
)
func boardLogger() log.ContextLogger {
boardLogOnce.Do(func() { boardLog = log.StdLogger() })
return boardLog
}
// HealthVerdict classifies a stored history entry at read time.
type HealthVerdict int
@@ -54,6 +171,7 @@ func (s *HistoryStorage) MarkFailed(tag string) {
updated.Delay = previous.Delay
}
s.delayHistory[tag] = updated
s.pruneLocked()
s.notifyUpdated()
s.access.Unlock()
}
+61
View File
@@ -0,0 +1,61 @@
package urltest
// lx: health board §5.C — the reachability half of "should this be probed".
//
// # Two different reasons not to probe, and why they cannot be one flag
//
// A group's OWN probing schedule is stood down for two unrelated reasons, and
// conflating them breaks one of the two:
//
// - NOT USED — no enabled routing rule reaches this group, so probing it
// measures a path nothing travels. That is a property of the CONFIG, it is
// decided once when the config is generated, and it travels in the config
// itself (option.URLTestOutboundOptions.SelfCheck). It cannot change while
// the box runs, because the rules cannot change while the box runs.
//
// - NOT REACHABLE RIGHT NOW — the group is a hop of a chain and a hop in
// FRONT of it is currently dead. Every member of this group dials through
// that hop, so every probe would fail inside it: the measurement would be
// about the broken hop, and would be recorded against this one. That is a
// property of the WORLD, it changes minute by minute, and it must be
// re-asked every time rather than baked into the config — a hop that comes
// back must resume probing on its own, with no reapply and nobody pressing
// anything.
//
// ProbeGate is the second one. It is deliberately a QUESTION asked at the
// moment of probing and never a stored answer: there is no flag to set, so
// there is no flag to forget to clear.
//
// The gate governs the group's own SCHEDULE only — the warm-up sweep and the
// ticker. An explicit check (a human, an API call) is a deliberate request and
// is never refused, exactly as with SelfCheck.
type ProbeGate interface {
// ProbeAllowed reports whether the outbound tagged tag may run its own
// scheduled probe right now.
//
// Implementations MUST answer true when they do not know: a gate that
// refuses on missing information would silence probing precisely when the
// system has the least idea what is going on, and nothing would ever
// measure its way out of that. A nil ProbeGate means "no gate" and every
// probe proceeds.
ProbeAllowed(tag string) bool
// ProbeWhenIdle reports whether the outbound tagged tag must keep measuring
// even when no traffic is passing through it.
//
// A urltest group normally probes only while it is in use: Touch arms the
// ticker on a dial, and the idle timeout stops it again. That is right for a
// group whose readings matter only while somebody is dialling it, and wrong
// for one the routing config REACHES: a rule that matches rarely — a narrow
// domain list, say — is in force the whole time, so the health of its target
// is a live question the whole time. Letting it go quiet means the panel
// reports "untested" about a rule that is armed, and the first real request
// pays a cold probe instead of picking an already-known-good member.
//
// Unlike ProbeAllowed, the safe answer here is FALSE when nothing is known.
// This one ADDS work, and a gate that claimed it on missing information would
// keep every group in the process probing forever — not a default anybody
// asked for. Absent gate, unknown tag, nothing configured yet: false, and the
// idle timeout behaves exactly as it always has.
ProbeWhenIdle(tag string) bool
}
+9
View File
@@ -21,6 +21,10 @@ type HistoryStorage struct {
access sync.RWMutex
delayHistory map[string]*adapter.URLTestHistory
updateHooks []*observable.Subscriber[struct{}]
// evicted counts entries dropped by the capacity bound (board_lx.go). The map
// is keyed by outbound tags chosen by a subscription provider, so it needs a
// ceiling; see the comment on maxBoardEntries.
evicted uint64
}
func NewHistoryStorage() *HistoryStorage {
@@ -71,6 +75,11 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes
}
// lx:end health-board
s.delayHistory[tag] = history
// lx:begin health-board — the map is keyed by provider-chosen tags and the
// store outlives every engine generation, so it must bound itself here: no
// shater path ever calls DeleteURLTestHistory. See maxBoardEntries.
s.pruneLocked()
// lx:end health-board
s.notifyUpdated()
s.access.Unlock()
}
+19
View File
@@ -2,6 +2,9 @@ package daemon
import (
"context"
// lx:begin sec-oomgate
"sync"
// lx:end sec-oomgate
"time"
"unsafe"
@@ -19,6 +22,10 @@ type ManagedService struct {
handler ManagedHandler
debug bool
oomReporter oomkiller.OOMReporter
// lx:begin sec-oomgate
oomReportMu sync.Mutex
oomReportLast time.Time
// lx:end sec-oomgate
}
type ManagedServiceOptions struct {
@@ -90,6 +97,18 @@ func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty)
if s.oomReporter == nil {
return nil, status.Error(codes.Unavailable, "OOM reporter not available")
}
// lx:begin sec-oomgate
// Rate-limit operator-triggered reports to at most one per minute: each write
// dumps process state + the config snapshot (secrets) to disk, so an
// authenticated client must not be able to spin it in a tight loop.
s.oomReportMu.Lock()
if !s.oomReportLast.IsZero() && time.Since(s.oomReportLast) < time.Minute {
s.oomReportMu.Unlock()
return nil, status.Error(codes.ResourceExhausted, "OOM report rate-limited (max 1/min)")
}
s.oomReportLast = time.Now()
s.oomReportMu.Unlock()
// lx:end sec-oomgate
return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total())
}
+7 -1
View File
@@ -2,6 +2,9 @@ package daemon
import (
"context"
// lx:begin sec-consttime
"crypto/subtle"
// lx:end sec-consttime
"strings"
"google.golang.org/grpc"
@@ -59,8 +62,11 @@ func authenticate(ctx context.Context, secret string) error {
return status.Error(codes.Unauthenticated, "missing authorization")
}
token, isBearer := strings.CutPrefix(values[0], "Bearer ")
if !isBearer || token != secret {
// lx:begin sec-consttime
// Constant-time compare: a plain != leaks the secret via response timing.
if !isBearer || subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 {
return status.Error(codes.Unauthenticated, "invalid authorization")
}
// lx:end sec-consttime
return nil
}
+23 -2
View File
@@ -131,7 +131,9 @@ func (s *StartedService) StartTailscaleSSHSession(
continue
}
go ssh.DiscardRequests(reqs)
go s.forwardSSHAgentChannel(channel)
// lx:begin sec-sshagent
go s.forwardSSHAgentChannel(sessionCtx, channel)
// lx:end sec-sshagent
}
}()
}
@@ -313,7 +315,8 @@ func (s *StartedService) StartTailscaleSSHSession(
return nil
}
func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
// lx:begin sec-sshagent
func (s *StartedService) forwardSSHAgentChannel(ctx context.Context, channel ssh.Channel) {
defer channel.Close()
fd, err := s.handler.ConnectSSHAgent()
if err != nil {
@@ -326,15 +329,33 @@ func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
return
}
defer conn.Close()
// The ssh-agent conn stays blocked in Read while idle, so io.Copy(channel,
// conn) never returns on its own — without this it leaks a goroutine + the
// agent fd for every closed session. Cancelling on either copy finishing (or
// on the session ctx) closes both ends, unblocking the peer copy. Both Close
// calls are idempotent with the deferred ones above.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
conn.Close()
channel.Close()
}()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(conn, channel)
cancel()
}()
go func() {
defer wg.Done()
io.Copy(channel, conn)
cancel()
}()
wg.Wait()
}
// lx:end sec-sshagent
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

-2
View File
@@ -1,2 +0,0 @@
untrusted comment: shater feed signing key
RWRaxLF3aJy44JbcxSFujtrFFEQ8lIsnTkd1K5TdjIhdlC2c0wa0fv4V
+90 -3
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/adapter"
@@ -171,6 +172,73 @@ func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
return response, nil
}
// requestBuffer owns the pooled buffer that backs one DoH query.
//
// Both transports behind HTTPSTransportWrapper write the request body on a
// goroutine of their own and return from RoundTrip as soon as the response
// HEADERS arrive: net/http's write loop is still copying out of the body a
// bufferful at a time (4 KiB of write buffer, or io.Copy's 32 KiB once it hands
// the body to the connection), and http2's writeRequestBody has read only the
// first max-frame-size bytes of it. Returning the buffer to the pool at that
// point handed live memory to the next caller while the query was still going
// out — everything past that first copy left the router as whatever that caller
// had written there. A data race, and a memory-disclosure primitive aimed at the
// resolver. Measured, not reasoned: with the write parked mid-query the bytes on
// the wire diverge from the bytes we packed at exactly one copy buffer in.
//
// Ownership is counted rather than handed over once, because a retry holds two
// bodies at a time and the two transports order that differently:
// http.Transport.rewindBody CLOSES the old body before asking GetBody for a
// new one, while http2's shouldRetryRequest asks GetBody first and closes the
// old body on a goroutine. exchange keeps a count of its own until RoundTrip
// returns — the only window in which either can call GetBody — so neither
// ordering can free the buffer under the other. If a transport ever fails to
// close a body, the count never reaches zero and the buffer is simply not
// reused: garbage, not corruption.
type requestBuffer struct {
buffer *buf.Buffer
raw []byte
refs atomic.Int32
}
func newRequestBuffer(buffer *buf.Buffer, raw []byte) *requestBuffer {
holder := &requestBuffer{buffer: buffer, raw: raw}
holder.refs.Store(1)
return holder
}
// body hands out a reader over the packed query as one more owner. It refuses
// once the buffer is back in the pool, so a late caller gets an error instead
// of a reader over memory that now belongs to somebody else.
func (b *requestBuffer) body() (*pooledRequestBody, bool) {
for {
refs := b.refs.Load()
if refs < 1 {
return nil, false
}
if b.refs.CompareAndSwap(refs, refs+1) {
return &pooledRequestBody{Reader: bytes.NewReader(b.raw), owner: b}, true
}
}
}
func (b *requestBuffer) release() {
if b.refs.Add(-1) == 0 {
b.buffer.Release()
}
}
type pooledRequestBody struct {
*bytes.Reader
owner *requestBuffer
closeOne sync.Once
}
func (b *pooledRequestBody) Close() error {
b.closeOne.Do(b.owner.release)
return nil
}
func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
exMessage := *message
exMessage.Id = 0
@@ -181,11 +249,31 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
requestBuffer.Release()
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
// Drops the count exchange holds once RoundTrip is done with the request;
// the bodies handed to the transport keep their own until it closes them.
defer queryBuffer.release()
requestBody, _ := queryBuffer.body() // cannot fail: the count above is ours
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), requestBody)
if err != nil {
requestBuffer.Release()
requestBody.Close()
return nil, err
}
// http.NewRequestWithContext infers both only for the body types it knows,
// and pooledRequestBody is not one of them. Upstream got them for free from
// *bytes.Reader; GetBody is what lets a POST be replayed when a pooled
// connection turns out to have been closed under us. Being unknown to
// net/http also costs one packet on the HTTP/1.1 leg: isKnownInMemoryReader
// no longer recognises the body, so the request headers are flushed before
// the query instead of travelling with it.
request.ContentLength = int64(len(rawMessage))
request.GetBody = func() (io.ReadCloser, error) {
retryBody, ok := queryBuffer.body()
if !ok {
return nil, E.New("DoH request buffer already released")
}
return retryBody, nil
}
request.Header = t.headers.Clone()
request.Header.Set("Content-Type", MimeType)
request.Header.Set("Accept", MimeType)
@@ -193,7 +281,6 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
currentTransport := t.transport
t.transportAccess.Unlock()
response, err := currentTransport.RoundTrip(request)
requestBuffer.Release()
if err != nil {
return nil, err
}
@@ -0,0 +1,541 @@
package transport
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
mDNS "github.com/miekg/dns"
"golang.org/x/net/http2"
)
// The request body of a DoH query is backed by a POOLED buffer. Neither
// transport behind HTTPSTransportWrapper is done with that body when RoundTrip
// returns: net/http hands the request to a write loop of its own and returns as
// soon as the response HEADERS have been read, and golang.org/x/net/http2 writes
// the body on the goroutine that runs writeRequest while roundTrip waits on
// respHeaderRecv. Returning the buffer to the pool at that point hands live
// memory to the next caller while the query is still being written to the wire,
// and what goes out is whatever that next caller put there.
//
// Both tests below force a window that is normally microseconds wide to stay
// open, and drain the pool while it is open:
//
// - HTTP/1.1: the client connection stops accepting writes past the request
// headers, so net/http's write loop is parked having copied only the first
// io.Copy buffer (32 KiB) of the query.
// - HTTP/2: the server pins a 1 KiB stream receive window and does not read
// the body, so writeRequestBody is parked in awaitFlowControl having copied
// only the first max-frame-size bytes of the query.
//
// In both, the server sends the response HEADERS first and withholds the
// response BODY until the pool has been drained, so Exchange has returned from
// RoundTrip — and released the buffer, on the broken build — while the query is
// still going out.
//
// Both queries are padded past the transport's copy buffer on purpose. Below it
// the transport lifts the whole query out of the pooled buffer in a single Read
// that RACES the release rather than provably following it, and a test built on
// that race would be a coin toss. The ownership defect is the same at every
// size; only its deterministic proof needs the padding.
const (
// Past io.Copy's 32 KiB buffer, which is the granularity net/http moves a
// request body at (persistConnWriter.ReadFrom -> io.Copy), and still inside
// buf.MaxPooledBufferSize so the buffer really comes from the pool.
httpsH1PaddedQuerySize = 40000
// Past http2's max frame size, which is how much of the body
// writeRequestBody lifts into its scratch buffer per round.
httpsH2PaddedQuerySize = 20000
// Pinned on the HTTP/2 server so the client cannot write the whole body
// before the response headers come back.
httpsPinnedStreamWindow = 1024
// Pinned too: Go's HTTP/2 server advertises a 1 MiB max frame size by
// default, and the client sizes its body-copy buffer from that — with the
// default it would slurp a 20 KB query in one Read and the divergence would
// be hidden by the copy size rather than absent. 16384 is the protocol
// minimum and what real resolvers advertise.
httpsPinnedMaxFrameSize = 16384
// How many times the HTTP/2 scenario is repeated; see the test.
httpsH2Rounds = 8
// How long to wait after the response headers before draining the pool, so
// that Exchange has certainly returned from RoundTrip.
httpsReleaseSettleDelay = 200 * time.Millisecond
)
// httpsPaddedQuery returns a query and the exact bytes HTTPSTransport.exchange
// packs for it.
func httpsPaddedQuery(t *testing.T, padding int) (*mDNS.Msg, []byte) {
t.Helper()
message := new(mDNS.Msg)
message.SetQuestion("example.com.", mDNS.TypeA)
opt := new(mDNS.OPT)
opt.Hdr.Name = "."
opt.Hdr.Rrtype = mDNS.TypeOPT
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: make([]byte, padding)})
message.Extra = append(message.Extra, opt)
onWire := *message
onWire.Id = 0
onWire.Compress = true
expected, err := onWire.Pack()
if err != nil {
t.Fatal(err)
}
return message, expected
}
func httpsTestReply(t *testing.T) []byte {
t.Helper()
query := new(mDNS.Msg)
query.SetQuestion("example.com.", mDNS.TypeA)
response := new(mDNS.Msg)
response.SetReply(query)
raw, err := response.Pack()
if err != nil {
t.Fatal(err)
}
return raw
}
// httpsPoisonPool takes buffers of one size class out of the pool and fills them
// with a pattern no DNS message contains. They are returned, not released: the
// caller holds them so nothing can hand them back while the check runs.
func httpsPoisonPool(size int, count int) []*buf.Buffer {
poison := make([]*buf.Buffer, 0, count)
for range count {
buffer := buf.NewSize(size)
poison = append(poison, buffer)
free := buffer.FreeBytes()
for i := range free {
free[i] = 0xEE
}
}
return poison
}
func httpsReleaseAll(buffers []*buf.Buffer) {
for _, buffer := range buffers {
buffer.Release()
}
}
// httpsRequirePoisonReachesReleasedBuffer is the CONTROL for the tests below. A
// clean result there means nothing unless this instrument is shown to be able to
// produce a dirty one: it must be true that a buffer released while its bytes
// are still referenced comes back out of the pool and gets overwritten. If that
// stops holding — a different allocator, a pool that zeroes, a size class that
// is not pooled at all — the tests below would go green on broken code.
//
// Retried, because under -race sync.Pool.Put drops one object in four on
// purpose. That same dice roll is why the checks below are 3-in-4 detectors
// under -race and certainties without it; it can only make a broken build look
// clean, never a clean build look broken.
func httpsRequirePoisonReachesReleasedBuffer(t *testing.T, size int, pattern []byte) {
t.Helper()
for range 32 {
control := buf.NewSize(size)
free := control.FreeBytes()
if len(free) < len(pattern) {
t.Fatalf("control failed: a %d-byte buffer came back %d bytes long", size, len(free))
}
copy(free, pattern)
alias := free[:len(pattern)]
control.Release()
held := httpsPoisonPool(size, 8)
poisoned := !bytes.Equal(alias, pattern)
httpsReleaseAll(held)
if poisoned {
return
}
}
t.Fatal("control failed: poisoning the pool never touched a released buffer, so a clean result below would prove nothing")
}
// httpsTestDialer hands HTTPSTransportWrapper a connection to a local test
// server, optionally wrapped.
type httpsTestDialer struct {
target string
wrap func(net.Conn) net.Conn
access sync.Mutex
conns []net.Conn
}
func (d *httpsTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", d.target)
if err != nil {
return nil, err
}
var wrapped net.Conn = conn
if d.wrap != nil {
wrapped = d.wrap(conn)
}
d.access.Lock()
d.conns = append(d.conns, conn)
d.access.Unlock()
return wrapped, nil
}
func (d *httpsTestDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return nil, os.ErrInvalid
}
func (d *httpsTestDialer) closeAll() {
d.access.Lock()
defer d.access.Unlock()
for _, conn := range d.conns {
conn.Close()
}
}
// httpsGatedConn stops accepting writes once limit bytes have gone out, until
// the gate is opened. HTTP/1.1 has no flow-control knob to park the writer with,
// so the connection provides one.
type httpsGatedConn struct {
net.Conn
limit int64
written atomic.Int64
gate chan struct{}
}
func (c *httpsGatedConn) Write(p []byte) (int, error) {
if c.written.Load()+int64(len(p)) > c.limit {
select {
case <-c.gate:
case <-time.After(30 * time.Second):
return 0, errors.New("gated conn: nobody opened the gate")
}
}
n, err := c.Conn.Write(p)
c.written.Add(int64(n))
return n, err
}
// httpsSlowServer is the handler both tests share: response HEADERS first, then
// nothing until the pool has been drained, then the request body, then the
// response body.
type httpsSlowServer struct {
reply []byte
served atomic.Int32
warmups int32
headersSent chan struct{}
bodyGate chan struct{}
received chan []byte
readErr chan error
}
func newHTTPSSlowServer(reply []byte) *httpsSlowServer {
return &httpsSlowServer{
reply: reply,
headersSent: make(chan struct{}, 1),
bodyGate: make(chan struct{}),
received: make(chan []byte, 1),
readErr: make(chan error, 1),
}
}
func (s *httpsSlowServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if s.served.Add(1) <= s.warmups {
// Warm-up: answer normally, so the connection is established and the
// client has applied the server's SETTINGS before the query that
// matters goes out.
io.Copy(io.Discard, request.Body)
writer.Header().Set("Content-Type", MimeType)
writer.Header().Set("Content-Length", strconv.Itoa(len(s.reply)))
writer.Write(s.reply)
return
}
// Without this, net/http's HTTP/1.1 server drains up to 256 KB of the
// request body before it will write response headers, precisely so that a
// half-duplex client cannot deadlock. That would consume the query before
// the client is anywhere near done sending it, and there would be nothing
// left in flight to catch. Full duplex is how a resolver that answers from
// cache before reading the whole query behaves; HTTP/2 is full duplex
// already and returns an error here, which is fine.
http.NewResponseController(writer).EnableFullDuplex()
writer.Header().Set("Content-Type", MimeType)
// Content-Length matters: without it Exchange falls into io.ReadAll and
// waits for the end of the response, which this handler is about to
// withhold on purpose.
writer.Header().Set("Content-Length", strconv.Itoa(len(s.reply)))
writer.WriteHeader(http.StatusOK)
writer.(http.Flusher).Flush()
s.headersSent <- struct{}{}
// A real resolver would be reading the query by now. Withholding it is what
// keeps the client parked mid-body while the pool is drained.
<-s.bodyGate
body, err := io.ReadAll(request.Body)
s.readErr <- err
s.received <- body
writer.Write(s.reply)
}
// drainPoolOnceHeadersAreOut waits for the response headers, gives Exchange time
// to return from RoundTrip, drains the size class the query buffer came from —
// on this goroutine, so a buffer released on the way out lands in our hands and
// not somewhere harmless — and only then lets the server read the query.
func (s *httpsSlowServer) drainPoolOnceHeadersAreOut(bufferSize int) <-chan []*buf.Buffer {
poisoned := make(chan []*buf.Buffer, 1)
go func() {
<-s.headersSent
time.Sleep(httpsReleaseSettleDelay)
poisoned <- httpsPoisonPool(bufferSize, 32)
close(s.bodyGate)
}()
return poisoned
}
func (s *httpsSlowServer) requireQueryOnWire(t *testing.T, expected []byte) {
t.Helper()
var sent []byte
select {
case sent = <-s.received:
case <-time.After(30 * time.Second):
t.Fatal("the server never received the request body")
}
if err := <-s.readErr; err != nil {
t.Fatal("reading the request body: ", err)
}
if bytes.Equal(sent, expected) {
return
}
firstDiff := -1
for i := 0; i < len(sent) && i < len(expected); i++ {
if sent[i] != expected[i] {
firstDiff = i
break
}
}
t.Fatalf("the query on the wire is not the query we packed: %d of %d bytes received, first difference at offset %d — "+
"the pooled request buffer was reused while the transport was still reading it", len(sent), len(expected), firstDiff)
}
// TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP1 proves that the query an
// HTTP/1.1 resolver receives is the query we asked to send, even when the pool
// is drained the instant the response headers arrive.
func TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP1(t *testing.T) {
message, expected := httpsPaddedQuery(t, httpsH1PaddedQuerySize)
bufferSize := 1 + message.Len()
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
handler := newHTTPSSlowServer(httpsTestReply(t))
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
dialer := &httpsTestDialer{
target: server.Listener.Addr().String(),
wrap: func(conn net.Conn) net.Conn {
// One 4 KiB flush of net/http's write buffer gets through, which is
// what carries the request headers to the server, and the write loop
// parks on the next one — still holding the query.
return &httpsGatedConn{Conn: conn, limit: 4096, gate: handler.bodyGate}
},
}
t.Cleanup(dialer.closeAll)
// Scheme http puts HTTPSTransportWrapper on its HTTP/1.1 leg, the one it
// also falls back to whenever a resolver does not negotiate h2.
destination := &url.URL{Scheme: "http", Host: "doh.invalid", Path: "/dns-query"}
dnsTransport := &HTTPSTransport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTPS, "test-doh-h1", nil),
logger: logger.NOP(),
dialer: dialer,
destination: destination,
headers: http.Header{},
transport: NewHTTPSTransportWrapper(dialer, M.ParseSocksaddr(server.Listener.Addr().String()), destination),
}
t.Cleanup(func() { dnsTransport.Close() })
poisoned := handler.drainPoolOnceHeadersAreOut(bufferSize)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := dnsTransport.Exchange(ctx, message); err != nil {
t.Fatal("exchange: ", err)
}
defer httpsReleaseAll(<-poisoned)
handler.requireQueryOnWire(t, expected)
}
// TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP2 does the same over h2,
// the leg every resolver that speaks HTTP/2 lands on.
func TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP2(t *testing.T) {
message, expected := httpsPaddedQuery(t, httpsH2PaddedQuerySize)
bufferSize := 1 + message.Len()
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
// Repeated because a buffer released on the goroutine running Exchange
// usually lands in that P's private sync.Pool slot, which the goroutine
// draining the pool cannot steal: one round catches a broken build about
// half the time, eight catch it better than 99 times in 100. Every round
// must come back clean.
for round := range httpsH2Rounds {
if !t.Run(strconv.Itoa(round), func(t *testing.T) {
httpsH2Round(t, message, expected, bufferSize)
}) {
return
}
}
}
func httpsH2Round(t *testing.T, message *mDNS.Msg, expected []byte, bufferSize int) {
handler := newHTTPSSlowServer(httpsTestReply(t))
// x/net/http2 may put the first request on the wire before it has applied
// the server's SETTINGS, and would then overrun the 1 KiB window this test
// pins and be reset with FLOW_CONTROL_ERROR. One small query first settles
// that: reading its response proves the SETTINGS frame ahead of it was
// processed.
handler.warmups = 1
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
h2server := &http2.Server{
MaxUploadBufferPerStream: httpsPinnedStreamWindow,
MaxReadFrameSize: httpsPinnedMaxFrameSize,
}
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
go h2server.ServeConn(conn, &http2.ServeConnOpts{Handler: handler})
}
}()
dialer := &httpsTestDialer{target: listener.Addr().String()}
t.Cleanup(dialer.closeAll)
// Scheme https keeps HTTPSTransportWrapper on its h2 leg. The dialer hands
// back a plain connection, which x/net/http2 speaks prior-knowledge h2 over;
// TLS adds nothing this test is about.
destination := &url.URL{Scheme: "https", Host: "doh.invalid", Path: "/dns-query"}
dnsTransport := &HTTPSTransport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTPS, "test-doh-h2", nil),
logger: logger.NOP(),
dialer: dialer,
destination: destination,
headers: http.Header{},
transport: NewHTTPSTransportWrapper(dialer, M.ParseSocksaddr(listener.Addr().String()), destination),
}
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
warmup := new(mDNS.Msg)
warmup.SetQuestion("warmup.invalid.", mDNS.TypeA)
if _, err = dnsTransport.Exchange(ctx, warmup); err != nil {
t.Fatal("warm-up exchange: ", err)
}
poisoned := handler.drainPoolOnceHeadersAreOut(bufferSize)
if _, err = dnsTransport.Exchange(ctx, message); err != nil {
t.Fatal("exchange: ", err)
}
defer httpsReleaseAll(<-poisoned)
handler.requireQueryOnWire(t, expected)
}
// TestHTTPSRequestBufferSurvivesRewind covers the second owner a retry creates.
// net/http rewinds a dead connection's request by CLOSING the body it has and
// then asking GetBody for another one (rewindBody), while x/net/http2 asks
// GetBody first and closes the old body on a goroutine (shouldRetryRequest,
// closeReqBodyLocked). Either ordering frees the buffer under the retry if the
// first Close is what returns it to the pool, and the retry then sends whatever
// the next pool user wrote — the same disclosure, one attempt later.
func TestHTTPSRequestBufferSurvivesRewind(t *testing.T) {
message, expected := httpsPaddedQuery(t, httpsH2PaddedQuerySize)
bufferSize := 1 + message.Len()
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
exMessage := *message
exMessage.Id = 0
exMessage.Compress = true
requestBuffer := buf.NewSize(bufferSize)
rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes())
if err != nil {
t.Fatal(err)
}
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
defer queryBuffer.release()
first, ok := queryBuffer.body()
if !ok {
t.Fatal("the first body was refused while exchange still holds the buffer")
}
// The transport got some of the query out before the connection turned out
// to be dead, then closed the body.
if _, err = io.CopyN(io.Discard, first, 128); err != nil {
t.Fatal(err)
}
first.Close()
// GetBody, as the retry would call it.
second, ok := queryBuffer.body()
if !ok {
t.Fatal("GetBody was refused after the first body was closed: the retry has no query left to send")
}
poison := httpsPoisonPool(bufferSize, 32)
defer httpsReleaseAll(poison)
retried, err := io.ReadAll(second)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(retried, expected) {
firstDiff := -1
for i := 0; i < len(retried) && i < len(expected); i++ {
if retried[i] != expected[i] {
firstDiff = i
break
}
}
t.Fatalf("the retried query is not the query we packed: %d of %d bytes, first difference at offset %d — "+
"closing the first body returned the buffer to the pool while the retry still needed it", len(retried), len(expected), firstDiff)
}
second.Close()
}
// TestHTTPSRequestBufferRefusesBodyAfterRelease pins the recoverable end of the
// contract: once the buffer really is back in the pool, GetBody must hand out an
// error rather than a reader over memory that now belongs to somebody else.
func TestHTTPSRequestBufferRefusesBodyAfterRelease(t *testing.T) {
requestBuffer := buf.NewSize(64)
rawMessage := requestBuffer.FreeBytes()[:8]
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
body, ok := queryBuffer.body()
if !ok {
t.Fatal("the first body was refused while the caller still holds the buffer")
}
body.Close()
body.Close() // http3 and net/http both manage to close a body twice
queryBuffer.release()
if _, ok = queryBuffer.body(); ok {
t.Fatal("a body was handed out over a buffer that is already back in the pool")
}
}
+29 -5
View File
@@ -126,6 +126,12 @@ func (t *HTTP3Transport) newTransport() *http3.Transport {
conn.Close()
return nil, dialErr
}
// quic-go does not take ownership of the packet conn passed to
// DialEarly: when the connection ends it only stops reading.
go func() {
<-quicConn.Context().Done()
conn.Close()
}()
return quicConn, nil
},
TLSClientConfig: t.tlsConfig,
@@ -156,15 +162,34 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
exMessage := *message
exMessage.Id = 0
exMessage.Compress = true
requestBuffer := buf.NewSize(1 + message.Len())
rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes())
// NOT a pooled buffer, deliberately — the request body must own memory this
// transport can never hand back.
//
// quic-go writes the request body on a goroutine of its own (http3's
// doRequest spawns it and goes on to block in ReadResponse), and NOTHING ever
// joins that goroutine. On the success path sendRequestBody closes the body
// when it is finished, but on every error path RoundTripOpt closes it as soon
// as doRequest returns — and doRequest waits only on the request-cancellation
// watchdog, not on the writer. So there is no moment at which this code can
// know the body is no longer being read, and therefore no moment at which it
// may return a pooled buffer. Releasing on Close looks like an ownership
// handoff and is not one.
//
// Owning it costs nothing here, measured rather than assumed: for a typical
// query (a 36-byte name, A record) Pack is 87 ns/op at 64 B and 1 alloc,
// against 108 ns/op at 64 B and 1 alloc for packing into a pooled buffer. The
// pool never avoided an allocation on this path — buf.NewSize allocates the
// Buffer struct itself, the same 64 bytes the message needs — it only added
// Get/Put on top. This path is hot in queries, not in bytes.
//
// The response buffer below stays pooled: it is read to completion and
// unpacked before Exchange returns, and nothing outlives it.
rawMessage, err := exMessage.Pack()
if err != nil {
requestBuffer.Release()
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
if err != nil {
requestBuffer.Release()
return nil, err
}
request.Header = t.headers.Clone()
@@ -174,7 +199,6 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
currentTransport := t.transport
t.transportAccess.Unlock()
response, err := currentTransport.RoundTrip(request)
requestBuffer.Release()
if err != nil {
return nil, err
}
@@ -0,0 +1,426 @@
package quic
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strconv"
"testing"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/dns/transport"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
mDNS "github.com/miekg/dns"
)
// The request body of a DoH3 query used to be backed by a POOLED buffer. quic-go
// sends that body on a goroutine of its own which outlives RoundTrip (http3's
// doRequest spawns it and returns as soon as the response HEADERS arrive), and
// NOTHING joins that goroutine, so there is no moment at which the transport may
// hand the buffer back.
//
// Two tests, because the two paths are observable in different ways.
//
// - On the SUCCESS path the body keeps flowing, so the damage is visible on the
// wire: TestHTTP3ExchangeRequestBufferOutlivesRoundTrip pins a 2 KB server
// stream window and answers before reading the body, so the client is still
// writing when Exchange returns, and compares what the server received.
//
// - On the FAILURE and CANCELLATION paths the damage is not visible on the wire
// at all: every ReadResponse error in quic-go calls str.CancelWrite BEFORE
// RoundTripOpt closes the body, so whatever the writer reads afterwards is
// thrown at a dead stream. What is left is a read of memory that belongs to
// somebody else. TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory therefore
// pins the CAUSE instead of the symptom: the bytes of a query must never end
// up in a buffer this transport can return to the pool.
const (
// Big enough to need more than one 8 KiB read out of the request body
// (http3's bodyCopyBufferSize), small enough to still come from the pool
// (buf.MaxPooledBufferSize).
paddedQuerySize = 20000
// Pinned on the server so the client cannot write the whole body before the
// response comes back.
pinnedStreamWindow = 2048
// Padding for the marked query of the ownership test. Only has to be
// distinctive and pooled, not large.
markedQueryPadding = 4096
markedQueryNeedle = 64
// How deep to drain a size class when looking for the needle.
poolScanDepth = 64
// How many times a CONTROL may repeat before it gives up.
//
// Both controls in this file assert the same thing — a buffer released while
// its bytes are still referenced comes back out of the pool — and under
// `-race` that is a DICE ROLL, not a certainty: sync.Pool.Put drops one
// object in four on purpose (runtime_randn(4) == 0, sync/pool.go). Measured
// in golang:1.26 with `go test -race -count=60`: the single-attempt control
// failed 18 times out of 60, i.e. the gate's -race pass had a ~30% chance of
// going red on a tree with nothing wrong with it.
//
// A retry is the honest repair rather than a papering-over, because the
// control's claim is EXISTENTIAL — "this instrument is able to find a
// released, still-referenced buffer" — and one success proves it. It is not
// an average over attempts, so nothing is diluted by taking more than one.
// 32 attempts leave a (1/4)^32 chance of a false alarm.
//
// What this does NOT do, said plainly: it does not make the VERDICT below
// certain under -race. The same 1-in-4 drop means a scan that comes back
// clean has a 1-in-4 chance of being clean because the pool threw the
// evidence away. That direction is the safe one — it can only let a broken
// build look clean, never make a clean build look broken — and the -race
// pass is not the only one that runs this test: [2/7] of scripts/run-tests.sh
// runs the same file WITHOUT -race, where both the control and the verdict
// are certainties.
controlAttempts = 32
)
func paddedQuery(t *testing.T) (*mDNS.Msg, []byte) {
t.Helper()
message := new(mDNS.Msg)
message.SetQuestion("example.com.", mDNS.TypeA)
opt := new(mDNS.OPT)
opt.Hdr.Name = "."
opt.Hdr.Rrtype = mDNS.TypeOPT
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: make([]byte, paddedQuerySize)})
message.Extra = append(message.Extra, opt)
// Exactly what HTTP3Transport.Exchange puts on the wire.
onWire := *message
onWire.Id = 0
onWire.Compress = true
expected, err := onWire.Pack()
if err != nil {
t.Fatal(err)
}
return message, expected
}
// poisonPool takes buffers of one size class out of the pool and fills them with
// a pattern no DNS message contains. The buffers are returned, not released: the
// caller holds them so nothing can hand them back while the check runs.
func poisonPool(size int, count int) []*buf.Buffer {
poison := make([]*buf.Buffer, 0, count)
for range count {
buffer := buf.NewSize(size)
poison = append(poison, buffer)
free := buffer.FreeBytes()
for i := range free {
free[i] = 0xEE
}
}
return poison
}
func releaseAll(buffers []*buf.Buffer) {
for _, buffer := range buffers {
buffer.Release()
}
}
// requirePoisonReachesReleasedBuffer is the CONTROL for the test below. A clean
// result there means nothing unless this instrument is shown to be able to
// produce a dirty one: it must be true that a buffer released while its bytes
// are still referenced comes back out of the pool and gets overwritten. If this
// stops holding — a different allocator, a pool that zeroes, a size class that
// is not pooled at all — the test below would go green on broken code.
//
// Retried, because under -race sync.Pool.Put drops one object in four on
// purpose. That same dice roll is why the check below is a 3-in-4 detector under
// -race and a certainty without it; it can only make a broken build look clean,
// never a clean build look broken. See controlAttempts.
func requirePoisonReachesReleasedBuffer(t *testing.T, size int, pattern []byte) {
t.Helper()
for range controlAttempts {
control := buf.NewSize(size)
free := control.FreeBytes()
if len(free) < len(pattern) {
t.Fatalf("control failed: a %d-byte buffer came back %d bytes long", size, len(free))
}
copy(free, pattern)
alias := free[:len(pattern)]
control.Release()
held := poisonPool(size, 8)
poisoned := !bytes.Equal(alias, pattern)
releaseAll(held)
if poisoned {
return
}
}
t.Fatal("control failed: poisoning the pool never touched a released buffer, so a clean result below would prove nothing")
}
// TestHTTP3ExchangeRequestBufferOutlivesRoundTrip proves that the query the
// server receives is the query we asked to send, even when the pool is drained
// the instant Exchange returns.
func TestHTTP3ExchangeRequestBufferOutlivesRoundTrip(t *testing.T) {
message, expected := paddedQuery(t)
bufferSize := 1 + message.Len()
requirePoisonReachesReleasedBuffer(t, bufferSize, expected)
drainGate := make(chan struct{})
received := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
// Answer BEFORE reading the request body. A real resolver would not, but
// any peer, middlebox or loss pattern that delays the body has the same
// effect, and this makes the window deterministic.
response := new(mDNS.Msg)
response.SetReply(testQuery())
rawResponse, err := response.Pack()
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", transport.MimeType)
// Content-Length matters here: without it Exchange falls into io.ReadAll
// and waits for the stream FIN, which this handler is about to withhold.
writer.Header().Set("Content-Length", strconv.Itoa(len(rawResponse)))
writer.Write(rawResponse)
writer.(http.Flusher).Flush()
<-drainGate
body, _ := io.ReadAll(request.Body)
received <- body
})
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), &quic.Config{
InitialStreamReceiveWindow: pinnedStreamWindow,
MaxStreamReceiveWindow: pinnedStreamWindow,
InitialConnectionReceiveWindow: 1 << 16,
MaxConnectionReceiveWindow: 1 << 16,
})
if err != nil {
t.Fatal(err)
}
server := &http3.Server{Handler: mux}
go server.ServeListener(listener)
t.Cleanup(func() {
server.Close()
listener.Close()
})
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
dnsTransport := &HTTP3Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3-buffer", nil),
logger: logger.NOP(),
dialer: dialer,
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
headers: http.Header{},
serverAddr: M.ParseSocksaddr(listener.Addr().String()),
tlsConfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS13,
},
}
dnsTransport.transport = dnsTransport.newTransport()
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err = dnsTransport.Exchange(ctx, message); err != nil {
t.Fatal("exchange: ", err)
}
// Exchange has returned, the body is still in flight. Drain the size class it
// came from, on this very goroutine, so a buffer released on the way out lands
// in our hands and not somewhere harmless. The buffers are held until after
// the comparison below.
poison := poisonPool(bufferSize, 32)
defer releaseAll(poison)
close(drainGate)
var sent []byte
select {
case sent = <-received:
case <-time.After(20 * time.Second):
t.Fatal("the server never received the request body")
}
if !bytes.Equal(sent, expected) {
firstDiff := -1
for i := 0; i < len(sent) && i < len(expected); i++ {
if sent[i] != expected[i] {
firstDiff = i
break
}
}
t.Fatalf("the query on the wire is not the query we packed: %d of %d bytes received, first difference at offset %d — "+
"the pooled request buffer was reused while quic-go was still reading it", len(sent), len(expected), firstDiff)
}
}
// markedQuery builds a query whose EDNS0 padding carries a random tag, so the
// packed bytes contain a needle that can be searched for in pool memory and
// cannot collide with anything else.
func markedQuery(t *testing.T) (*mDNS.Msg, []byte) {
t.Helper()
padding := make([]byte, markedQueryPadding)
if _, err := rand.Read(padding); err != nil {
t.Fatal(err)
}
message := new(mDNS.Msg)
message.SetQuestion("example.com.", mDNS.TypeA)
opt := new(mDNS.OPT)
opt.Hdr.Name = "."
opt.Hdr.Rrtype = mDNS.TypeOPT
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: padding})
message.Extra = append(message.Extra, opt)
return message, padding[:markedQueryNeedle]
}
// poolHoldsNeedle drains one size class of the buffer pool and reports whether
// any buffer in it still carries the needle. It must run on the goroutine that
// released the buffer: sync.Pool keeps a per-P private slot that no other P can
// steal from, and on the paths this test covers the release happens inline in
// RoundTripOpt, on the caller's own goroutine.
func poolHoldsNeedle(size int, needle []byte, count int) bool {
held := make([]*buf.Buffer, 0, count)
defer func() { releaseAll(held) }()
var found bool
for range count {
buffer := buf.NewSize(size)
held = append(held, buffer)
if bytes.Contains(buffer.FreeBytes(), needle) {
found = true
}
}
return found
}
// requireInstrumentFindsPackedQuery is the CONTROL. It does exactly what the old
// Exchange did — pack a query into a pooled buffer and release it — and demands
// that the scan below FINDS the needle. Without it, "the pool does not hold the
// query" would also be the verdict for a scan that can never find anything.
//
// Retried for the same reason its sibling control above is, and it was NOT
// before: under -race sync.Pool.Put drops one object in four, so a single
// attempt made this control — and with it the whole -race pass of the gate —
// fail on 18 of 60 measured runs with nothing wrong in the tree. A fresh
// needle is packed on each attempt, so a later one cannot be answered by an
// earlier one's bytes. See controlAttempts for what the retry does and does not
// buy.
func requireInstrumentFindsPackedQuery(t *testing.T) {
t.Helper()
for range controlAttempts {
message, needle := markedQuery(t)
size := 1 + message.Len()
exMessage := *message
exMessage.Id = 0
exMessage.Compress = true
buffer := buf.NewSize(size)
if _, err := exMessage.PackBuffer(buffer.FreeBytes()); err != nil {
t.Fatal(err)
}
buffer.Release()
if poolHoldsNeedle(size, needle, poolScanDepth) {
return
}
}
t.Fatalf("control failed: %d times in a row, a query packed into a pooled buffer and released was NOT "+
"found by the scan, so a clean verdict below would prove nothing. Under -race sync.Pool.Put drops "+
"one object in four, which is what the retries absorb; this many consecutive misses is something "+
"else — a pool that zeroes on Put, a size class that stopped being pooled, or buf.Buffer no longer "+
"handing its array back at all", controlAttempts)
}
// TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory pins the ownership rule the
// failure paths depend on.
//
// quic-go's http3.Transport closes the request body on every error path
// (transport.go RoundTripOpt) the moment doRequest returns, and doRequest waits
// only on the request-cancellation watchdog — never on the goroutine writing the
// body. So releasing the buffer when the body is closed is not an ownership
// handoff, and the only safe arrangement is for the query never to live in pool
// memory at all.
//
// This test encodes THAT design. A future guarded-pool design (a lock around
// Read and Close, refusing reads after release) would also be correct and would
// fail this test on purpose — it would have to replace it, and say so.
func TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory(t *testing.T) {
requireInstrumentFindsPackedQuery(t)
// A UDP socket nobody answers on: the handshake runs to the context deadline
// instead of being refused, which is the shape a router sees when the tunnel
// carrying its resolver drops.
blackhole, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { blackhole.Close() })
for _, testCase := range []struct {
name string
ctx func(t *testing.T) (context.Context, context.CancelFunc)
}{
{
// RoundTripOpt closes the body after the handshake gives up.
name: "server never answers",
ctx: func(t *testing.T) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 500*time.Millisecond)
},
},
{
// The cancellation watchdog fires, then RoundTripOpt closes the body.
name: "context already cancelled",
ctx: func(t *testing.T) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, func() {}
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
message, needle := markedQuery(t)
size := 1 + message.Len()
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
dnsTransport := &HTTP3Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3-ownership", nil),
logger: logger.NOP(),
dialer: dialer,
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
headers: http.Header{},
serverAddr: M.ParseSocksaddr(blackhole.LocalAddr().String()),
tlsConfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS13,
},
}
dnsTransport.transport = dnsTransport.newTransport()
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := testCase.ctx(t)
defer cancel()
if _, err := dnsTransport.Exchange(ctx, message); err == nil {
t.Fatal("expected the exchange to fail; this test is about the failure path")
}
// Same goroutine that ran RoundTripOpt, so the per-P private slot a
// release would have landed in is the one being drained.
if poolHoldsNeedle(size, needle, poolScanDepth) {
t.Fatal("the bytes of the query came back out of the buffer pool: the request body was packed into pooled " +
"memory and released while quic-go's body writer could still be reading it")
}
})
}
}
+351
View File
@@ -0,0 +1,351 @@
package quic
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
sbTLS "github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/dns/transport"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
mDNS "github.com/miekg/dns"
)
var _ N.Dialer = (*trackingDialer)(nil)
// These tests pin down who owns the UDP socket handed to quic-go.
//
// quic-go's Dial/DialEarly take a net.PacketConn but do NOT take ownership of
// it: quic.setupTransport() builds a Transport with createdConn=false, and
// Transport.Close() then only calls conn.SetReadDeadline(time.Now()) instead of
// conn.Close(). So every QUIC connection torn down here — idle timeout, a
// retryable error, an engine reload calling Reset() — used to strand the UDP
// socket that carried it for the rest of the process's life. On a router that
// resolves through DoQ/DoH3 for months that is an unbounded fd leak.
//
// Both tests reconnect once and assert the socket from the FIRST connection is
// actually closed. Without the `<-conn.Context().Done() -> rawConn.Close()`
// watchdogs in quic.go / http3.go they fail on that assertion.
type trackedConn struct {
net.Conn
closeOnce sync.Once
closed chan struct{}
}
func (c *trackedConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return c.Conn.Close()
}
// trackingDialer hands out real UDP sockets and remembers every one of them.
type trackingDialer struct {
access sync.Mutex
conns []*trackedConn
}
func (d *trackingDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, network, destination.String())
if err != nil {
return nil, err
}
tracked := &trackedConn{Conn: conn, closed: make(chan struct{})}
d.access.Lock()
d.conns = append(d.conns, tracked)
d.access.Unlock()
return tracked, nil
}
func (d *trackingDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return net.ListenUDP("udp", nil)
}
func (d *trackingDialer) count() int {
d.access.Lock()
defer d.access.Unlock()
return len(d.conns)
}
func (d *trackingDialer) at(index int) *trackedConn {
d.access.Lock()
defer d.access.Unlock()
return d.conns[index]
}
func (d *trackingDialer) closeAll() {
d.access.Lock()
defer d.access.Unlock()
for _, conn := range d.conns {
conn.Close()
}
}
func requireClosed(t *testing.T, conn *trackedConn, what string) {
t.Helper()
select {
case <-conn.closed:
case <-time.After(5 * time.Second):
t.Fatalf("%s: the UDP socket of the retired QUIC connection was never closed — quic-go does not own it, we must", what)
}
}
func requireDialed(t *testing.T, dialer *trackingDialer, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if dialer.count() >= want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("expected at least %d dial(s), got %d", want, dialer.count())
}
func testServerTLSConfig(t *testing.T, nextProtos []string) *tls.Config {
t.Helper()
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
if err != nil {
t.Fatal(err)
}
return &tls.Config{
Certificates: []tls.Certificate{*certificate},
NextProtos: nextProtos,
MinVersion: tls.VersionTLS13,
}
}
func testClientTLSConfig(t *testing.T, nextProtos []string) sbTLS.Config {
t.Helper()
config, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
Enabled: true,
Insecure: true,
ServerName: "localhost",
})
if err != nil {
t.Fatal(err)
}
config.SetNextProtos(nextProtos)
return config
}
// startDoQServer serves a minimal DoQ responder and returns its address.
func startDoQServer(t *testing.T) M.Socksaddr {
t.Helper()
listener, err := quic.ListenAddr("127.0.0.1:0", testServerTLSConfig(t, []string{"doq"}), nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
listener.Close()
})
go func() {
for {
conn, acceptErr := listener.Accept(ctx)
if acceptErr != nil {
return
}
go func(conn *quic.Conn) {
for {
stream, streamErr := conn.AcceptStream(ctx)
if streamErr != nil {
return
}
go func(stream *quic.Stream) {
defer stream.Close()
request, readErr := transport.ReadMessage(stream)
if readErr != nil {
return
}
response := new(mDNS.Msg)
response.SetReply(request)
transport.WriteMessage(stream, 0, response)
}(stream)
}
}(conn)
}
}()
return M.ParseSocksaddr(listener.Addr().String())
}
func testQuery() *mDNS.Msg {
message := new(mDNS.Msg)
message.SetQuestion("example.com.", mDNS.TypeA)
return message
}
func TestQUICTransportClosesPacketConnOnReconnect(t *testing.T) {
t.Parallel()
serverAddr := startDoQServer(t)
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
dnsTransport := &Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeQUIC, "test-doq", nil),
dialer: dialer,
serverAddr: serverAddr,
tlsConfig: testClientTLSConfig(t, []string{"doq"}),
connection: transport.NewConnPool(transport.ConnPoolOptions[*quic.Conn]{
Mode: transport.ConnPoolSingle,
IsAlive: func(conn *quic.Conn) bool {
return conn != nil && !common.Done(conn.Context())
},
Close: func(conn *quic.Conn, _ error) {
conn.CloseWithError(0, "")
},
}),
}
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("first exchange: ", err)
}
requireDialed(t, dialer, 1)
first := dialer.at(0)
// Retire the connection the way a retryable error or an engine reload does.
dnsTransport.Reset()
requireClosed(t, first, "Reset()")
// The reconnect must still work, on a fresh socket.
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("second exchange: ", err)
}
requireDialed(t, dialer, 2)
second := dialer.at(1)
if second == first {
t.Fatal("expected a new UDP socket for the reconnect")
}
if err := dnsTransport.Close(); err != nil {
t.Fatal(err)
}
requireClosed(t, second, "Close()")
}
func TestHTTP3TransportClosesPacketConnOnReconnect(t *testing.T) {
t.Parallel()
mux := http.NewServeMux()
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
message, err := readRequestMessage(request)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
response := new(mDNS.Msg)
response.SetReply(message)
rawResponse, err := response.Pack()
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", transport.MimeType)
writer.Write(rawResponse)
})
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), nil)
if err != nil {
t.Fatal(err)
}
server := &http3.Server{Handler: mux}
go server.ServeListener(listener)
t.Cleanup(func() {
server.Close()
listener.Close()
})
serverAddr := M.ParseSocksaddr(listener.Addr().String())
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
stdConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS13,
}
dnsTransport := &HTTP3Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3", nil),
logger: logger.NOP(),
dialer: dialer,
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
headers: http.Header{},
serverAddr: serverAddr,
tlsConfig: stdConfig,
}
dnsTransport.transport = dnsTransport.newTransport()
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("first exchange: ", err)
}
requireDialed(t, dialer, 1)
first := dialer.at(0)
dnsTransport.Reset()
requireClosed(t, first, "Reset()")
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("second exchange: ", err)
}
requireDialed(t, dialer, 2)
second := dialer.at(1)
if second == first {
t.Fatal("expected a new UDP socket for the reconnect")
}
if err = dnsTransport.Close(); err != nil {
t.Fatal(err)
}
requireClosed(t, second, "Close()")
}
func readRequestMessage(request *http.Request) (*mDNS.Msg, error) {
defer request.Body.Close()
rawMessage := make([]byte, 4096)
n, err := readFull(request.Body, rawMessage)
if err != nil {
return nil, err
}
var message mDNS.Msg
err = message.Unpack(rawMessage[:n])
if err != nil {
return nil, err
}
return &message, nil
}
func readFull(reader interface{ Read([]byte) (int, error) }, buffer []byte) (int, error) {
var total int
for total < len(buffer) {
n, err := reader.Read(buffer[total:])
total += n
if err != nil {
if total > 0 {
return total, nil
}
return total, err
}
}
return total, nil
}
+12
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/sing-box/adapter"
@@ -117,6 +118,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
rawConn.Close()
return nil, E.Cause(err, "establish QUIC connection")
}
// quic-go does not take ownership of the packet conn passed to
// DialEarly: when the connection ends it only stops reading.
go func() {
<-earlyConnection.Context().Done()
rawConn.Close()
}()
return earlyConnection, nil
})
if err != nil {
@@ -144,6 +151,11 @@ func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.
return nil, E.Cause(err, "open stream")
}
defer stream.CancelRead(0)
stopWatch := context.AfterFunc(ctx, func() {
stream.CancelRead(0)
_ = stream.SetWriteDeadline(time.Now())
})
defer stopWatch()
err = transport.WriteMessage(stream, 0, message)
if err != nil {
stream.Close()
+44
View File
@@ -12,6 +12,50 @@ as GitHub **pre-releases** and never become "Latest".
#### Unreleased (shater)
**`l3-honest-drop` — ICMP routed to an L4-only outbound is dropped, not
forged** — ships with `shaterd` (part of the shater L3 ingress,
`docs-shater/DECISIONS.md` D25), not as an lx release tag; recorded here because
it edits two upstream files. Without it the TUN stack answers an unroutable echo
ITSELF — sing-tun's `ICMPForwarder.HandlePacket` rewrites Echo→EchoReply
whenever the flow judgment comes back Accept (`stack_gvisor_icmp.go`) — so a
ping routed to vless/vmess/… would read as a working tunnel while the packet
never left the router.
* **`route/route.go` (`PreMatch`)** — the pre-match walk was renamed to
`preMatch` and the exported `PreMatch` became a thin FUNNEL that rewrites
`PreMatchContinue` and `PreMatchBypass` to `PreMatchDrop` for
`N.NetworkICMP`. An earlier version overrode `continueResult` inside
`preMatchFlow` instead; that covered only the exits reaching that function and
left three of the walk's own exits forging — the `prepareMatchMetadata` error
return, the sniff bail-outs, and the `default:` arm of the rule-action switch
(every action pre-match has no arm for: `hijack-dns`, `direct`, …). A guard on
the single return value cannot be outgrown by a new exit. `PreMatchBypass` is
folded in because sing-tun implements `ActionBypass` on the nfqueue plane only
— on the TUN path it lands in the same `default:` arm as Accept, i.e. forges.
* **`adapter/router.go` (`JudgeFlow`, the `!isPort` branch)** — ICMP returns
`ActionDrop` where it fell through to `ActionAccept`. Second line of defense:
`adapter.FlowOutbound` and `tun.Port` are distinct interfaces, and a drift
between them must not quietly re-enable the forged reply.
* **TCP/UDP behaviour is unchanged** — `PreMatchContinue` still means "take the
ordinary connection route" for both, `PreMatchBypass` still means bypass, and
the `!isPort` fallthrough still returns `ActionAccept` for them; pinned by
`route/prematch_icmp_lx_test.go` and `adapter/judgeflow_icmp_lx_test.go`
(both inside the marker), each ICMP case having an explicit TCP/UDP twin.
* **NOT covered: a FRAGMENTED echo to a WireGuard/AWG outbound is still
forged** — sing-tun's `ForwardDispatcher.Dispatch` returns before asking for a
verdict at all when `parsed.fragment`, and the reassembled packet reaches
`ICMPForwarder.HandlePacket`, whose `installFlow` demands an UNSPECIFIED port
address that a WireGuard endpoint never has. Fixing it inside `JudgeFlow`
is NOT possible — both consumers call it with identical arguments and the
working path needs the concrete address. Full chain, the two viable fixes and
the trap are in `docs-shater/DECISIONS.md` D25, under "What is still NOT
covered, said plainly", item 2.
* **Rebase cost: two small marked blocks** (`lx:begin/end l3-honest-drop`, a
wrapper function in `route/route.go` and one branch body in
`adapter/router.go`) plus the two self-contained test files — carried across
an upstream rebase by eye. Note that `PreMatch`'s own body now lives in
`preMatch`, so an upstream change to the walk applies to that function.
**Fork-layer + control-plane rework of proxy health** — ships with `shaterd`
(the shater router daemon), not as an lx release tag; recorded here because the
load-bearing half lives in fork zones (`common/urltest`, `protocol/group`).
+63 -8
View File
@@ -62,16 +62,61 @@ flowchart LR
C["LAN client"] -->|"nft tproxy, mark → tproxy port"| IN["sing-box tproxy inbound (sniff SNI/Host/QUIC)"]
IN --> R{"route: rule match — src / dst / list / geo / client"}
R -->|"proxied"| OUT["outbound / selector (balancer, chain)"]
R -->|"direct"| DIR["direct (flow-offload on)"]
R -->|"direct"| DIR["direct (out the normal route, untunnelled)"]
R -->|"blocked"| BLK["block"]
OUT --> NET["exit — VLESS/Reality/AmneziaWG2/Hysteria2/…"]
```
Reliability (ported from v0.1): own nft table `inet shater` + own marks/tables
(never touch fw4); atomic validate→stage→swap; commit-confirm rollback;
idempotent reconcile under flock; management-bypass always; fail-closed
(never touch fw4); atomic validate→stage→swap; commit-confirm rollback (opt-in —
see §5); idempotent reconcile under flock; management-bypass always; fail-closed
kill-switch (dead group → block, not a silent direct leak).
Only TCP and UDP reach that path — TPROXY carries nothing else. What happens to
the rest is §3a.
### 3a. L3 ingress and kernel egress — what TPROXY cannot carry
Two opt-in globals cover the protocols the tproxy plane leaves on the floor.
Both are off in a stock config, and both are configured through UCI only (the
panel does not expose them).
**`globals.l3_tunnel` — LAN ICMP through the tunnel.** The generator adds a
synthetic `tun` inbound tagged `l3-in` (gVisor stack, `auto_route` **off**, MTU
65535, `shater/generate/inbound.go`), so ICMP is routed by the engine's own rules
instead of being dropped or answered by a forged local reply. The device is not
one fixed name: the generator emits a stable placeholder (so a no-op reconcile
still hashes identical and does not rebuild the engine once a minute), and
`shater/engine/l3slot.go` substitutes one of the two slots `shater-l3a` /
`shater-l3b` (`netplane/l3.go`) just before `box.New` — a new generation must
never reopen the name the outgoing one still holds
(`TUNSETIFF: device or resource busy` took the whole LAN down once). The routing half is scoped and lives entirely outside
the main table: our nft prerouting chain stamps LAN `icmp`/`ipv6-icmp` with
`L3Mark` (`fwmark_base + 0x80`), and `netplane.addL3Routing` binds that mark to
`L3Table` (`table_base + 8`), whose only content is a default route out the live
slot. Because the daemon creates the device at runtime, netifd never learns about
it and fw4 would reject the forward on its own account — so `30_shater-core`
seeds a **`shater_l3` zone in the user's `/etc/config/firewall`**, matching
`list device 'shater-l3*'` (a string match that is valid before the TUN exists
and covers both slots). Ceiling: ICMP echo only, and only for L3-capable
egresses; see `DECISIONS.md` D25 for what is still not covered.
**`globals.untunnelable_egress` — everything else, carried by the kernel.** It
names an existing interface/tunnel egress. Whatever the L3 block above did not
claim — ESP/AH, GRE, IGMP, SCTP, and ICMP too when `l3_tunnel` is off — is
stamped in prerouting with **that egress's own mark** (`netplane/nft.go`,
`UntunnelableEgressBinding`) and accepted; the `fwmark → table` pair
`addEgressRouting` already installed for the egress then routes it out the
egress's device. No new mark, no new table, and the engine never sees a byte —
which is why any IP protocol works here while the L3 TUN is narrow. Order is
load-bearing: this sweep runs **after** the L3 marking (first match wins) and
**after** the local-plane accepts, so LAN-to-LAN, router-addressed traffic and
IPv6 neighbour discovery never leave through an uplink. With `ipv6=0` the mark
is scoped to `nfproto ipv4`, because `addEgressRouting` installs the `-6`
rule/table pair only when IPv6 is on and marked v6 without it would fall through
to the main table past the kill-switch. `globals.untunnelable` (block | icmp |
direct) stays in charge of whatever neither mechanism carries.
## 4. DNS + filtering + stats
```mermaid
@@ -79,7 +124,7 @@ flowchart LR
C["client :53"] -->|"hijack"| DNS["sing-box DNS (in-process)"]
DNS --> FILT{"shater filter: blocklists + allowlist + per-device policy"}
FILT -->|"blocked"| NX["NXDOMAIN / 0.0.0.0"]
FILT -->|"allowed"| RES["resolvers (DoH/DoT/plain/FakeIP) + nftset for routing"]
FILT -->|"allowed"| RES["resolvers (DoH/DoT/plain/local/FakeIP), per-rule detour"]
DNS -->|"query events (engine observability)"| AGG["shater stats aggregator"]
AGG --> PANEL["panel: top domains · per-device · allowed/blocked · timeline"]
```
@@ -87,9 +132,10 @@ flowchart LR
Because the engine's DNS runs **in our process**, every query (domain, client,
verdict, latency) is available to the stats aggregator without log-scraping —
this is the payoff of embedding. Blocklist matching uses an efficient compiled
matcher, not dnsmasq megalists (see `DECISIONS.md` D5). Per-device blocking =
engine route/DNS rule keyed by client, or nftset(device) × nftset(blocked-domain)
→ drop.
matcher, not dnsmasq megalists (see `DECISIONS.md` D5). Per-device blocking is an
engine route/DNS rule keyed by client. Routing decisions come from in-engine
rule-sets: the v0.1 mechanism where dnsmasq populated nft sets does not exist in
v0.2 (`generate/dns.go`).
## 5. Config & apply flow
@@ -100,11 +146,20 @@ stateDiagram-v2
Render --> Validate: engine config check + nft -c
Validate --> KeepOld: fail
Validate --> Apply: ok (atomic swap: engine reload + nft/route reconcile)
Apply --> ConfirmWindow
Apply --> Committed: confirm_timeout = 0 (SHIPPED DEFAULT — nothing armed)
Apply --> ConfirmWindow: confirm_timeout > 0
ConfirmWindow --> Committed: confirmed
ConfirmWindow --> Rollback: timeout
Rollback --> LastGood
```
**The confirm window is opt-in and ships closed.** `model.DefaultGlobals()` leaves
`ConfirmTimeout` at zero, the shipped `/etc/config/shater` says
`option confirm_timeout '0'`, and `apply.ArmRollback` returns immediately on a
non-positive timeout — so on a stock install every apply takes the left edge above
and there is no net under it. `shaterd apply` reports which edge it took
(`reason: commit-confirm-off` vs an armed window). Set
`globals.confirm_timeout` to arm it.
## 6. Roadmap tiers
See `ROADMAP.md` for the phased plan and `FEATURES.md` for the full feature list.
+38 -26
View File
@@ -31,12 +31,11 @@ Do not delete it — we port proven pieces from it. What v0.1 has:
- **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS +
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
- **CI + signed opkg feed** on Gitea: builds per-arch, signs the feed index with
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`,
secret in the Gitea repo secret `KEY_BUILD`.
- **CI + a signed package feed** on Gitea: builds per-arch, signs the feed index,
publishes a rolling `latest` Gitea release the router consumes as a feed.
(v0.1 shipped `.ipk` signed with a usign key — that lane is retired, D22.)
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
fail-closed, opkg install/upgrade from the signed feed.
fail-closed, install/upgrade from the signed feed.
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
`run.json` are xray-shaped.
@@ -52,6 +51,9 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
MASQUE/WARP, and gRPC observability (DNS queries / rules / outbounds). Upstream
sing-box brings VLESS/VMess/Trojan/Shadowsocks/WireGuard/Reality + Hysteria2/
TUIC. It is library-first (`libbox`) and **GPL-3.0** (compatible with us).
That list is what the FORK can build, not what shater ships: `shater/registry`
registers only what `shater/generate` can emit, and MASQUE is one of the types
deliberately left out (~6 MB of binary and resident RAM). See `FEATURES.md`.
- We **fork it** (not just depend on it) so we can embed literally everything —
control-plane, admin panel, DNS filter — and integrate tightly with the
engine internals (DNS, routing, stats). This is a deliberate, decided
@@ -85,13 +87,13 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
## Repository model
- **`shater` `main` = our fork of sing-box-lx.** After Phase 1 it contains the
full sing-box-lx tree PLUS our additive overlay (`shater/`, `panel/`,
`openwrt/`, `docs-shater/`). Upstream is tracked via a git remote and merged by tag.
- **`shater` `main` = our fork of sing-box-lx.** It contains the full sing-box-lx
tree PLUS our additive overlay (`shater/`, `panel/`, `openwrt/`, `docs-shater/`,
`scripts/`, `ci/`). Upstream is tracked via a git remote and merged by tag.
Phase 1 merged the engine in on 2026-07-14 (`v1.14.0-lx.3`); `main` has not been
a docs-only seed since.
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
from).
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
are reading now (LICENSE, README, `docs-shater/`, `dist/shater-feed.pub`).
## What to port from v0.1 (don't rewrite these ideas)
@@ -105,8 +107,8 @@ overlay, don't redo:
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
and the flexible **ruleset/list** model — though sing-box has its own share-link
parser and config schema we now target.
- **CI feed build + usign signing + Gitea release** (adapt to the single forked
binary; keep key `5ac4b177689cb8e0`).
- **CI feed build + index signing + Gitea release** (adapted to the single forked
binary; the format is apk, signed with the EC key — D22).
- The LuCI **design system** (the "instrument panel" identity) — reused for the
mini-dashboard and as the panel's visual language.
@@ -120,20 +122,30 @@ filter/stats engine wired into sing-box's DNS.
v0.2 fork; branch `v0.1` = the working xray-based version.
- **Upstream to track:** `https://github.com/Leadaxe/sing-box-lx` (which tracks
`https://github.com/SagerNet/sing-box`).
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
`main`; new CI is added when the v0.2 build exists.
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep
verifying).
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt`
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
drivable with the Playwright MCP.
- **CI:** Gitea Actions (act_runner + Docker), `.gitea/workflows/release.yml` —
builds the four packages through the ImmortalWrt 25.12.1 SDK and publishes the
signed per-arch apk repo. The opkg/`.ipk` lane was deleted, not disabled (D22).
- **Test gate:** `bash scripts/run-tests.sh` — the whole suite under the SHIPPED
build tags, on linux (in Docker from a non-linux host), with `-race`, and with
three anti-silent-skip checks. Not optional reading before touching `shater/`.
- **Feed signing:** EC (prime256v1) key for the apk index; secret in the repo
secret `KEY_APK`; public key `dist/shater-apk.pem`, installed on routers as
`/etc/apk/keys/shater-apk.pem`. Never regenerate it (D22).
- **Test VM:** **ImmortalWrt 25.12.1** (`r37978-cd0a06bfd3fd`) x86_64 in Docker
(`docker ps --filter name=openwrt-vm`), apk-tools 3.0.5 — deliberately the same
revision as `mini_router`, and required: the only package format we publish is
`.apk`, which does not install on 24.10 at all. SSH via the ssh-manager MCP
server `local_openwrt` (localhost:2222, root/openwrt). LuCI at
`http://127.0.0.1:8080` (root/openwrt), drivable with the Playwright MCP.
- **Routers:** `mini_router` (BPi-R3 Mini, ImmortalWrt 25.12.1) carries the real
home traffic; `main_router` (BPi-R4, OpenWrt 25.12.0). Both `aarch64_cortex-a53`,
both apk-tools 3.0.5 — see the table in D22.
## Current status
Repo reset done: v0.1 preserved on its branch; `main` cleaned to this docs-first
scaffold. Next is Phase 1 in `ROADMAP.md` — fork sing-box-lx into `main`
(add upstream remote, merge a pinned tag), stand up the embedding prototype
(prove AmneziaWG 2.0, measure binary size with feature-trim + `-s -w` + UPX)
before building the control plane and panel.
**v0.2 is feature-complete and running on real hardware.** ROADMAP Phases 0–8 are
done and VM-verified; the product ships as a signed apk feed and is installed on
`mini_router`. Read `ROADMAP.md` for what each phase delivered, `FEATURES.md` for
the honest MVP/T1/T2 state of each feature (including what is declared but not
shipped), and `DECISIONS.md` for why. Work since Phase 8 has been correctness and
honesty passes rather than new phases.
+1261 -1
View File
File diff suppressed because it is too large Load Diff
+59 -9
View File
@@ -6,15 +6,51 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
## Proxy engine & protocols (from the sing-box fork)
- **[MVP]** VLESS, VMess, Trojan, Shadowsocks, WireGuard, Reality/XTLS.
- **[MVP]** **AmneziaWG 2.0** (I1–I5 CPS decoy packets) — a driving requirement.
- **[T1]** Hysteria2, TUIC, ShadowTLS, XHTTP, MASQUE/CONNECT-IP (Cloudflare WARP).
- **[MVP]** Hysteria2, TUIC (`hysteria2://`/`hy2://`/`tuic://`, `shater/parse`),
XHTTP transport — all shipped: the router tag set carries `with_quic` and
`with_xhttp` and `shater/registry` registers them (`scripts/router-tags.sh`,
`buildtags.Features`).
- **[T1]** ShadowTLS — half-built: `shater/generate` emits it and `shater/registry`
registers it, but no parser produces one (there is no `shadowtls://` share link
and no subscription path), so a config cannot reach it today.
- **NOT SHIPPED** MASQUE/CONNECT-IP (Cloudflare WARP). `masque` appears nowhere in
`shater/parse`, `shater/generate` or `shater/model`, and `shater/registry` names
it among the upstream types it deliberately does not register (~6 MB of binary
and resident RAM). The engine fork can build it; this product does not.
- **[MVP]** Transports: TCP/WS/gRPC/HTTPUpgrade/H2/QUIC as upstream provides.
## Transparent proxying & routing
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
Host/QUIC sniffing.
- **[MVP]** **L3 ingress for ICMP** (`globals.l3_tunnel`, opt-in, default off):
LAN ping travels THROUGH the tunnel instead of being dropped or answered by a
forged local reply. The engine opens a dedicated TUN (`shater-l3`, gVisor
stack, `auto_route` off); nft marks LAN icmp/icmpv6 only and a scoped
`ip rule` routes it in — the TPROXY plane and the main routing table stay
untouched (D25). Carried only by L3-capable egresses (WireGuard/AmneziaWG,
direct); ICMP routed to vless/vmess/… is honestly dropped, never faked.
Ceiling is upstream sing-tun's: ICMP echo only — Windows tracert works, IPv6
traceroute shows just the destination; ESP/AH/GRE/IGMP stay with the
`untunnelable` policy (D17) unless `untunnelable_egress` carries them (D26).
- **[MVP]** **Kernel egress for untunnelable protocols**
(`globals.untunnelable_egress`, opt-in, default empty): names an existing
interface/tunnel egress, and IPsec (ESP/AH), PPTP/GRE, SCTP — everything that
is neither TCP nor UDP, plus ICMP when the L3 ingress is off — is routed out
that egress's device by the KERNEL with kernel NAT, reusing the egress's own
fwmark/table from `addEgressRouting`; the proxy never sees a byte, which is
why every protocol works (D26). What that buys depends on the device: a
WireGuard interface really is a tunnel, a second WAN is just another uplink
whose real address the destination sees. It does not revive multicast IPTV,
and UDP-based VPNs (WireGuard, OpenVPN-UDP, IPsec NAT-T) never needed it —
they follow the routing rules as before. The `untunnelable` policy (D17)
keeps only the failure case: a route that did not come up.
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
proto → target (outbound/selector/chain/direct/block) + egress.
destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
A rule names its **destination through a rule-set only** — a reusable named list
(inline domains/CIDRs, a local or remote file, or a geosite/geoip category) that is
compiled once into a `.srs` and shared by every rule that references it. Domain
entries take `full:` (exact), `suffix:` / a leading dot (host + subdomains),
`keyword:` (substring) and `regexp:`; a bare entry means host + subdomains.
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
interface/tunnel (e.g. an AmneziaWG tunnel).
@@ -36,6 +72,16 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
type=fakeip + pool — there is no global "FakeIP mode"); no DNS leaks. Routing
is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population
mechanism does not exist in v0.2 (see generate/dns.go).
The hijack covers the queries a client sends **to the router itself** — the
address DHCP hands out — because `globals.dns_intercept` is **ON by default**
(D24). With it off, those queries go to dnsmasq and out to the ISP in the clear,
so the well-behaved client leaks while the one that hard-codes 8.8.8.8 does not.
`.lan` and the private PTR zones are preserved through dnsmasq either way. Two
things the promise does NOT cover, both by design: while the engine is DOWN the
holding plane hooks `forward` only, so dnsmasq still answers router-addressed
:53 unfiltered (client traffic and DNS to external resolvers stay blocked); and
with no `config resolver` at all there is no DNS plane to filter with — queries
fall through to the system resolver and generate says so.
- **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter).
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
@@ -73,8 +119,12 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
## Reliability ("железно")
- **[MVP]** Fail-closed kill-switch (dead group → block, never silent direct leak);
IPv6 dropped when disabled.
- **[MVP]** Atomic apply with engine + `nft -c` validation; commit-confirm
auto-rollback to last-good.
- **[MVP]** Atomic apply with engine + `nft -c` validation. Commit-confirm
auto-rollback to last-good is built and works, but it is **opt-in and ships
OFF**: `DefaultGlobals()` leaves `ConfirmTimeout` at 0, the shipped
`/etc/config/shater` says `confirm_timeout '0'`, and `apply.ArmRollback` returns
at once on a non-positive timeout. Until an operator sets a window, an apply on
a stock box has no net under it — and `shaterd apply` says so.
- **[MVP]** Idempotent reconcile from hotplug/boot under flock; restart engine only
on real config change; management-bypass (SSH/LuCI/LAN) always exempt.
- **[MVP]** Own nft table `inet shater` + own marks/tables; never touch fw4.
@@ -89,8 +139,8 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
SIM uplink → different egress); backup/restore; i18n (EN + RU).
## Ops & distribution
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`.
- **[MVP]** Single signed binary; signed apk feed on Gitea (EC key
`dist/shater-apk.pem`); one-line install; named-package `apk upgrade`.
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
- **[T2]** apk (OpenWrt 25.x) packaging; multi-router fleet management; REST/gRPC
external API; Telegram bot.
- **[T2]** Multi-router fleet management; REST/gRPC external API; Telegram bot.
(apk packaging landed and is now the only lane — D22.)
+349 -92
View File
@@ -1,7 +1,7 @@
# Shater v0.2 — Build & Install
How to build the ship artifact (the SPA-embedded `shaterd` binary) and install
the OpenWrt feed onto a router.
the signed apk repo onto a router.
## 1. Build the `shaterd` binary
@@ -26,7 +26,9 @@ What it does:
Arg / env:
- `VERSION` — stamped into `constant.Version`. Resolution: positional arg →
`$SHATER_VERSION` → `git describe --tags` → `v0.2.0-dev`.
`$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
@@ -41,32 +43,53 @@ UPX="…/scratchpad/upx-4.2.4-win64/upx.exe" scripts/build-shaterd.sh v0.2.0 --f
The `dist/*` and `openwrt/shaterd/files/shaterd-*.upx` outputs are gitignored —
they are release artifacts, not source.
Tag set (D9 — keep in sync with `docs-shater/DECISIONS.md`):
Tag set (D9/D23) — defined in **one** place, `scripts/router-tags.sh`, which
documents every tag and is sourced by the build:
```
with_quic,with_wireguard,with_utls,
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_gvisor`: the shater data plane is tproxy/redirect and generate
never emits a tun inbound, so the userspace gvisor netstack is unreachable code.
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:
```sh
scripts/check-router-tags.sh # from Windows/macOS it re-execs itself in golang:1.26
```
It (1) fails if a feature declared in `FEATURES.md` lost a build tag it needs to
run (`shater/buildtags`, no tags/OS/network required) and (2) constructs one node
of every declared protocol through `box.New` **compiled with the shipped tag
set** — nothing may be skipped in that run. Adding a protocol to
`shater/parse`+`shater/generate` means adding a row to `buildtags.Features` and a
probe case in `shater/generate/shipped_tags_linux_test.go`.
## 2. Packages
Four OpenWrt packages live under `openwrt/`:
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`), cron, hotplug, sysctl, inert default UCI. `DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full`. |
| `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`. |
| `byedpi` | per-arch | *Optional* ByeDPI (`ciadpi`) local desync SOCKS proxy for a `type='byedpi'` egress. |
### Why `shaterd` is a prebuilt-binary package
@@ -84,22 +107,62 @@ it. Because the binary is UPX-packed, the package disables the SDK's default str
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
Install order follows the deps (`shaterd` → `shater-core` → `luci-app-shater`):
**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`):
```sh
opkg install shaterd_0.2.0-1_<arch>.ipk # or: apk add shaterd (25.12+)
opkg install shater-core_0.2.0-1_all.ipk
opkg install luci-app-shater_0.2.0-1_all.ipk
opkg install byedpi_0.17.3-1_<arch>.ipk # optional: ByeDPI egress
# <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
```
Installing from a signed feed instead:
From the repo instead (§5 sets it up once), deps pull the rest in:
```sh
# add the feed (customfeeds.conf / apk repositories), then:
opkg update && opkg install shater-core luci-app-shater # shaterd pulled in as a dep
apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
## 4. Enable
@@ -109,93 +172,162 @@ install. Configure nodes/rules (via the LuCI panel or `uci`), then enable and ap
```sh
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 commit-confirm on the running daemon
shaterd confirm # confirm (cancels the auto-rollback)
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.
## 5. Add the signed feed (recommended — then `opkg upgrade` just works)
### What enabling does to DNS
CI (`.gitea/workflows/release.yml`) publishes every build as a **rolling `latest`
Gitea release** that is itself a signed opkg `src/gz` feed: the release holds the
`.ipk` for all arches, a `Packages`/`Packages.gz` index, a usign `Packages.sig`,
and the public key `shater-feed.pub`. opkg filters by `Architecture`, so the **same
two lines work on every device** (x86 testbed picks `x86_64 + all`; the BPI routers
pick `aarch64_cortex-a53 + all`).
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:
> **Format:** OpenWrt 24.10 (our SDK) uses **opkg** (`.ipk`, `Packages.gz`, usign),
> so the feed is `src/gz` and the trust anchor is the usign key
> `dist/shater-feed.pub` (fingerprint **`5ac4b177689cb8e0`**). apk only replaces
> opkg at OpenWrt **25.12** — see §6.
- `.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".
One-time setup on the router:
To opt out, on the router:
```sh
# 1) trust the feed key — the FILENAME must equal the usign key fingerprint.
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
# 2) add the feed (one URL serves every arch).
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
>> /etc/opkg/customfeeds.conf
# 3) refresh + install (shaterd is pulled in as a dependency).
opkg update
opkg install luci-app-shater # -> shater-core -> shaterd
opkg install byedpi # optional: ByeDPI desync egress
uci set shater.globals.dns_intercept=0
uci commit shater
shaterd apply
```
With the key installed, opkg's default `check_signature 1` verifies the feed on
every `opkg update`; no `--nocheck-signature` needed. A **tagged** release
(`vX.Y.Z`) publishes the identical layout at
`.../releases/download/vX.Y.Z` if you prefer to pin a version instead of tracking
`latest`.
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.
### Updating
### The boot-time fail-closed armor
```sh
opkg update
opkg upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
`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.
```
Updates are only offered when the feed's `Version` differs from the installed one,
so **bump `PKG_RELEASE`** (or `PKG_VERSION`) in the package Makefile on every
shipped change — otherwise `opkg upgrade` sees the same version and does nothing.
Do **not** `opkg upgrade` base/system packages from this feed; upgrade only the
four shater packages above.
that is the mechanism working, not a fault.
## 6. apk feed (OpenWrt/ImmortalWrt 25.12+ — incl. BananaWRT 25.12-mtk-vendor)
**When it refuses to arm** — each is a state check made at boot, never a record of
something that happened on the way down:
OpenWrt/ImmortalWrt **25.12** replaces opkg 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`). The
package **Makefiles are unchanged** — the SDK release decides the format.
| 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. |
CI builds this lane **in parallel** with the opkg feed (same manual triggers:
`v*` tag push or `workflow_dispatch`): the `build-apk` jobs in
`.gitea/workflows/release.yml` compile the same 4 packages through the official
**ImmortalWrt 25.12 SDK** (tarballs from
**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 publish **one release per arch** — rolling `apk-latest-x86_64` /
`apk-latest-aarch64_cortex-a53`, or `apk-vX.Y.Z-<arch>` for a tagged version.
Per-arch (unlike the combined opkg release) because apk filenames carry no
architecture and packages are fetched relative to the `packages.adb` URL.
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:** apk cannot use the usign key. The apk trust anchor is the separate EC
> public key **`dist/shater-apk.pem`** (generated once by `ci/gen-apk-key.sh`;
> private half lives ONLY in the Gitea secret **`KEY_APK`**, the apk analog of
> `KEY_BUILD`). Never regenerate either key — that invalidates every deployed
> router's trust. The usign identity `shater-feed.pub` keeps signing the
> opkg/24.10 feed, untouched.
> **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.
One-time setup on a 25.12 router (BananaWRT `25.12-mtk-vendor` on the BPI-R3
mini, BPI-R4 on 25.12, or the future 25.12 VM — `/etc/apk/arch` picks the right
per-arch release automatically):
### 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:
```sh
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
@@ -203,26 +335,145 @@ wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
# 2) add the repo — the line points at the packages.adb INDEX FILE itself.
# (rolling; for a pinned router put apk-vX.Y.Z-$(cat /etc/apk/arch) here — §5.1)
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) refresh + install (shaterd pulled in as a dependency).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # optional: ByeDPI desync egress
```
### Updating
### 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:
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
apk upgrade shaterd shater-core luci-app-shater
```
Same rule as opkg: an upgrade is only offered when the feed version differs, so
bump `PKG_RELEASE`/`PKG_VERSION` on every shipped change (apk shows it as
`0.2.0-r1`). Pin a version instead of tracking rolling by pointing the repo line
at `.../download/apk-vX.Y.Z-$(cat /etc/apk/arch)/packages.adb`.
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:
```sh
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](DECISIONS.md#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken)):
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:
```sh
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.
```sh
# 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:
```sh
# 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
@@ -231,9 +482,15 @@ The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
**`aarch64_cortex-a53`**, and its images even point their distfeeds at vanilla
`downloads.immortalwrt.org/releases/25.12-SNAPSHOT` — so packages built with the
vanilla ImmortalWrt 25.12 filogic SDK install cleanly; no SuperKali-special SDK
is needed. We ship **no kmods** (shaterd is a static Go binary, byedpi plain C),
so the vendor 6.6 kernel is irrelevant to our packages; the kmod *dependencies*
of shater-core (`kmod-nft-tproxy`, `kmod-nft-socket`, plus `ip-full`) are
already **baked into the BananaWRT mtk-vendor image** (verified in its
`config.buildinfo`). On a self-built 25.12 image, make sure those kmods come
from the image's own kernel build.
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.
+127 -16
View File
@@ -28,6 +28,14 @@ openwrt/
luci-app-shater/ # thin LuCI launcher [Phase 3]
```
> That block is the Phase-2 **plan**, kept because the wave assignment below reads
> from it. The tree that shipped is flatter — `shater/` holds `alert apply
> buildtags cmd devices engine generate logsink model netplane panel parse
> registry stats subscribe` — with rulesets, schedules, profiles, backup and
> migration living inside `model/` and `generate/` rather than as packages of
> their own, and with **no `preset/`**: v0.2 has no preset subsystem at all (see
> the `config preset` note in the schema section).
Build order / waves (parallel agents must own DISJOINT dirs, build only their own
package, and never edit `go.mod`):
- **Wave 1 (contract):** `model/` (+ uci reader + migrate). Everything imports it.
@@ -76,8 +84,7 @@ flock. Commit-confirm/rollback and read verbs can follow, but Teardown must be h
`procd_set_param file` watch); `reload_service`→start/stop; `service_triggers`
reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-cron`
(sub/ruleset/schedule due + reconcile). `uci-defaults/30_shater-core` (seed rt_tables
8192, enable inits, seed disabled presets, `model.Migrate`, sysctl from
`netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
8192, enable inits, `model.Migrate`, sysctl from `netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
`sysctl.d/99-shater.conf` (from `netplane`). Default `/etc/config/shater` conffile.
---
@@ -136,7 +143,7 @@ reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-
- **conns.go** — `ConnsJSON`, `parseConntrack` (live flows, proxied/direct).
- **flock_unix.go** — real blocking cross-process flock; `lockPath=/var/lock/xrayctl.lock`.
- **geodata.go** — `geoAssetPresent`, `geoStrip`, `GeodataStatus/Download/Remove` (strip geo matchers when dat absent).
- **migrate.go** — `Migrate`, `CurrentSchemaVersion=1`, `uciRunner` (UCI schema migration; refuses newer).
- **migrate.go** — `Migrate`, `uciRunner`, and on the v0.1 branch `CurrentSchemaVersion = 1` with a single step `{0 -> 1}`. **That 1 is v0.1's number and nothing else's.** v0.2's `shater/model/migrate.go` is at `CurrentSchemaVersion = 2` with `{0 -> 1, 1 -> 2}` — `migrate1to2` is the one that removed `dst_domain`/`dst_ip` from `config rule` (see the schema subsection below, which is the live document). Both versions refuse a config NEWER than the build; in v0.2 that refusal also covers every config WRITE (`model.ErrSchemaTooNew`), so a downgraded package cannot quietly rewrite a newer config into the older form.
- **nodeops.go** — `NodeSetEnabled/NodeDelete/NodeAssignGroup/NodeQR`.
- **observatory.go** — read xray live state via gRPC API inbound (127.0.0.1:10853). (rewrite → lx command server)
- **preset.go** — `presetRules`, `presetDef`, `builtinPresets` (curated rule packs → synthetic Rules).
@@ -195,7 +202,7 @@ type Chain struct { Name string; Hops []string } // "group:<n>" | "node:<n>", L1
type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
type Rule struct {
Name string; Enabled bool; Order int
Src []string; DstDomain,DstRuleset,DstIP []string; DstPort,Proto string
Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
Target string // chain:|group:|node:|direct|block
Egress,Kill string
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
@@ -251,16 +258,116 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
> v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
### uci.go — `/etc/config/shater` schema
- `config globals`: enabled, loglevel, kill_switch, dns_mode, ipv6, fwmark_base, table_base, confirm_timeout, resolver_default, resolver_fallback, probe_url, probe_interval, schema_version, active_profile.
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff.
- `config subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
- `config chain`: name, list hop. `config egress`: name, type, interface, target.
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url), url, path, format, update_interval, list entry.
- `config rule`: name, enabled, order, list src/dst_domain/dst_ruleset/dst_ip, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end/tz.
- `config preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
> This subsection alone describes the **CURRENT v0.2 schema**, not v0.1 — the
> shipped `/etc/config/shater` points its reader here by name, so it is kept in
> step with `shater/model/uci.go` (read) and `render.go` (write), which are the
> only two places a section type or option name exists. Everything else in PART A
> is the v0.1 survey the port was planned from and is deliberately frozen.
>
> An option not listed below is not "undocumented" — it is IGNORED: the parser's
> type switch drops an unknown section type whole, and an unknown option inside a
> known section is never read. That is deliberate (`TestUnknownSectionAndOptionIgnored`
> pins that such a config still parses — the daemon has to come up on whatever it
> finds), and it is also why a dead knob here is SILENT: setting one changes the
> file and nothing else, with no error anywhere to say so.
- `config globals` — the full option set, with the value used when the option is
ABSENT (the `model.DefaultGlobals` seed). Booleans are always written back as
`'1'`/`'0'` by `render.go`, so an explicit value never decays into the seed:
| option | default | meaning |
|---|---|---|
| `enabled` | `0` as shipped | master switch; `0` ⇒ `Reconcile` tears the stack down instead of applying |
| `loglevel` (alias `log_level`) | `warning` | engine + daemon level; `none/off/silent/disabled` ⇒ log disabled, unknown ⇒ `warn` + a validation warning |
| `log_syslog` / `log_file` / `log_persist` | `1` / `1` / `0` | operational log (`shater/logsink`): syslog, rotated file, and whether that file lives on flash instead of tmpfs |
| `log_max_kb` | `2048` | size cap of the log file, clamped to 128…8192; `0` = "use the default", not "off" |
| `kill_switch` | `closed` | `closed` = fail-closed (block on engine loss, incl. a holding plane when the engine never started); `open` = plain routing |
| `ipv6` | `1` | `0` drops LAN IPv6 in the forward chain instead of leaving it unproxied |
| `fwmark_base` / `table_base` | `0x2000` | reserved fwmark / routing-table bases (must not collide with fw4 or other apps) |
| `confirm_timeout` | `0` | seconds before an unconfirmed apply auto-rolls back; `0` = commit-confirm off |
| `resolver_default` / `resolver_fallback` / `endpoint_resolver` | unset | `config resolver` names: the DNS catch-all, its failover chain, and the bootstrap-direct server that resolves proxy endpoint DOMAINS |
| `probe_url` / `probe_interval` | engine defaults | the ONE instrument all health probing uses (D20 — there are no per-group overrides) |
| `panel_port` | `0` ⇒ `8088` | admin-panel HTTP port |
| `dns_filter` | `0` | master enable of the blocklist/allowlist filter (D15); needs at least one `config resolver` |
| `dns_intercept` | **`1`** | force ALL LAN plaintext `:53` into the engine, INCLUDING queries addressed to the router itself. See D24 for why this is the default, what preserves `.lan`, and what happens while the engine is down |
| `block_doh` | `0` | NXDOMAIN the known public DoH hostnames + the Firefox canary and reject `:443` to their IPs, so clients fall back to `:53` (which the engine catches) |
| `group_health` | `1` | OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group |
| `untunnelable` | `block` | policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): `block` \| `icmp` (echo out, rest dropped) \| `direct` (all out, bypassing the tunnel) |
| `l3_tunnel` | **`1`** | Opens the synthetic `l3-in` TUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LAN `icmp`/`ipv6-icmp` with `fwmark_base+0x80` and a scoped `ip rule` sends it to table `table_base+8`. ON by default since the flip (`model/l3tunnel_default_test.go`): with it off a LAN ping is decided by `untunnelable` alone, whose every rung either drops the echo or lets it out of the WAN with the client's real address. An ABSENT option therefore comes back ON; only an explicit `0` closes it, and that opt-out survives the write→read round-trip. Set through UCI — no panel control writes it (the Networks page reads it to explain what `untunnelable` still decides). See D25 and `ARCHITECTURE.md` §3a |
| `untunnelable_egress` | unset | **opt-in**, UCI-only. Names a `config egress`; everything the L3 block did not claim (ESP/AH, GRE, IGMP, SCTP, and ICMP when `l3_tunnel=0`) is stamped with that egress's OWN mark and routed out its device by the kernel — no new mark, no new table, engine not in the path. Empty ⇒ `untunnelable` above stays in sole charge (D26) |
| `geo_provider` | unset = auto | `sagernet` \| `loyalsoldier` \| `metacubex` \| `custom`; auto = country codes from SagerNet, everything else from Loyalsoldier |
| `geosite_url` / `geoip_url` | unset | `{category}` templates, honoured only when `geo_provider=custom` |
| `geosite_index_url` / `geoip_index_url` | unset | git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions |
| `stats_backend` | `memory` | `off` (no aggregation at all) \| `memory` (RAM, lost on restart) \| `sqlite` (aggregates in RAM + query/connection log on disk). The value NAME is historical: the on-disk store is **bbolt**, not SQLite, since the migration — a leftover sqlite-era `stats.db` is detected by its file magic and replaced (`shater/stats/boltring.go`) |
| `stats_ring_size` / `stats_timeline_minutes` / `stats_max_domains` | `200` / `60` / `5000` | live-log length, sparkline minutes, domain-map cap. **`0` = UNLIMITED** (grows with traffic), which is why these three are always emitted |
| `stats_disk_limit_mb` | `64` | on-disk cap of `stats.db`; only meaningful for `stats_backend=sqlite`; `0` = unlimited |
| `stats_retention_disabled` | `0` | master switch that turns OFF all trimming/pruning — every aggregate then grows unbounded |
| `schema_version` | `0` = pre-versioned | UCI schema revision; `shaterd migrate` writes `2` |
| `active_profile` | unset | display bookkeeping: the last profile switched to |
Deleted options still parse (unknown keys are ignored) and drain out on the next
render: `dns_mode` (D17 — fake-IP is a resolver TYPE), `sweep_interval` (D19).
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp.
**No `sniff`.** Since sing-box 1.11 sniffing is a leading route ACTION rule with no
inbound matcher, so every inbound is sniffed always; the flag was read by nothing but
its own UCI round-trip. Re-adding it would be a regression, not a restored feature —
the hijack-dns rule matches the SNIFFED `dns` protocol, so a per-inbound toggle is a
DNS-leak switch wearing a performance label (`model.go`, `Inbound`).
- `config subscription`: name, enabled, url, update_interval, fetch_via(`direct`|`proxy`), fetch_detour, ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days — plus the persisted `Subscription-Userinfo` state the daemon writes back itself: user_upload, user_download, user_total, user_expire, userinfo_at (absent = `0` = "not reported", on both the read and the write side).
`fetch_detour` is consulted only when `fetch_via=proxy`; it names the outbound the fetch dials through — `group:X` \| `node:X` \| `egress:X` \| `chain:X` \| `direct`, empty = direct (`apply.HTTPClient`/`resolveVia`).
- `config node`: name, enabled, uri, mux, mux_concurrency, sockopt_mark, tcp_fast_open, tcp_keepalive_idle, egress — the last binds THIS node's own upstream to a `config egress` (multi-WAN), so its exit connection leaves over the chosen device.
**No `xudp_concurrency`/`xudp_udp443`**: xudp was an xray packet-encoding knob with no sing-box counterpart, and the fields went with the generator rewrite.
`from_sub` / `fingerprint` / `stale` are READ but never written. Subscription nodes live in per-subscription cache files (`model/subcache.go`); the options survive only so an old config's cached nodes are imported once on first read, after which they drain out of UCI.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, egress — the last binds EVERY member's dialer to that egress (a node's own `egress` is more specific and wins).
**No `probe_url`/`probe_interval`**: the per-group overrides were deleted; probing is configured once, in `globals` (D20). Old configs carrying them still parse — the options are ignored and drain out on the next render.
- `config chain`: name, list hop (`group:<n>` \| `node:<n>`, L1..Ln, Ln = exit).
- `config egress`: name, type, interface, dpi.
type is `interface` \| `direct`; `tunnel` is an accepted ALIAS of `interface` and an empty value means `direct` — both folded to the canonical spelling once, at the config boundary (`Model.NormalizeEgressTypes`, called by `ReadUCI`), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by `ValidateEgresses`, every binding to it fail-closed). A type this product REMOVED is a third case with the same fail-closed behaviour and a different sentence — `model.RetiredEgressTypes` is the closed table both `ValidateEgresses` and the generator read it from, so an operator whose config was correct for an older build is told what happened rather than that their value is a typo (D29).
`interface` names the device and is meaningful for the `interface` type only; `dpi` is the native DPI-bypass preset — `off`|`fragment`|`record`|`spoof` (D13).
**No `port`**: no surviving egress kind dials anything, so the option is not parsed and drains out on the next render. It belonged to the removed SOCKS-hop kind (D29).
**No `target`**: v0.1's `proxy`/`block` egress kinds are gone — where traffic goes is a rule's `target`, what device it leaves by is an egress.
- `config ruleset`: name, type(`domain`|`ipcidr`, default `domain`), source(`inline`|`file`|`url`|`geosite`|`geoip`, default `inline`), url, path, format, update_interval, list category, list entry.
`format` names the ENGINE rule-set format and has exactly two real values, `binary` (a compiled `.srs`) and `source` (a sing-box rule-set `.json`); empty — and the v0.1 leftover `plain`, and `auto` — mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip.
`list category` is the canonical spelling (one chip per geosite category or geoip country code, each materialised as its own remote `.srs`); a legacy single `option category` is still accepted on read and re-emitted as a list.
- `config rule`: name, enabled, order, list src, list dst_ruleset, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end, sched_utc_offset.
v0.1 carried `dst_domain`/`dst_ip` on the rule itself; **schema v2 removed both** — a
destination is a `config ruleset` and nothing else. `shaterd migrate` folds each legacy
list into a generated `rule-<name>` (and `rule-<name>-ip`) inline ruleset; see
`DECISIONS.md` D21 for the entry-by-entry conversion table.
- `config profile`: name, enabled, priority, list match_iface, list enable_rule, list disable_rule, endpoint_resolver.
The condition is `match_iface` and nothing else: the active default-route device must be in that set (`cmd/shaterd/profilewatch.go`). The overrides are the two rule lists plus an optional per-profile `endpoint_resolver`, which overrides `globals.endpoint_resolver` while the profile is active (`generate/dns.go`; the point is keying the bootstrap resolver to the WAN in use).
**No `probe_url`/`probe_mode`**, and they were deleted rather than documented: they promised "activate while a probe succeeds/fails", nothing ever probed, AND the selector treated a profile carrying a `probe_url` as having an unsatisfiable condition and skipped it — so adding a probe to a working profile silently switched that profile off.
**No `sched_*`, no `default_target`/`default_egress`** either — a profile enables and disables named rules; it does not carry a routing default of its own.
- `config resolver`: name, type, address, detour, pool.
type is `doh` (synonym `https`) \| `dot` (synonym `tls`) \| `plain` (synonyms `udp` and the empty default) \| `tcp` \| `local` \| `fakeip`. Anything else is NOT built — the resolver simply does not exist, and `generate` says so by name.
Each type consumes a different subset, and the rest is thrown away (`generate.warnIgnoredResolverFields` warns per field rather than dropping it silently): `doh`/`dot`/`plain`/`tcp` use address + detour, ignore pool; `local` uses detour only (it reads the router's `/etc/resolv.conf`); `fakeip` uses pool only (default `198.18.0.0/15`) — it mints answers locally, so it has nothing to dial and ignores both address and detour.
`detour` is per-SERVER, not global: every sing-box DNS server carries its own.
- `config dns_rule`: order, list match_domain, list match_src, resolver. It has no `name` — a DNS rule is identified by its order and selectors.
- `config blocklist`: name, enabled, source(`inline`|`file`|`url`|`geosite`), url, path, list category, list entry, response, update_interval.
`response` has exactly two values: `nxdomain` (default — Rcode 3, empty answer) and `zero` (NOERROR + `A 0.0.0.0`, and `AAAA ::` when `globals.ipv6`). There is no third; neither is sing-box's `action:reject`, which answers REFUSED.
- `config allowlist`: the same minus `response` (an allowlist has no verdict to render); it overrides every blocklist, being emitted at higher priority.
- `config device`: name, mac, ip, enabled, list block, list allow — where `block`/`allow` are DOMAINS, not targets: a device entry is per-device DNS filtering (block regardless of the global `dns_filter` switch, allow overriding every blocklist). Per-device ROUTING is not a device concern — it is an ordinary `config rule` whose `src` names the device.
- `config alert`: name, enabled, type(`telegram`|`webhook`), token + chat_id (telegram), url (webhook), list event, via, fallback.
- **`config preset` is NOT a section type, and there is no preset subsystem in
v0.2.** `ParseUCIExport`'s type switch has no `preset` branch, so such a section
is parsed by nothing, reaches no part of the model, and setting `enabled=1` on
one changes the file and nothing about the router.
It used to appear anyway: `30_shater-core` seeded three (`block_ads`,
`ru_bypass`, `private`) "so the LuCI Rules page renders their toggles", and
v0.2's LuCI app is a launcher with no Rules page. Worse than inert — the panel's
first save wiped them, because `writeUCIWith` replaces the whole package
(`uci delete shater` + `uci import`), so the placeholder deleted itself and read
as a breakage. **The seeding is gone, and the script now DELETES any `preset`
section an older release left behind** (safe by construction: the type is read
by no consumer, so there is no setting to lose).
This is not a feature waiting to be re-enabled. v0.1's packs were xray
`geosite:`/`geoip:` matcher lists materialised into synthetic rules
(`xrayctl/preset.go` on the v0.1 branch); under schema v2 a rule's destination
IS a `config ruleset`, so the same pack is an ordinary ruleset + rule — which
the panel's Routing page builds today, geosite/geoip sources included. Anything
richer needs a section type the parser knows, and that has to land in
`shater/model` first.
### Subscriptions & HAPP fetch
Schemes: `vless:// vmess:// trojan:// ss:// wireguard:// wg://`. Body formats (`DetectSubFormat`): clash-YAML, xray-JSON, singbox-JSON, base64/plain link list. All converge to URIs re-parsed by `ParseShareLink`. HAPP fetch: UA default `Happ/3.13.0`; headers `x-hwid` (auto UUIDv4/sub), `x-device-os`, `x-ver-os`, `x-device-model`, custom. `fetch_via=proxy` dials local socks. Quota/expiry from `Subscription-Userinfo` (`upload;download;total;expire`). Reconcile by `Fingerprint` (sha256 of proto|addr|port|id|net|sec|sni|path) → new/keep/stale (drop after 3 stale refreshes).
@@ -268,10 +375,14 @@ Schemes: `vless:// vmess:// trojan:// ss:// wireguard:// wg://`. Body formats (`
---
## PART B — v0.1 packaging (`shater-core/`, branch `v0.1`)
Pure scripts+config, `PKGARCH:=all`. v0.1 DEPENDS: `+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full`. → **v0.2 deps: `+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full`** (engine does DNS in-process, so dnsmasq-full may be droppable — confirm the :53 listener is our engine). `/etc/config/shater` is a conffile.
Pure scripts+config, `PKGARCH:=all`. v0.1 DEPENDS: `+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full`. → **v0.2 deps (authoritative: `openwrt/shater-core/Makefile`, which annotates each one): `+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle`** — `dnsmasq-full` is gone (the engine owns the `:53` hijack listener); `kmod-tun` is `/dev/net/tun` for the L3 ingress, `nftables-json` is the `nft -j` output `netplane/stats.go` parses, `ca-bundle` is the cert store a `CGO_ENABLED=0` binary has no host fallback for. `/etc/config/shater` is a conffile.
- **init.d/shater** (procd, START=99/STOP=10): v0.1 supervised `xray run -c /etc/xray/run.json`; → v0.2 supervises `shaterd`. `respawn 3600 5 0` (infinite). **No `procd_set_param file` watch** (would bounce tunnel on commit). Inert unless `globals.enabled=1`. `ACTIVE_FLAG=/var/run/shater.active` gates hotplug/cron. `stop` clears flag + tears down nft table + reserved routing tables. `reload_service`→start/stop. trigger `procd_add_reload_trigger "shater"`.
- **init.d/shater-cron** (START=96): supervised `loop`; per-item due-check, runs sub/ruleset update + reconcile + schedule due; watchdog: engine dead 5 ticks ⇒ kill_switch=open stops stack (fail-open), closed logs crit.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), enable both inits, seed preset packs (disabled), run migrate, apply sysctl.
- **init.d/shater-armor** (START=21/STOP=89, v0.2-only — no v0.1 counterpart): the fail-closed plane BEFORE the daemon exists. `/etc/init.d/shater` is START=99, so from netifd's `ifup` until the daemon's first apply the router forwarded LAN→WAN in the clear. The daemon persists its holding plane to `/etc/shater/boot.nft` on every apply; this loads it after fw4 (19) and netifd (20), `nft -c`-validated. Four state checks refuse to arm (no/empty/invalid file, missing `shaterd`, no `S??shater` rc-link, readable UCI saying `enabled≠1`) — asked ON THE WAY UP, deliberately not recorded on the way down. Hooks `forward` only, so SSH/LuCI/panel stay reachable. `stop()` is a NO-OP. Operator-facing writeup: `INSTALL.md` §4.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), `mkdir /etc/shater`, DELETE any leftover `config preset` section (a type nothing parses — see the schema note above; the seeding of three of them is gone), seed the `shater_l3` fw4 zone + a `<zone>→shater_l3` forwarding for every zone (named sections, `list device 'shater-l3*'`) and migrate a legacy exact-name entry to the wildcard, run `shaterd migrate` (whose result is **classified and reported**, not discarded — see below), apply sysctl, then a DETACHED bring-up (enable+restart `shater`/`shater-cron`, enable `shater-armor`, conditional `firewall reload`) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock.
- **`shaterd migrate` reporting** (both call sites: `uci-defaults/30_shater-core` and `init.d/shater`'s `start_service`). The verb exits 1 for every failure, so the shell classifies the outcome itself, with a CLOSED positive list — `ok` / `downgrade` / `unreadable` / `failed` (`shater_migrate_class`, duplicated in the two scripts because the package installs no shell library they could share; `TestMigrateClassifiersAgree` fails if they ever diverge). `downgrade` is recognised by the substring `newer than this build`, which both `model.migrateWith`'s refusal and `model.ErrSchemaTooNew` contain — a contract pinned by `TestMigrateDowngradeSignatureIsAContract`. An unrecognised failure lands on `failed`, which says so and quotes the binary verbatim, rather than being reported as one of the causes we can name.
Failures reach the operator on **two channels that are not syslog**, because `globals.log_syslog=0` is a deliberate setting about the syslog stream and not a request to be left uninformed: the script's own **stderr** (the operator's terminal on a hand-typed `restart`; the package manager's output inside `apk add`/`opkg install`), and **`/etc/shater/migrate-failed`** on flash, written on failure and REMOVED on the first success — its absence is the all-clear. syslog gets the same line too when `log_syslog` allows it. A migration that SUCCEEDED is routine and stays on the syslog channel only.
`30_shater-core` still **exits 0** after a failed migration, deliberately: a uci-defaults script that exits non-zero is kept and re-run at every boot, and this one re-runs a detached enable+restart of `shater`/`shater-cron` plus a firewall reload — so one recoverable failure would become permanent boot-time churn, to carry a status nothing reads. The retry that matters already exists: `init.d/shater` runs the migration on every start.
- **hotplug.d/iface/99-shater**: ifup/ifdown → debounced (2s) `reconcile` (netifd wipes ip rules on reload). Guarded by enabled + ACTIVE_FLAG.
- **sysctl.d/99-shater.conf**: `ip_forward=1`, `rp_filter=0` (all+default), `lo.route_localnet=1`, `lo.accept_local=1`, `all.src_valid_mark=1`, `ipv6.all.forwarding=1`.
+18
View File
@@ -0,0 +1,18 @@
# Документация shater
Документация продукта **shater** (управляемый интернет-шлюз для роутеров на
OpenWrt). Лицо репозитория и быстрый старт — в корневом [`../README.md`](../README.md).
| Документ | О чём |
|----------|-------|
| [CONTEXT.md](CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, решения в кратце, testbed/инфра |
| [INSTALL.md](INSTALL.md) | Сборка ship-артефакта (`shaterd`) и установка apk-фида (25.12+): роллинг или фиксация версии |
| [ARCHITECTURE.md](ARCHITECTURE.md) | One-binary дизайн, auth-handoff LuCI→панель, data/DNS/apply-потоки (диаграммы) |
| [FEATURES.md](FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [ROADMAP.md](ROADMAP.md) | Фазовый план |
| [DECISIONS.md](DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия и т.д. |
| [DESIGN.md](DESIGN.md) | Визуальная система панели — направление «Faceplate», токены, компоненты |
| [PORTING.md](PORTING.md) | Порт проверенных кусков из v0.1 |
Документация движка-форка (sing-box-lx) — в его слое: [`../docs-lx/`](../docs-lx/)
и [`../SPECS/`](../SPECS/).
+17 -21
View File
@@ -49,25 +49,21 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
the gate: fail-closed forward drop (4f618140), engine apply-swap close-first
fallback (9b6b9406), DNS hijack-dns per D14 (86194ce6).
## Phase 2b — DPI-bypass egress = ByeDPI (D13)
- The one external desync tool is **ByeDPI (ciadpi)** — chosen over zapret because
it *is* a SOCKS egress (fits shater's "routing picks the egress" model with zero
packet-plane conflict); zapret is explicitly rejected (see D13).
- Add egress `dpi` value `byedpi`: a supervised local `ciadpi` SOCKS5 instance +
a `socks` outbound pointed at it. New `openwrt/` procd package + musl-static
cross-build of ciadpi (~100 KB); `model` reserves the egress kind, `generate`
wires the `socks` outbound.
- QUIC gap closed by routing (drop `udp/443` for desync-domains → TCP+TLS
fallback), not by adopting a packet plane.
- The **`byedpi` package** (`openwrt/byedpi/`, SEPARATE & optional) provides the
`ciadpi` process behind a `type='byedpi'` egress: it cross-compiles ciadpi via
the SDK toolchain and ships a procd init that supervises one `ciadpi` SOCKS5
desync instance per enabled `config instance` in `/etc/config/byedpi`
(127.0.0.1:`<port>`). Install it only when you want a byedpi egress; a
`type='byedpi'` egress with no matching `ciadpi` listener simply has nothing to
dial. `shater-core` does NOT depend on it (opt-in).
- **Gate:** a DPI-blocked domain (that plain `fragment` can't crack) loads via the
`byedpi` egress on the VM, direct (no tunnel), kill-switch still honest.
## Phase 2b — DPI-bypass egress ✅ DONE, then REVERSED (D29, 2026-07-27)
- Shipped as **ByeDPI (ciadpi)**: a supervised local SOCKS5 desync process in its
own optional `openwrt/byedpi/` package, reached through a `type='byedpi'`
egress. Chosen over zapret because it *is* an egress and needed no second
packet plane (D13); zapret stays rejected.
- **Removed in full on 2026-07-27** — package, egress kind, readiness endpoint and
panel plate. It was adopted because the engine's own `tls_fragment` /
`tls_record_fragment` presets did not get through; the cause was a defect in
our fragmentation (the split always landed inside the first label of the name),
not a limit of the method. With that fixed the built-in presets carry this, and
the external process is weight without a job. Full argument: **D29**.
- What survives from this phase: the native `dpi` presets `fragment` / `record` /
`spoof` on a `direct` or `interface` egress, which is what the feature is now.
- A config still naming the removed kind is fail-closed and told so by name — see
`model.RetiredEgressTypes`.
## Phase 3 — Admin panel MVP + thin LuCI launcher ✅ DONE (2026-07-15)
- `panel/`: embedded web server on its own port + session store; token-mint ubus
@@ -86,7 +82,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
well-known lists (StevenBlack/OISD/AdGuard).
- **Gate:** ad/tracker domains blocked network-wide; big list loads fast; RAM sane.
## Phase 5 — Statistics (per-domain / client / device)
## Phase 5 — Statistics (per-domain / client / device) ✅ DONE
- Stats aggregator consuming the engine's DNS/routing/stats observability + nft
counters: top domains, allowed vs blocked, per-device breakdown, timelines,
per-node/per-rule traffic, live query log with one-click block.
@@ -104,7 +100,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
## Phase 8 — Ship it ✅ DONE
- Adapt CI to build/sign the single forked binary for both arches; publish the
signed opkg feed (reuse key `5ac4b177689cb8e0`); install/upgrade docs.
signed feed (apk since D22, EC key `dist/shater-apk.pem`); install/upgrade docs.
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
## Cross-cutting (every phase)
@@ -0,0 +1,170 @@
# Живое тестирование shater v0.2.6 на mini_router
**Дата:** 2026-07-25
**Устройство:** Bananapi BPi-R3 Mini · ImmortalWrt **25.12-linkup** · `aarch64_cortex-a53`
**Установка:** из подписанного apk-фида `apk-v0.2.6-aarch64_cortex-a53`
**Пакеты:** `shaterd 0.2.0-r3`, `shater-core 0.2.0-r3`, `luci-app-shater 0.2.0-r2`, `byedpi 0.17.3-r1`
**Сборка:** CI run 61, коммит `024e9308c` (вершина `main`)
Сценарий: полное удаление предыдущей установки → чистая установка из фида →
проверка дефолтного состояния → восстановление рабочего конфига с подписками
(315 узлов) → функциональная проверка.
**Итог: 79 проверок, 74 PASS, 5 находок** (детали и разбор — в
`shater-bugs-2026-07-25.md` на рабочем столе).
---
## 1. Релиз и фид
| # | Проверка | Результат |
|---|---|---|
| T1 | Публикация `apk-v0.2.6-<arch>` для обеих архитектур | PASS |
| T2 | Ассеты: 4 `.apk` + `packages.adb` + `shater-apk.pem` | PASS |
| T3 | `apk update` принимает индекс (проверка EC-подписи) | PASS |
| T4 | Пакеты видны в нужных версиях (r3/r3/r2) | PASS |
| T5 | Диагностика сборки: `kmod packages selected (=m): 0` (было 1078) | PASS |
| T6 | Собраны ровно наши 4 пакета | PASS |
| T7 | opkg-лейн v0.2.6 (24.10) тоже зелёный | PASS |
## 2. Установка
| # | Проверка | Результат |
|---|---|---|
| T8 | `apk add luci-app-shater byedpi` — 4 пакета | PASS |
| T9 | Зависимости `kmod-nft-tproxy`/`kmod-nft-socket` из базового фида | PASS |
| T10 | Целостность: `apk manifest` = sha256 файла на диске | PASS |
| T11 | Установлен именно бинарь v0.2.6 (5 491 616 Б vs 5 488 336 Б в r2) | PASS |
| T12 | init-скрипты `shater`, `shater-cron` | PASS |
| T13 | `sysctl.d/99-shater.conf`, `hotplug.d/iface/99-shater` | PASS |
| T14 | boot-линки `S99shater`, `K10shater`, `S96shater-cron` | PASS |
## 3. Дефолтное состояние (чистая установка)
| # | Проверка | Результат |
|---|---|---|
| T15 | Дефолтный конфиг создан uci-defaults (27 строк) | PASS |
| T16 | `enabled='0'` — плоскость не ставится без согласия | PASS |
| T17 | Заготовлен tproxy-inbound на LAN, пресеты выключены | PASS |
| T18 | Демон стартует, `plane=none`, `table=false` | PASS |
| T19 | Права конфига `-rw-------` (0600) | PASS |
## 4. Восстановление рабочего конфига
| # | Проверка | Результат |
|---|---|---|
| T20 | Восстановление из бэкапа (3213 UCI-строк) | PASS |
| T21 | Кэш подписок цел: 315 узлов в 4 файлах | PASS |
| T22 | `shaterd migrate` → `ok`, схема v1 | PASS |
| T23 | Старт с реальным конфигом: `active`, `engine_running`, `plane=full` | PASS |
## 5. Data plane
| # | Проверка | Результат |
|---|---|---|
| T24 | Таблица `inet shater` создана (9 цепочек/сетов) | PASS |
| T25 | 16 tproxy-правил | PASS |
| T26 | `ip rule from all fwmark 0x2000 lookup shater` | PASS |
| T27 | `accept_local=1` на `br-lan` | PASS |
| T28 | DNS-divert: `dport 53 → tproxy :12345` для LAN-интерфейсов | PASS |
| T29 | DoT заблокирован: `dport 853 reject` | PASS |
| T30 | `block_doh=1`, правила присутствуют | PASS |
| T31 | **Kill-switch fail-closed**: цепочка `forward` завершается `drop` для LAN (v4+v6) | PASS |
| T32 | fw4 и dnsmasq не тронуты (свои таблицы целы) | PASS |
## 6. Панель и API
| # | Проверка | Результат |
|---|---|---|
| T33 | SPA отдаётся на `:8088` | PASS |
| T34 | `shaterd mint-token` выдаёт одноразовый токен | PASS |
| T35 | `/api/status` без сессии → **401** | PASS |
| T36 | `/api/session` (POST, JSON) → 200 + cookie `HttpOnly; SameSite=Strict; Max-Age=28800` | PASS |
| T37 | `/api/status` по cookie отдаёт данные, совпадающие с CLI | PASS |
| T38 | `/api/config` — 340 записей узлов | PASS |
| T39 | `/api/groups/health` — 103 протестировано, 13 живых, выбран `FR-vless-8` | PASS |
| T40 | `/api/devices` — устройства с IPv4/IPv6/MAC | PASS |
| T41 | `/api/interfaces` — `ewan/eth1 10.0.0.125/24 zone=wan` | PASS |
| T42 | `/api/ruleset/status` — remote-ruleset обновлён сегодня | PASS |
| T43 | `/api/stats` — memory backend, счётчики и top-domains | PASS |
| T44 | `/api/stats/log` — query-log с доменом, qtype, rcode, сервером | PASS |
| T45 | `/api/log?range=100` — пусто (следствие `log_file='0'`, не дефект) | OK |
## 7. Жизненный цикл конфигурации
| # | Проверка | Результат |
|---|---|---|
| T46 | `shaterd apply` → `{"changed":false}`, `can_rollback=true` | PASS |
| T47 | `shaterd confirm` снимает авто-откат (`can_rollback=false`) | PASS |
| T48 | `shaterd rollback` после confirm корректно сообщает об отсутствии last-good | PASS |
| T49 | `shaterd reconcile` (SIGHUP) не роняет движок | PASS |
| T50 | `shaterd sub update all-qomar` — реально обновил 143 узла | PASS |
| T51 | `shaterd blocklist update` → reconcile signalled | PASS |
| T52 | `shaterd schedule due` → reconcile signalled | PASS |
## 8. Устойчивость
| # | Проверка | Результат |
|---|---|---|
| T53 | `kill -9` демона → procd поднимает новый PID | PASS |
| T54 | После respawn: `engine_running=true`, `plane=full` | PASS |
| T55 | `stop` снимает таблицу `inet shater` полностью | PASS |
| T56 | `stop` → пауза → `start`: плоскость восстанавливается | PASS |
| T57 | Сеть при остановленном shater не деградирует | PASS |
| T58 | Память: 253 МБ занято из 2 ГБ при работающем движке | PASS |
## 9. DNS
| # | Проверка | Результат |
|---|---|---|
| T59 | Резолв через `127.0.0.1` | PASS |
| T60 | LAN-клиенты резолвят через движок (query-log растёт) | PASS |
| T61 | `.lan`-домены остаются за dnsmasq | PASS |
| T62 | dnsmasq жив и слушает на всех адресах | PASS |
| T63 | **Резолв через LAN-адрес `10.67.0.1` после `restart`** | **FAIL — B3** |
| T64 | Тот же резолв после `stop` → пауза → `start` | PASS |
## 10. Конфигурация и логи
| # | Проверка | Результат |
|---|---|---|
| T65 | 5 правил маршрутизации, 2 профиля, активен `ethernet-uplink` | PASS |
| T66 | **Два правила `default`, оба catch-all — нижнее живое, верхнее мертво** | **FAIL — B1** |
| T67 | **`shaterd nodes` всегда возвращает `[]`** | **FAIL — B2** |
| T68 | Логи уходят в syslog (`log_syslog=1`, 22 записи) | PASS |
| T69 | **ANSI-escape коды в syslog** | **FAIL — B5** |
| T70 | `loglevel=warning` соблюдается | PASS |
| T71–T79 | Прочие проверки состояния (статус-поля, права, uptime, счётчики, целостность таблиц) | PASS |
---
## Находки
| ID | Суть | Важность |
|---|---|---|
| **B1** | Два catch-all правила `default`; одно из них не работает никогда. **Поправка к первоначальному диагнозу:** правило без условий задаёт `route.Final`, а не выпускается как match-all, поэтому выигрывает ПОСЛЕДНЕЕ (`order=100 → group:auto`) — трафик идёт через прокси, а мёртвая настройка это `order=20 → direct` | средняя |
| **B2** | `shaterd nodes` — заглушка, всегда `[]`, хотя usage обещает список узлов (в кэше 315, в `/api/config` 340) | средняя |
| **B3** | После `service shater restart` резолв к LAN-адресу роутера не работает и не восстанавливается; `stop`+пауза+`start` — работает (гонка) | средняя |
| **B4** | `PKG_RELEASE` не менялся с v0.2.1 → v0.2.2…v0.2.6 выходят как `r3` при разном содержимом; `apk upgrade` не увидит обновления | средняя |
| **B5** | ANSI-раскраска попадает в syslog | низкая |
Разбор с воспроизведением — в `shater-bugs-2026-07-25.md`.
## История CI по этому релизу
Путь до зелёной сборки apk-лейна занял четыре итерации, каждая вскрывала
следующий слой одной причины:
| Тег | Что чинили | Итог |
|---|---|---|
| v0.2.2 | — (первый прогон с фиксами аудита) | `Disk quota exceeded`, 3593 `apk mkpkg kmod-*` |
| v0.2.3 | `.config` строится с нуля, а не дописывается | 1078 kmod — SDK вообще не везёт `.config` |
| v0.2.4 | Выключены `ALL`/`ALL_KMODS`/`ALL_NONSHARED` | 1078 kmod — они выбираются не через `ALL_KMODS` |
| v0.2.5 | Второй проход: явное `is not set` для каждого kmod | 1078 kmod — kconfig игнорирует user-значение у беспромптовых символов |
| **v0.2.6** | Удаление сгенерированных блоков `config PACKAGE_*` (`default m`) из `Config-build.in` | **0 kmod, сборка зелёная** |
Корень: `target/sdk/Makefile` генерирует `Config-build.in` прогоном
`convert-config.pl` по конфигу бильдбота, где `ALL_KMODS=y` уже развернулся в
`CONFIG_PACKAGE_kmod-*=m` на каждый модуль. Фильтр `next if /^(# )?CONFIG_PACKAGE/`
в скрипте стоит в ветке `else`, куда строка со знаком `=` не попадает, поэтому
каждый kmod приезжает в SDK как безусловный `default m`.
+9 -2
View File
@@ -2,6 +2,9 @@ package libbox
import (
"context"
// lx:begin sec-consttime
"crypto/subtle"
// lx:end sec-consttime
"errors"
"net"
"os"
@@ -97,9 +100,11 @@ func unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerIn
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authentication secret")
}
if values[0] != sCommandServerSecret {
// lx:begin sec-consttime
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
return nil, status.Error(codes.Unauthenticated, "invalid authentication secret")
}
// lx:end sec-consttime
return handler(ctx, req)
}
@@ -115,9 +120,11 @@ func streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServe
if len(values) == 0 {
return status.Error(codes.Unauthenticated, "missing authentication secret")
}
if values[0] != sCommandServerSecret {
// lx:begin sec-consttime
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
return status.Error(codes.Unauthenticated, "invalid authentication secret")
}
// lx:end sec-consttime
return handler(srv, ss)
}
+8 -2
View File
@@ -74,7 +74,11 @@ func (r *oomReporter) WriteReport(memoryUsage uint64) error {
draftInfo = nil
}
reportsDir := filepath.Join(sWorkingPath, "oom_reports")
err = os.MkdirAll(reportsDir, 0o777)
// lx:begin sec-perms
// OOM reports embed the config snapshot (server secrets, keys) and logs;
// keep the tree owner-only (0700 dirs / 0600 files) instead of 0777/0666.
err = os.MkdirAll(reportsDir, 0o700)
// lx:end sec-perms
if err != nil {
return err
}
@@ -121,7 +125,9 @@ func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error {
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
now := time.Now().UTC()
err := os.MkdirAll(destPath, 0o777)
// lx:begin sec-perms
err := os.MkdirAll(destPath, 0o700)
// lx:end sec-perms
if err != nil {
return err
}
+7 -2
View File
@@ -44,7 +44,10 @@ func baseReportMetadata() reportMetadata {
func writeReportFile(destPath string, name string, content []byte) {
filePath := filepath.Join(destPath, name)
os.WriteFile(filePath, content, 0o666)
// lx:begin sec-perms
// Report files may carry the config snapshot (secrets) — owner-only.
os.WriteFile(filePath, content, 0o600)
// lx:end sec-perms
chownReport(filePath)
}
@@ -69,7 +72,9 @@ func copyConfigSnapshot(destPath string) {
}
func initReportDir(path string) {
os.MkdirAll(path, 0o777)
// lx:begin sec-perms
os.MkdirAll(path, 0o700)
// lx:end sec-perms
chownReport(path)
}
+7 -1
View File
@@ -46,7 +46,7 @@ require (
github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3
github.com/sagernet/sing-mux v0.3.5
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc
github.com/sagernet/sing-shadowsocks v0.2.8
github.com/sagernet/sing-shadowsocks2 v0.2.1
github.com/sagernet/sing-shadowtls v0.2.1
@@ -104,14 +104,20 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koron/go-ssdp v0.0.4 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
github.com/libp2p/go-netroute v0.2.1 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
+26 -2
View File
@@ -101,6 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -109,10 +111,14 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY=
github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY=
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
@@ -122,6 +128,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -138,6 +146,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8=
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8=
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
@@ -264,8 +276,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg=
github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI=
github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 h1:hFLPJ21uNZSbRnzhOKz4Zv0b4F93mpDorWyN93BeRcM=
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7/go.mod h1:+oqD54aHel4ALKkp1hVXWCgLU/EjLojvm6AUzDfvj0I=
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw=
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0=
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
@@ -360,6 +372,8 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
@@ -367,17 +381,24 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -389,6 +410,7 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
@@ -396,8 +418,10 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

-88
View File
@@ -1,88 +0,0 @@
#
# byedpi — ByeDPI (ciadpi), a tiny portable-C SOCKS5/HTTP desync proxy.
#
# This is the process behind a Shater egress of `type='byedpi'`: shaterd's
# `generate` emits a SOCKS5 outbound `egress-<name>` -> 127.0.0.1:<port>, and a
# `ciadpi` instance supervised by this package listens on that port, applies
# TCP/TLS desync to the connections passing through it, and goes DIRECT to the
# target (no tunnel). Kept as a SEPARATE, optional package: a byedpi egress is
# opt-in — install this only when you want the external desync engine.
#
# Compiled C (musl, per target) => NOT PKGARCH:=all. The OpenWrt SDK toolchain
# cross-compiles ciadpi via its own plain Makefile.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=byedpi
PKG_VERSION:=0.17.3
PKG_RELEASE:=1
# Pinned upstream release tag v0.17.3 (commit
# 7efde1b1296eaaa187b70e951894dde17527489c). codeload emits a stable tarball
# per tag; PKG_HASH is the sha256 of that tarball (build fails on mismatch).
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hufrea/byedpi/tar.gz/refs/tags/v$(PKG_VERSION)?
PKG_HASH:=0a9cb8585554c68c3e2be88c33c9bf6f99f8e8c7f54b362285adab99e262566c
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/byedpi
SECTION:=net
CATEGORY:=Network
TITLE:=ByeDPI (ciadpi) local SOCKS5/HTTP desync proxy
URL:=https://github.com/hufrea/byedpi
# Pure C against musl; every target has a C toolchain, so no arch-depends.
# No runtime library deps beyond libc (static-ish tiny binary).
DEPENDS:=
endef
define Package/byedpi/description
ByeDPI is a small local SOCKS5/HTTP proxy that applies TCP/TLS desynchronization
(split, disorder, fake packets, TLS-record splitting) to the connections passing
through it and then connects DIRECTLY to the destination — no upstream tunnel.
Its binary is `ciadpi`. In the Shater stack it is the process behind an egress of
`type='byedpi'`: shaterd routes selected traffic to a local SOCKS5 outbound
pointed at ciadpi's 127.0.0.1:<port>. Multi-instance, driven by /etc/config/byedpi.
endef
# ciadpi's upstream Makefile appends its own required flags with `CFLAGS +=`.
# A CFLAGS set on the make command line CLOBBERS that `+=` (GNU make: a
# command-line assignment overrides the makefile's append), so we must re-supply
# ciadpi's own needed flags (-I. -std=c99 and its warning set) alongside
# $(TARGET_CFLAGS). CPPFLAGS (-D_DEFAULT_SOURCE) is left untouched by not
# overriding it. The default target `all` builds the `ciadpi` binary; its link
# rule is `$(CC) -o ciadpi $(OBJ) $(LDFLAGS)`, so $(TARGET_LDFLAGS) reaches the
# link. Kernel headers (linux/netfilter_ipv4.h) come from the SDK sysroot.
define Build/Compile
+$(MAKE) -C $(PKG_BUILD_DIR) \
CC="$(TARGET_CC)" \
CFLAGS="$(TARGET_CFLAGS) -I. -std=c99 -Wall -Wno-unused -Wextra -Wno-unused-parameter" \
LDFLAGS="$(TARGET_LDFLAGS)" \
all
endef
define Package/byedpi/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/ciadpi $(1)/usr/bin/ciadpi
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/byedpi $(1)/etc/init.d/byedpi
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/byedpi $(1)/etc/config/byedpi
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/etc/uci-defaults/40_byedpi $(1)/etc/uci-defaults/40_byedpi
endef
# /etc/config/byedpi is user-editable desired state -> preserve on upgrade.
define Package/byedpi/conffiles
/etc/config/byedpi
endef
$(eval $(call BuildPackage,byedpi))
-33
View File
@@ -1,33 +0,0 @@
#
# ByeDPI (ciadpi) desync SOCKS proxies (/etc/config/byedpi).
#
# Each `config instance` is one supervised `ciadpi` process bound to
# 127.0.0.1:<port>. Point a Shater egress at it:
#
# config egress 'bd'
# option name 'bd'
# option type 'byedpi'
# option port '1080' # must match an enabled instance's `port`
#
# then a rule with `option target 'egress:bd'` routes selected traffic through
# ciadpi, which desyncs it and connects DIRECTLY to the target (no tunnel).
#
# This shipped default is INERT (enabled='0'): installing the package opens no
# listener. Set enabled='1' and apply to bring the proxy up.
#
# This file is a conffile — your edits survive package upgrades.
#
config instance 'default'
option enabled '0'
option port '1080'
# Desync preset (documented ByeDPI example — general RKN/YouTube-busting):
# --disorder 1 : split the first segment at offset 1 and send the two
# parts in REVERSE order (TCP desync; defeats naive
# stream reassembly in the DPI).
# --auto=torst : auto mode — if the connection is reset (TCP RST), retry
# with the desync params instead of failing.
# --tlsrec 1+s : re-frame the TLS record boundary at the SNI offset +1,
# so the ClientHello SNI is split across TLS records and
# SNI-based DPI can't match the hostname.
option args '--disorder 1 --auto=torst --tlsrec 1+s'
-79
View File
@@ -1,79 +0,0 @@
#!/bin/sh /etc/rc.common
# /etc/init.d/byedpi — procd supervisor for ByeDPI (ciadpi) desync SOCKS proxies.
#
# One supervised `ciadpi` process per ENABLED `config instance` in
# /etc/config/byedpi. Each instance is a local SOCKS5 desync proxy bound to
# 127.0.0.1:<port>; a Shater egress of type='byedpi' with the matching `port`
# routes traffic to it (shaterd emits a SOCKS5 outbound to that port). ciadpi
# desyncs the connection and goes DIRECT to the target — no tunnel.
#
# Design:
# * INERT by default: the shipped instance has enabled='0', and config_foreach
# starts nothing unless an instance is explicitly enabled. Installing this
# package can never, by itself, open a listener or affect connectivity.
# * FOREGROUND: ciadpi stays in the foreground unless -D/--daemon is given (we
# never pass it), so procd supervises the real process. respawn on crash.
# * Instances are named after their UCI section, so `reload` diffs per-section
# and restarts only the instances whose config actually changed.
# * busybox ash only — no bashisms.
USE_PROCD=1
START=90 # before shater (START=99): the SOCKS egress should be up
STOP=11 # after shater (STOP=10) — higher STOP runs LATER on shutdown,
# so the desync proxy outlives the data plane it serves.
PROG=/usr/bin/ciadpi
CONF=byedpi
# Validate one `instance` section. Datatypes per openwrt-uci:
# enabled : bool (default 0 — inert)
# port : port (default 1080 — matches shater's byedpi egress default)
# args : free-form desync flag string (passed verbatim to ciadpi)
validate_instance_section() {
uci_load_validate "$CONF" instance "$1" "$2" \
'enabled:bool:0' \
'port:port:1080' \
'args:string:'
}
start_instance() {
# $1 = section name, $2 = validation return code
local cfg="$1"
[ "$2" = 0 ] || { echo "byedpi: validation failed for '$cfg'"; return 1; }
[ "$enabled" -eq 1 ] || return 0
# Never claim to run without the binary (half-removed/failed upgrade must
# degrade to "off", not to a phantom respawn loop).
[ -x "$PROG" ] || { echo "byedpi: $PROG missing, skipping '$cfg'"; return 1; }
procd_open_instance "$cfg"
# Bind loopback only: this proxy is reachable solely by the local engine.
procd_set_param command "$PROG" -i 127.0.0.1 -p "$port"
# Desync flag list. Unquoted on purpose: word-split $args into separate argv
# tokens (e.g. "--disorder 1 --auto=torst --tlsrec 1+s" -> 5 arguments).
# shellcheck disable=SC2086
[ -n "$args" ] && procd_append_param command $args
procd_set_param respawn
# Restart this instance when its config changes (checksum-watched).
procd_set_param file /etc/config/$CONF
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
start_service() {
config_load "$CONF"
config_foreach validate_instance_section instance start_instance
}
reload_service() {
start_service
}
service_triggers() {
# Reload (not full restart) when /etc/config/byedpi changes via a
# config.change event (LuCI Save&Apply / `reload_config`).
procd_add_reload_trigger "$CONF"
procd_add_validation validate_instance_section
}
@@ -1,12 +0,0 @@
#!/bin/sh
# /etc/uci-defaults/40_byedpi
#
# Idempotent first-boot setup for the byedpi package. Runs once on first boot
# (and via the default postinst on a live opkg/apk install); must exit 0 so it
# is cleared and not retried. Enabling the init is safe on a fresh box: the init
# and the shipped config are INERT (the 'default' instance has enabled='0'), so
# nothing listens until an instance is explicitly enabled.
[ -x /etc/init.d/byedpi ] && /etc/init.d/byedpi enable
exit 0
+7 -2
View File
@@ -24,8 +24,13 @@ LUCI_TITLE:=LuCI thin launcher for Shater (mini dashboard + panel handoff)
LUCI_DEPENDS:=+shater-core +rpcd
LUCI_PKGARCH:=all
PKG_VERSION:=0.2.0
PKG_RELEASE:=1
# Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
# SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
# full rationale — bug B4). The literals are the manual/offline fallback only.
# These MUST stay above the luci.mk include: luci.mk only defaults PKG_VERSION/
# PKG_RELEASE when they are still unset, and the i18n subpackages inherit them.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-3.0-or-later
@@ -6,11 +6,24 @@
'require ui';
// The admin panel (shaterd's own web server) listens on its own port. shaterd
// reports the configured port in status.panel_port (globals.panel_port, default
// reports the CONFIGURED port in status.panel_port (globals.panel_port, default
// 8088); the launcher builds the redirect from that, falling back to the default
// when status is unavailable. `panelPort` tracks the latest reported value and is
// refreshed from each status poll. If the panel is ever fronted by TLS, the http
// scheme below must follow.
//
// CONFIGURED IS NOT BOUND, and nothing on this page can turn one into the other:
//
// - shaterd starts the panel server in a goroutine and treats a bind error as
// log-and-continue ("panel server unavailable (daemon continues)",
// cmd/shaterd/main.go). After a restart that races the old listener, the
// daemon is healthy, `panel_port` still names the port, and nothing is
// listening on it.
// - SHATER_PANEL_ADDR can disable the panel outright (panelAddr()), while
// apply.Status still reports effectivePanelPort() — 8088 when unset.
//
// There is no "panel is listening" field to read, so this page must not imply
// one. The hint and the tooltip say the port is configured, not checked.
var DEFAULT_PANEL_PORT = 8088;
var panelPort = DEFAULT_PANEL_PORT;
@@ -26,15 +39,32 @@ var callMintToken = rpc.declare({
expect: { '': {} }
});
// led renders a small status dot: state is 'good' | 'warn' | 'bad'.
// LED palette. 'unknown' is an UNLIT socket — never amber and never green.
// Amber is this page's "degraded", and there is nothing to be degraded about
// when no reading has arrived; green on a missing reading is how the panel used
// to claim health it had not measured (see panel/src/planeState.ts, which says
// the same thing and is the wording this page is kept in step with).
var LED_COLORS = {
good: '#37b24d',
warn: '#f59f00',
bad: '#e03131',
unknown: '#6b6b6b'
};
// led renders a small status dot: state is 'good' | 'warn' | 'bad' | 'unknown'.
// The dot is decorative — every row states its condition in words beside it — so
// it is hidden from assistive tech rather than being the only carrier of meaning.
function led(state) {
var color = state === 'good' ? '#37b24d'
: state === 'warn' ? '#f59f00'
: '#e03131';
// Closed positive list. An unrecognised state resolves to UNKNOWN, never to
// green: an open default here is exactly how a state nobody thought about
// ends up painted healthy.
var color = Object.prototype.hasOwnProperty.call(LED_COLORS, state)
? LED_COLORS[state] : LED_COLORS.unknown;
var glow = (color === LED_COLORS.unknown) ? '' : ';box-shadow:0 0 5px ' + color;
return E('span', {
'aria-hidden': 'true',
'style': 'display:inline-block;width:.72em;height:.72em;border-radius:50%;' +
'margin-right:.6em;vertical-align:-.05em;background:' + color +
';box-shadow:0 0 5px ' + color
'margin-right:.6em;vertical-align:-.05em;background:' + color + glow
});
}
@@ -49,63 +79,439 @@ function row(state, label, value) {
]);
}
// statusRows maps the shaterd status object to LED rows. An empty object (the
// ubus call failed / daemon down) degrades every row to a "down" reading.
function statusRows(st) {
st = st || {};
var down = (st.running !== true);
// ---------------------------------------------------------------------------
// Pure state derivation — no DOM below this line until statusRows().
//
// statusReadout() maps a shaterd status object to a list of
// { state, label, value } descriptors. It is deliberately free of E()/DOM so it
// can be run against recorded fixtures offline; tests/status-readout.test.js
// does exactly that for the four cases this page has to tell apart — engine up,
// engine down with the daemon answering, no daemon at all, and a daemon that
// cannot read the configuration — plus the degenerate and old-daemon answers.
// The gate runs it as step [7/7].
// ---------------------------------------------------------------------------
// PLANES is the closed set of values a LIVE Applier.Status() can put in `plane`
// (shater/apply/apply.go: "full" | "hold" | "none"). It is the FALLBACK proof of
// daemon liveness for a shaterd that predates daemon_answered — see daemonState().
var PLANES = { full: true, hold: true, none: true };
// daemonState — is the shaterd PROCESS answering?
//
// 'up' — a live Applier produced this status.
// 'down' — proven not: `shaterd status` printed its OFFLINE STUB.
// 'unknown' — no usable answer, or an answer too old to say either way.
//
// THE FIELD, THEN THE FALLBACK.
//
// `daemon_answered` (cmd/shaterd/main.go, statusDaemonAnsweredKey) is the
// CONTRACT: true means a running daemon answered over the control socket and
// every other field is that daemon's own Applier.Status(); false means the object
// is the offline stub — the apply.Status zero value plus a UCI and kernel read —
// and is not a status report at all. It is a positive, closed, two-valued
// statement about where the object came from, which is exactly what this page
// needs and what it never had.
//
// The `plane` test below is what this page used BEFORE that field existed, and it
// is kept only for the non-atomic-update window: `apk upgrade` can leave a new
// luci-app-shater beside an old shaterd, and that shaterd emits no
// daemon_answered. It works because the stub leaves `plane` at "" while a live
// Status() always assigns one of the three words — a side effect, not a promise,
// which is precisely why it is now second and not first. If the two ever
// disagree, the explicit field wins: a stub that somehow carried a plane word
// must still read as "no daemon answered".
//
// `running` MUST NOT be used for this. It changed meaning on 2026-07-26
// (a8970b8ac): it used to be a hardcoded true, and is now the ENGINE's liveness
// (apply.go `Running: engineUp`). A daemon that is perfectly alive with a dead
// engine reports running=false — and this page used to answer that with a red
// "Daemon: not running", the advice "start the Shater service first", and a
// DISABLED button to the one place the config can be fixed. The holding plane
// keeps management reachable on purpose (shater/netplane/nft.go); LuCI was the
// only thing taking that guarantee away.
//
// Nor is "the ubus call returned" sufficient: the rpcd plugin shells out to
// `shaterd status`, which prints a parseable object on BOTH branches (the exit
// code is what differs, and command substitution in the plugin drops it).
//
// Everything else is unknown and is painted as unknown: {} from a failed ubus
// call, {"error":...} from the plugin (which is ALSO what a live-but-wedged
// daemon produces — cmdStatus prints nothing and exits 1 on a control-socket
// timeout, so "wedged" must not be reported as "dead"), and a status from a
// daemon predating both fields.
function daemonState(st) {
if (!st || typeof st !== 'object')
return 'unknown';
// Closed positive list on the contract field. Anything that is not exactly
// `true` or exactly `false` is not a verdict — it falls through rather than
// being coerced, because a truthy string is not an answer.
if (st.daemon_answered === true)
return 'up';
if (st.daemon_answered === false)
return 'down';
// Compatibility fallback: an old shaterd under a new LuCI.
if (typeof st.plane === 'string' && PLANES[st.plane] === true)
return 'up';
if (st.plane === '')
return 'down';
return 'unknown';
}
// configState — could the daemon READ the router's configuration when it took
// this status?
//
// 'ok' — config_readable=true: enabled, kill_switch and panel_port below
// are readings.
// 'failed' — config_readable=false: those three are ZERO VALUES AND MEAN
// NOTHING (apply.go Status.ConfigReadable). Rendering `enabled`
// false as "switched off" here is the documented defect: the read
// fails when /overlay is full or a `uci commit` was interrupted,
// which is exactly when the fail-closed plane has the LAN cut off —
// and telling the owner they switched it off themselves sends them
// to a settings page backed by the same unreadable file.
// 'unknown' — nobody said. Two ways to get here, and neither may be read as
// 'failed':
// * a daemon predating the field (non-atomic package update);
// * NO LIVE DAEMON AT ALL. The offline stub in cmdStatus reads
// UCI directly and never sets ConfigReadable, so it emits
// config_readable=false while its enabled/kill_switch/
// panel_port ARE genuine reads. Taking that at face value would
// put "the configuration could not be read" on screen for a
// perfectly readable configuration, and would throw away the
// only facts a dead-daemon status does carry. So the field is
// only consulted when a daemon actually answered.
function configState(st) {
if (daemonState(st) !== 'up')
return 'unknown';
if (st.config_readable === true)
return 'ok';
if (st.config_readable === false)
return 'failed';
return 'unknown';
}
// killSwitchSetting — the CONFIGURED kill-switch policy, or null when it cannot
// be known. It is one of the three config-sourced fields, so an unreadable
// configuration leaves it "" — and "" must never normalise to "closed", which is
// how a router nobody could read printed a green "fail-closed" row (the panel's
// killSwitchReadout carries the same note).
function killSwitchSetting(st) {
if (configState(st) === 'failed')
return null;
if (st.kill_switch === 'closed')
return 'closed';
if (st.kill_switch === 'open')
return 'open';
return null;
}
// panelTarget — where the launcher will point, and whether the port came from the
// router or from this file's built-in default. It never claims the panel answers
// there; see the DEFAULT_PANEL_PORT comment at the top.
function panelTarget(st) {
st = (st && typeof st === 'object') ? st : {};
// The config gate is belt-and-braces: an unreadable configuration already
// leaves panel_port at 0, which the > 0 test rejects. It is spelled out so the
// rule ("only meaningful with config_readable=true") is visible at the point
// of use rather than inferred from a zero value.
if (configState(st) !== 'failed' &&
typeof st.panel_port === 'number' && st.panel_port > 0)
return { port: st.panel_port, known: true };
return { port: DEFAULT_PANEL_PORT, known: false };
}
// engineState — is a sing-box instance actually started?
//
// The engine lives INSIDE the shaterd process, so a dead daemon is a dead engine
// and this page may say so without guessing. With the daemon up, `engine_running`
// is the self-documenting field and `running` carries the same fact by
// construction; either may prove a NEGATIVE, and a negative always wins. Neither
// asserting anything leaves 'unknown'.
function engineState(st) {
var d = daemonState(st);
if (d === 'down')
return 'down';
if (d === 'unknown')
return 'unknown';
if (st.running === false || st.engine_running === false)
return 'down';
if (st.running === true || st.engine_running === true)
return 'up';
return 'unknown';
}
function mk(state, label, value) {
return { state: state, label: label, value: value };
}
function statusReadout(st) {
st = (st && typeof st === 'object') ? st : {};
var dstate = daemonState(st);
var estate = engineState(st);
var cstate = configState(st);
var kswitch = killSwitchSetting(st);
// WHICH FIELDS SURVIVE A DEAD DAEMON. The offline stub's contract
// (cmd/shaterd/main.go statusDaemonAnsweredKey) names them: enabled, active,
// table, kill_switch and panel_port are read on the spot from UCI and the
// kernel and are real; running, engine_running, plane, traffic, hash, warnings
// and uptime "are placeholders, not measurements". Today the stub happens to
// leave all of them at their zero values, so reading them would look harmless
// — but "it happens to be zero" is the same side-effect reasoning that
// daemon_answered was added to replace. They are dropped on the stated
// contract instead, so a stub that ever grew a value cannot paint this page
// green.
var live = (dstate !== 'down');
var planeWord = live ? st.plane : '';
var traffic = (live && st.traffic && typeof st.traffic === 'object') ? st.traffic : {};
var rows = [];
// Daemon process itself.
rows.push(row(
down ? 'bad' : 'good',
_('Daemon (shaterd)'),
down ? _('not running') : _('running')
));
// --- The shaterd process itself. ------------------------------------------
// Its own liveness is not a field; it is whether a live daemon answered.
if (dstate === 'up')
rows.push(mk('good', _('Daemon (shaterd)'), _('responding')));
else if (dstate === 'down')
rows.push(mk('bad', _('Daemon (shaterd)'),
_('not responding — start the Shater service')));
else
rows.push(mk('unknown', _('Daemon (shaterd)'),
_('no usable answer — state unknown')));
// Desired state: globals.enabled in UCI.
rows.push(row(
st.enabled ? 'good' : 'warn',
_('Service enabled'),
st.enabled ? _('enabled') : _('inert (disabled)')
));
// --- The engine (sing-box) inside it. -------------------------------------
if (estate === 'up')
rows.push(mk('good', _('Engine (sing-box)'), _('running')));
else if (estate === 'down' && dstate === 'down')
rows.push(mk('bad', _('Engine (sing-box)'),
_('stopped — it runs inside shaterd, which is not answering')));
else if (estate === 'down')
rows.push(mk('bad', _('Engine (sing-box)'),
_('stopped — the daemon is up but no instance is running')));
else
rows.push(mk('unknown', _('Engine (sing-box)'), _('not reported')));
// Interception raised (ACTIVE_FLAG present after a successful enabled apply).
rows.push(row(
st.active ? 'good' : (st.enabled ? 'warn' : 'bad'),
_('Interception'),
st.active ? _('active') : _('inactive')
));
// --- Could the configuration be read at all? ------------------------------
// Placed ABOVE the three rows it qualifies, because it decides what they mean.
if (cstate === 'ok')
rows.push(mk('good', _('Configuration'), _('readable')));
else if (cstate === 'failed')
rows.push(mk('bad', _('Configuration'),
_('COULD NOT BE READ — the service/kill-switch/panel-port rows below say ' +
'"not known" because the daemon has no reading, NOT because anything is ' +
'switched off. If traffic is blocked that is the fail-closed plane; do not ' +
'turn anything off to fix it.') +
(typeof st.config_error === 'string' && st.config_error !== ''
? ' [' + st.config_error + ']' : '')));
else
rows.push(mk('unknown', _('Configuration'),
dstate === 'up'
? _('not reported by this daemon')
: _('not reported — no daemon answered')));
// Data plane: the `inet shater` nft table is loaded.
rows.push(row(
st.table ? 'good' : (st.enabled ? 'warn' : 'bad'),
_('Data plane'),
st.table ? _('nft table inet shater loaded') : _('not loaded')
));
// --- Desired state: globals.enabled in UCI. -------------------------------
// Read through cstate first: `enabled` is sourced from the configuration, so
// with config_readable=false its `false` is a zero value, not a choice.
if (cstate === 'failed')
rows.push(mk('unknown', _('Service enabled'),
_('not known — the configuration could not be read')));
else if (st.enabled === true)
rows.push(mk('good', _('Service enabled'), _('enabled')));
else if (st.enabled === false)
rows.push(mk('warn', _('Service enabled'), _('inert (disabled)')));
else
rows.push(mk('unknown', _('Service enabled'), _('not reported')));
// Kill-switch: fail-closed ("closed") is the safe posture; "open" leaks
// LAN→WAN if the engine goes down. Unknown (older daemon) degrades to warn.
var ks = st.kill_switch;
rows.push(row(
ks === 'closed' ? 'good' : 'warn',
_('Kill-switch'),
ks === 'closed' ? _('closed (fail-closed)')
: ks === 'open' ? _('open (leaky)')
: _('unknown')
));
// --- The ACTIVE_FLAG latch. -----------------------------------------------
// NOT a health signal, and this row must never read as one. apply.go states
// the contract: it is the "the service is meant to be running" latch that
// gates hotplug and cron; it is raised by a successful enabled apply and
// cleared only by teardown, so it STAYS UP while the engine is down and the
// fail-closed holding plane is blocking the LAN — deliberately, because
// clearing it would switch off the very cron reconcile that brings the engine
// back. This page used to render it as "Interception: active", in green, over
// a dead engine and a blocked LAN. The lamp now reports only whether the
// latch AGREES with globals.enabled.
//
// The latch itself is a filesystem fact and stays readable when the
// configuration does not; what an unreadable configuration takes away is the
// COMPARISON, since the lamp only reports whether the latch agrees with
// globals.enabled. So the words stay and the lamp goes out.
if (typeof st.active !== 'boolean')
rows.push(mk('unknown', _('Service latch'), _('not reported')));
else if (cstate === 'failed')
rows.push(mk('unknown', _('Service latch'), st.active
? _('raised — the service is meant to be running; whether that matches the ' +
'setting is not known, the configuration could not be read')
: _('cleared — the service is torn down; whether that matches the setting is ' +
'not known, the configuration could not be read')));
else if (st.active)
rows.push(mk(st.enabled === true ? 'good' : 'warn', _('Service latch'),
_('raised — the service is meant to be running')));
else
rows.push(mk(st.enabled === false ? 'good' : 'warn', _('Service latch'),
_('cleared — the service is torn down')));
// Running engine config hash ("" when the engine is not started).
rows.push(row(
st.hash ? 'good' : 'warn',
_('Config hash'),
st.hash ? st.hash : '—'
));
// --- What is loaded in the kernel right now. ------------------------------
// The row this page was missing. `plane` distinguishes the working ruleset
// from the FAIL-CLOSED HOLDING PLANE, which `table` cannot: `table` is a bare
// existence check, so a held LAN and a working one look identical through it.
switch (planeWord) {
case 'full':
// Deliberately mechanical wording. "full" means the table, the policy
// routing and the engine are all in place — it does NOT mean traffic is
// tunnelled. That claim belongs to the traffic verdict below.
rows.push(mk('good', _('Traffic plane'),
_('full — ruleset, routing and engine are all installed')));
break;
case 'hold':
rows.push(mk('bad', _('Traffic plane'),
_('hold — the engine is down and LAN→WAN forwarding is BLOCKED')));
break;
case 'none':
// The alarming wording is earned by a KNOWN fail-closed setting. With an
// unreadable configuration kswitch is null, and the row states the fact it
// has (nothing is installed) without the claim it does not.
rows.push(mk(kswitch === 'closed' ? 'bad' : 'warn', _('Traffic plane'),
kswitch === 'closed'
? _('none — nothing is installed; traffic reaches the WAN unprotected')
: _('none — no data plane is installed')));
break;
default:
rows.push(mk('unknown', _('Traffic plane'),
dstate === 'down'
? _('not reported — no daemon answered')
: _('not reported by this daemon')));
break;
}
// --- Where the traffic goes under the running config. ---------------------
// Separate from the plane on purpose: a router with one `default -> direct`
// rule has a fully installed plane and sends every packet out the plain WAN
// with its real address.
switch (traffic.verdict) {
case 'tunnel':
rows.push(mk('good', _('Traffic verdict'), _('tunnel — unmatched traffic is proxied')));
break;
case 'split':
rows.push(mk('warn', _('Traffic verdict'),
_('split — the default leaves directly; only matched rules are tunnelled')));
break;
case 'direct':
rows.push(mk('warn', _('Traffic verdict'),
_('direct — nothing is tunnelled; traffic leaves over the plain WAN')));
break;
case 'blocked':
rows.push(mk('warn', _('Traffic verdict'), _('blocked — unmatched traffic is dropped')));
break;
default:
rows.push(mk('unknown', _('Traffic verdict'), _('not reported')));
break;
}
// --- The nft table, as a bare presence check. -----------------------------
// Kept because the offline stub still reads it straight from the kernel, so
// it is the one plane fact available when no daemon answers. It says nothing
// about WHICH ruleset is loaded — that is the Traffic plane row.
//
// "Not loaded" is only the calm amber when the service is KNOWN to be switched
// off. With an unreadable configuration that is not known, and an absent
// firewall table with no explanation is the alarming side, not the calm one.
if (st.table === true)
rows.push(mk('good', _('nft table'), _('inet shater is loaded')));
else if (st.table === false)
rows.push(mk(cstate !== 'failed' && st.enabled === false ? 'warn' : 'bad',
_('nft table'), _('not loaded')));
else
rows.push(mk('unknown', _('nft table'), _('not reported')));
// --- Kill-switch: the configured policy, and whether it is in force. ------
// "closed" with no plane installed is a setting that is not in effect, which
// is worse news than "open" and must not share its amber lamp.
if (cstate === 'failed')
rows.push(mk('unknown', _('Kill-switch'),
_('not known — the configuration could not be read')));
else if (kswitch === 'closed' && planeWord === 'none')
rows.push(mk('bad', _('Kill-switch'),
_('closed, but NOT in effect — no data plane is installed')));
else if (kswitch === 'closed' && dstate === 'up')
rows.push(mk('good', _('Kill-switch'), _('closed (fail-closed)')));
else if (kswitch === 'closed')
rows.push(mk('warn', _('Kill-switch'),
_('configured closed; whether it is installed is not known')));
else if (kswitch === 'open')
rows.push(mk('warn', _('Kill-switch'), _('open (leaky)')));
else
rows.push(mk('unknown', _('Kill-switch'), _('not reported')));
// --- Running engine config hash ("" when the engine is not started). ------
if (live && typeof st.hash === 'string' && st.hash !== '')
rows.push(mk('good', _('Config hash'), st.hash));
else if (estate === 'down')
rows.push(mk('unknown', _('Config hash'), _('none — the engine is not started')));
else
rows.push(mk('unknown', _('Config hash'), _('not reported')));
// --- Where the "Open panel" button will point. ----------------------------
// The lamp is UNLIT even on a perfectly healthy router, and that is the point:
// the lamps on this page report a condition, and the condition an operator
// cares about here — "will the panel answer on that port" — is one nothing in
// this status measures. shaterd's panel server is started in a goroutine whose
// bind error is only logged, so a taken port leaves a healthy daemon reporting
// a port nothing is listening on; SHATER_PANEL_ADDR can switch the server off
// entirely and the port is still reported. A green lamp here would be a
// promise made out of a configuration value.
var target = panelTarget(st);
if (target.known)
rows.push(mk('unknown', _('Panel port'),
_('%PORT% — configured; nothing here reports whether the panel is listening on it')
.replace('%PORT%', String(target.port))));
else if (cstate === 'failed')
rows.push(mk('unknown', _('Panel port'),
_('not known — the configuration could not be read; the button will try the built-in default %PORT%')
.replace('%PORT%', String(target.port))));
else
rows.push(mk('unknown', _('Panel port'),
_('not reported — the button will try the built-in default %PORT%')
.replace('%PORT%', String(target.port))));
return rows;
}
// statusRows turns the readout into LED table rows.
function statusRows(st) {
return statusReadout(st).map(function(r) {
return row(r.state, r.label, r.value);
});
}
// panelTitle describes the button's target and what is known about it. It never
// promises the panel is up — only where the launcher will point.
function panelTitle(st) {
var dstate = daemonState(st);
var target = panelTarget(st);
// Second sentence, on every branch: the port is a configuration value that
// nothing on the wire confirms. A page that says "opens the panel" and lands
// on a connection refused has made a claim it had no field to support.
var port = target.known
? _('The port is the configured one; a status cannot say whether the panel is listening on it, so a connection error here means the port, not your token.')
: _('No panel port was reported, so this falls back to the built-in default and may well be the wrong port.');
if (dstate === 'up')
return _('Mint a session token and open the admin panel.') + ' ' + port;
if (dstate === 'down')
return _('shaterd is not answering, so this will probably fail — but the panel is served by the daemon, not by the engine, so it is worth trying: any failure is reported here.') + ' ' + port;
return _('The daemon state is not known. Try it — a failure is reported here rather than hidden.') + ' ' + port;
}
// panelHint is the grey line beside the button. Same rule as the tooltip: it
// states what the button DOES, and marks the port as configured rather than
// checked.
function panelHint(hostname, target) {
return (target.known
? _('opens http://%HOST%:%PORT%/ with a single-use session token — %PORT% is the configured port, not a checked one')
: _('opens http://%HOST%:%PORT%/ with a single-use session token — no port was reported, so %PORT% is this page\'s built-in default'))
.replace('%HOST%', hostname)
.replace(/%PORT%/g, String(target.port));
}
// handleOpenPanel mints a single-use token and hands it to the panel via the
// ARCHITECTURE §2 browser bridge: GET http://<router>:<port>/?t=<token>. The panel
// validates+consumes the token and drops a session cookie.
@@ -151,13 +557,21 @@ return view.extend({
handleSave: null,
handleReset: null,
// Exposed so the offline fixture harness (tests/status-readout.test.js) can
// exercise the state derivation without a browser, a router, or a DOM.
statusReadout: statusReadout,
daemonState: daemonState,
engineState: engineState,
configState: configState,
panelTarget: panelTarget,
panelTitle: panelTitle,
panelHint: panelHint,
load: function() {
return L.resolveDefault(callStatus(), {});
},
render: function(st) {
var self = this;
var table = E('table', { 'class': 'table' }, statusRows(st));
var openBtn = E('button', {
@@ -165,34 +579,37 @@ return view.extend({
'click': ui.createHandlerFn(this, handleOpenPanel)
}, [ _('Open panel') ]);
function hintText() {
return _('opens http://%s:%d/ with a single-use session token')
.format(window.location.hostname, panelPort);
}
var hint = E('span', {
'style': 'margin-left:1em;color:#888;font-size:90%'
}, hintText());
}, panelHint(window.location.hostname, panelTarget(st)));
// Reflect daemon reachability on the button up front, then keep the whole
// dashboard live. Also track the panel port reported in status so the
// launcher redirect and hint follow globals.panel_port.
// Track the panel port reported in status so the launcher redirect and the
// hint follow globals.panel_port.
//
// THE BUTTON IS NEVER DISABLED. It used to be locked whenever
// `running !== true`, which after a8970b8ac means "the engine is down" —
// precisely the situation the panel exists to get you out of, and one in
// which the daemon and its web server are still up and still minting
// tokens (cmd/shaterd/main.go starts the panel server independently of the
// engine). Locking it on a guess is the failure; a mint that fails already
// reports itself through ui.addNotification, which is the recoverable
// direction for an unknown state.
function reflect(state) {
state = state || {};
panelPort = state.panel_port || DEFAULT_PANEL_PORT;
hint.textContent = hintText();
var down = (state.running !== true);
openBtn.disabled = down;
openBtn.title = down
? _('shaterd is not running — start the Shater service first')
: _('Mint a session token and open the admin panel');
state = (state && typeof state === 'object') ? state : {};
// One source for the port: the same panelTarget() the "Panel port" row
// renders, so the row, the hint, the tooltip and the redirect cannot
// disagree about where the button goes.
var target = panelTarget(state);
panelPort = target.port;
hint.textContent = panelHint(window.location.hostname, target);
openBtn.title = panelTitle(state);
}
reflect(st || {});
reflect(st);
poll.add(function() {
return L.resolveDefault(callStatus(), {}).then(function(s) {
dom.content(table, statusRows(s));
reflect(s || {});
reflect(s);
});
}, 5);
@@ -209,7 +626,7 @@ return view.extend({
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Admin panel')),
E('p', { 'class': 'cbi-value-description' },
_('The rich admin panel is served by shaterd on its own port. LuCI mints a short-lived, single-use token for your browser — the panel has no separate login.')),
_('The rich admin panel is served by shaterd on its own port — by the daemon, not by the engine, so it stays reachable while the engine is down. LuCI mints a short-lived, single-use token for your browser; the panel has no separate login.')),
E('div', {}, [ openBtn, hint ])
])
]);
@@ -4,9 +4,41 @@
# Registers the ubus object "shater" (object name == this file's name) with two
# read-side methods the thin LuCI launcher calls over ubus:
#
# status -> passthrough of `shaterd status` ({running,enabled,active,table,hash})
# status -> passthrough of `shaterd status`
# mint_token -> passthrough of `shaterd mint-token` ({"token":"..."} | {"error":"..."})
#
# The status object is whatever apply.Status marshals (shater/apply/apply.go is the
# only definition; this script never parses or reshapes it), plus the one field
# `shaterd status` splices in itself. As of 2026-07-27 that is:
#
# daemon_answered, running, engine_running, enabled, active, table, plane,
# traffic, hash, kill_switch, panel_port, config_readable, config_error,
# can_rollback, warnings, started_unix, uptime_seconds
#
# Three of those are load-bearing for the caller and easy to misread:
#
# daemon_answered — WHERE THE OBJECT CAME FROM, and the only field that says so by
# contract (cmd/shaterd/main.go, statusDaemonAnsweredKey). true: a running
# daemon answered over the control socket. false: this is the OFFLINE STUB —
# the apply.Status zero value plus a UCI and kernel read — and the fields only
# a live daemon can know (running/engine_running/plane/traffic/hash/warnings/
# uptime) are placeholders. dashboard.js keys "daemon: down" off this.
# running / engine_running — the ENGINE's liveness, not the daemon's. `running`
# was a hardcoded true until a8970b8ac (2026-07-26) and is now `engineUp`, so
# a healthy daemon with a dead engine reports running=false. The daemon's own
# liveness is not a field of apply.Status at all.
# config_readable — whether the daemon could READ the configuration. When false,
# enabled/kill_switch/panel_port are zero values and mean NOTHING. Note the
# stub above never sets it, so its false is not a failed read either — a
# consumer must check daemon_answered first. dashboard.js does.
#
# EXIT CODE: `shaterd status` now exits 1 when no daemon answered, and this script
# deliberately ignores that — command substitution below keeps only stdout. The stub
# is still worth relaying (it carries the real UCI and nft-table readings, and it
# says what it is), and dropping it would blank the LuCI page instead of degrading
# it. The wedged case is the one where the exit code matters, and it reports itself
# by printing NOTHING: the case below then emits the error object.
#
# Why shell out to shaterd instead of talking to /var/run/shaterd.ctl directly:
# a reliable AF_UNIX client is NOT guaranteed on stock OpenWrt (busybox `nc` is
# usually built without `-U`; socat/ucode-socket aren't in the base image). shaterd
@@ -0,0 +1,521 @@
#!/usr/bin/env node
/*
* Offline harness for the dashboard's state derivation.
*
* Run: node openwrt/luci-app-shater/tests/status-readout.test.js
* (the gate runs it as step [7/7]; see scripts/run-tests.sh NONGO_TEST_RUNNERS)
*
* Why this exists: the LuCI page is the ONE screen an operator reaches when the
* engine is down and the fail-closed holding plane is blocking the LAN. What it
* says there is a claim about the router's behaviour, and until now nothing
* checked those claims. There is no browser and no router in this loop — the view
* exposes statusReadout/daemonState/engineState/configState/panelTarget/
* panelTitle/panelHint as plain functions, and this file feeds them recorded
* status objects.
*
* The fixtures are not invented. Each is what the wire actually carries:
*
* ENGINE_UP — apply.Status() from a live daemon with a started engine,
* marked daemon_answered:true by the CLI.
* ENGINE_DOWN — the same daemon with a dead engine; the holding plane is
* installed and the LAN is blocked.
* DAEMON_DOWN — the OFFLINE STUB `shaterd status` prints when the daemon is
* unreachable (cmd/shaterd/main.go cmdStatus): apply.Status zero
* value + a UCI and kernel read, marked daemon_answered:false.
* CONFIG_BAD — a LIVE daemon that could not read the configuration
* (config_readable:false): enabled/kill_switch/panel_port are
* zero values that mean NOTHING.
* NO_ANSWER — {} , what L.resolveDefault hands render() when the ubus call
* fails outright.
* PLUGIN_ERROR — {"error":...} from the rpcd plugin, which is ALSO what a
* live-but-wedged daemon produces.
* OLD_* — a shaterd predating daemon_answered/config_readable, under a
* new luci-app-shater. Packages do not update atomically, so
* this combination WILL exist in the field.
* LEGACY — a daemon predating plane/engine_running as well.
*
* Mutation check (each has been run; the named assertions in brackets fail):
* - delete the daemon_answered branches from daemonState() [H/*, C/*]
* - delete the `plane` fallback from daemonState() [OLD/*]
* - read config_readable without the daemon gate [C/config-*]
* - paint config_readable:false rows from their zero values [K/*]
* Reproduce by copying dashboard.js, breaking the copy, and pointing
* DASHBOARD_JS at it.
*/
'use strict';
var fs = require('fs');
var path = require('path');
// --- Load the view module with LuCI's globals stubbed. ----------------------
// The view file is a module body LuCI wraps in a function, so it ends in a
// top-level `return` and cannot be require()d. Wrapping it in new Function is the
// same thing LuCI's loader does. The 'require x' lines are bare string literals
// and evaluate to nothing.
// DASHBOARD_JS points the harness at a copy of the view. It exists so the
// mutation check is repeatable: copy dashboard.js, reintroduce the defect in the
// copy, run this file against it, and watch the named assertions fail. A test
// that cannot be shown to fail on the broken code is decoration.
var SRC = process.env.DASHBOARD_JS || path.join(__dirname, '..', 'htdocs',
'luci-static', 'resources', 'view', 'shater', 'dashboard.js');
function loadView() {
var src = fs.readFileSync(SRC, 'utf8');
var factory = new Function('view', 'dom', 'poll', 'rpc', 'ui', 'E', '_', 'L',
'window', src);
return factory(
{ extend: function(o) { return o; } }, // view
{ content: function() {} }, // dom
{ add: function() {} }, // poll
{ declare: function() { return function() {}; } }, // rpc
{ createHandlerFn: function() { return function() {}; }, addNotification: function() {} },
function() { return {}; }, // E
function(s) { return s; }, // _ (identity)
{ resolveDefault: function(p, d) { return Promise.resolve(d); } },
{ location: { hostname: 'router' }, open: function() { return null; } }
);
}
var page = loadView();
// --- Fixtures ---------------------------------------------------------------
var ENGINE_UP = {
daemon_answered: true,
running: true, engine_running: true, enabled: true, active: true, table: true,
config_readable: true, config_error: '',
plane: 'full', traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 },
hash: 'a1b2c3d4', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
var ENGINE_DOWN = {
daemon_answered: true,
running: false, engine_running: false, enabled: true, active: true, table: true,
config_readable: true, config_error: '',
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
// Exactly what Status.JSON() + markStatusOrigin(false) emit for the cmdStatus
// offline stub. NOTE config_readable:false: the stub reads UCI directly (so
// enabled/kill_switch/panel_port below ARE real readings) but never sets the
// field, so it ships the zero value. Anything keying on config_readable without
// first checking that a daemon answered will call this configuration unreadable.
var DAEMON_DOWN = {
daemon_answered: false,
running: false, engine_running: false, enabled: true, active: true, table: true,
config_readable: false, config_error: '',
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
warnings: null, started_unix: 0, uptime_seconds: 0
};
// A LIVE daemon whose readConfig() failed: /overlay full, or a `uci commit`
// interrupted. apply.go then leaves enabled=false, kill_switch="" and
// panel_port=0 as PLACEHOLDERS and raises config_readable=false + config_error.
// The engine cannot be generated, so the fail-closed holding plane is what is
// installed — which is the whole trap: the LAN is cut off and the three
// placeholders spell out a calm "the owner switched it off".
var CONFIG_BAD = {
daemon_answered: true,
running: false, engine_running: false, enabled: false, active: true, table: true,
config_readable: false,
config_error: 'uci: read /etc/config/shater: no space left on device',
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: '', panel_port: 0, can_rollback: false,
warnings: [], started_unix: 1753500000, uptime_seconds: 90
};
var NO_ANSWER = {};
var PLUGIN_ERROR = { error: 'shaterd unavailable' };
// Non-atomic package update: new luci-app-shater, old shaterd. No
// daemon_answered, no config_readable — the `plane` fallback is all there is.
var OLD_DAEMON_UP = {
running: false, engine_running: false, enabled: true, active: true, table: true,
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
var OLD_DAEMON_DOWN = {
running: false, engine_running: false, enabled: true, active: true, table: true,
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
warnings: null, started_unix: 0, uptime_seconds: 0
};
// Older still: no plane, no engine_running either.
var LEGACY = {
running: true, enabled: true, active: true, table: true, hash: 'deadbeef',
kill_switch: 'closed', panel_port: 8088
};
// A configured panel port that is NOT the built-in default, so "reported" and
// "fell back" are distinguishable in the readout and in the button hint.
var CUSTOM_PORT = Object.assign({}, ENGINE_UP, { panel_port: 9090 });
// --- Assertions -------------------------------------------------------------
var failures = [];
function check(name, cond, detail) {
if (cond) return;
failures.push(name + (detail ? ': ' + detail : ''));
}
// readout indexes the rows by label. A row that is NOT emitted must fail by name
// rather than by throwing on `undefined.state`: a harness that dies mid-run stops
// reporting the assertions after it, which is the silent-skip failure this
// project has been bitten by. Missing rows come back as a loud sentinel instead.
var MISSING = { state: '<row absent>', value: '<row absent>', missing: true };
function readout(st) {
var out = {};
page.statusReadout(st).forEach(function(r) { out[r.label] = r; });
return new Proxy(out, {
get: function(t, k) {
if (typeof k !== 'string' || k in t) return t[k];
return MISSING;
},
has: function(t, k) { return k in t; }
});
}
function show(title, st) {
process.stdout.write('\n=== ' + title + ' ===\n');
process.stdout.write(' daemon=' + page.daemonState(st) +
' engine=' + page.engineState(st) +
' config=' + page.configState(st) + '\n');
page.statusReadout(st).forEach(function(r) {
process.stdout.write(' [' + r.state.padEnd(7) + '] ' +
r.label.padEnd(18) + ' ' + r.value + '\n');
});
}
function lamps(st) {
return page.statusReadout(st).map(function(r) { return r.state; });
}
// 1. Live daemon, engine up.
show('A. daemon alive, engine running', ENGINE_UP);
check('A/daemon', page.daemonState(ENGINE_UP) === 'up');
check('A/engine', page.engineState(ENGINE_UP) === 'up');
check('A/config', page.configState(ENGINE_UP) === 'ok');
check('A/no-red', lamps(ENGINE_UP).indexOf('bad') === -1,
'a fully healthy router must show no red lamp');
// Closed positive list of the rows that a fully-reporting healthy router MUST
// have a verdict for. It replaces a blanket "no unknown anywhere", which stopped
// being the right assertion when the Panel port row was added: that row is unlit
// even here, on purpose, because nothing in a status measures whether the panel
// is listening. Naming the rows says which readings are owed, and a row that
// silently disappears fails here rather than passing as "no unknowns".
var MUST_BE_KNOWN = ['Daemon (shaterd)', 'Engine (sing-box)', 'Configuration',
'Service enabled', 'Service latch', 'Traffic plane', 'Traffic verdict',
'nft table', 'Kill-switch', 'Config hash'];
var a = readout(ENGINE_UP);
MUST_BE_KNOWN.forEach(function(label) {
check('A/known:' + label, a[label].state !== 'unknown' && !a[label].missing,
'every field is present, so this row may not read as unknown (got ' +
a[label].state + ')');
});
check('A/panel-port-unlit', a['Panel port'].state === 'unknown',
'the panel port is a CONFIGURED value; a lit lamp would promise a listener ' +
'that nothing in this status measures');
// 2. THE DEFECT. Live daemon, dead engine, LAN held.
show('B. daemon alive, engine DOWN, holding plane', ENGINE_DOWN);
check('B/daemon-up', page.daemonState(ENGINE_DOWN) === 'up',
'the daemon is answering; calling it dead is the bug being fixed');
check('B/engine-down', page.engineState(ENGINE_DOWN) === 'down');
var b = readout(ENGINE_DOWN);
check('B/daemon-row-green', b['Daemon (shaterd)'].state === 'good',
'got ' + b['Daemon (shaterd)'].state + ' / ' + b['Daemon (shaterd)'].value);
check('B/daemon-row-no-start-advice',
b['Daemon (shaterd)'].value.indexOf('start') === -1,
'must not tell the operator to start a service that is already running');
check('B/plane-red', b['Traffic plane'].state === 'bad');
check('B/plane-says-blocked', /BLOCKED/.test(b['Traffic plane'].value));
check('B/latch-not-called-interception',
!b['Service latch'].missing && b['Interception'].missing === true,
'`active` is the run latch, not a "we are proxying" signal — apply.go: ' +
'"Never render it as \'we are proxying\'"');
check('B/latch-value-is-a-latch', /meant to be running/.test(b['Service latch'].value),
'the latch row must state the latch, not claim traffic is being proxied');
check('B/latch-not-active-word', !/^active$/.test(b['Service latch'].value));
check('B/verdict-unknown', b['Traffic verdict'].state === 'unknown',
'no verdict was published; it must not be painted as tunnel');
check('B/some-red', lamps(ENGINE_DOWN).indexOf('bad') !== -1,
'a blocked LAN must not be an all-green screen');
// The button is a property of render(), not of the pure readout, so it is
// guarded at the source level: nothing may ever set `disabled` on the launcher.
// Locking the way into the panel while the engine is down is the defect this
// whole file exists for, and it must not come back by a different route.
check('B/button-never-disabled',
!/openBtn\s*\.\s*disabled/.test(fs.readFileSync(SRC, 'utf8')),
'dashboard.js assigns openBtn.disabled — the launcher must never be locked');
// 3. Daemon not answering at all — the offline stub, marked daemon_answered:false.
show('C. daemon NOT running (offline stub)', DAEMON_DOWN);
check('C/daemon-down', page.daemonState(DAEMON_DOWN) === 'down',
'daemon_answered:false is the contract; got ' + page.daemonState(DAEMON_DOWN));
check('C/engine-down', page.engineState(DAEMON_DOWN) === 'down');
var c = readout(DAEMON_DOWN);
check('C/daemon-row-red', c['Daemon (shaterd)'].state === 'bad');
check('C/plane-unknown', c['Traffic plane'].state === 'unknown',
'the stub reports no plane; got ' + c['Traffic plane'].value);
check('C/kill-switch-not-green', c['Kill-switch'].state !== 'good',
'"closed" from a dead daemon proves nothing is installed to enforce it');
check('C/distinct-from-B',
c['Daemon (shaterd)'].value !== b['Daemon (shaterd)'].value,
'engine-down and daemon-down must not render identically');
// The stub ships config_readable:false without ever having tried a config read
// (cmd/shaterd/main.go never sets it), while its enabled/kill_switch/panel_port
// ARE genuine UCI reads. Believing the field here would put a red "COULD NOT BE
// READ" on screen for a perfectly readable file and throw away the only facts a
// dead-daemon status carries.
check('C/config-not-claimed-unreadable', page.configState(DAEMON_DOWN) === 'unknown',
'config_readable from the offline stub is a zero value, not a reading; got ' +
page.configState(DAEMON_DOWN));
check('C/config-row-unknown', c['Configuration'].state === 'unknown',
'got ' + c['Configuration'].state + ' / ' + c['Configuration'].value);
check('C/config-row-blames-the-daemon', /no daemon answered/.test(c['Configuration'].value));
check('C/enabled-still-read', c['Service enabled'].state === 'good',
'the stub read globals.enabled from UCI; that reading must survive');
check('C/panel-port-still-read', page.panelTarget(DAEMON_DOWN).known === true,
'the stub read globals.panel_port from UCI; the button must use it');
// 4/5/6/7. Degenerate answers must degrade to unknown, never to healthy.
[['D. ubus call failed ({})', NO_ANSWER],
['E. rpcd plugin error / wedged daemon', PLUGIN_ERROR],
['F. legacy daemon (no plane, no engine_running)', LEGACY]].forEach(function(p) {
show(p[0], p[1]);
var st = p[1];
check(p[0] + '/daemon-unknown', page.daemonState(st) === 'unknown');
check(p[0] + '/engine-unknown', page.engineState(st) === 'unknown');
var r = readout(st);
check(p[0] + '/plane-unknown', r['Traffic plane'].state === 'unknown');
check(p[0] + '/daemon-row-unknown', r['Daemon (shaterd)'].state === 'unknown');
check(p[0] + '/config-unknown', r['Configuration'].state === 'unknown');
check(p[0] + '/no-false-green-plane', r['Traffic plane'].state !== 'good');
});
// The legacy fixture additionally must not crash and must not lose the fields it
// DOES carry — a non-atomic package update must degrade, not black out.
var f = readout(LEGACY);
check('F/enabled-still-read', f['Service enabled'].state === 'good');
check('F/hash-still-read', f['Config hash'].value === 'deadbeef');
check('F/table-still-read', f['nft table'].state === 'good');
// --- G. the compatibility fallback: an OLD shaterd under this LuCI ----------
// `apk upgrade shaterd shater-core luci-app-shater` is not atomic, so a new page
// will meet a daemon that emits no daemon_answered at all. `plane` must keep
// carrying the verdict on its own.
show('G1. OLD shaterd, alive (plane fallback)', OLD_DAEMON_UP);
check('G1/daemon-up', page.daemonState(OLD_DAEMON_UP) === 'up',
'plane:"hold" is a word only a live Applier.Status() writes; got ' +
page.daemonState(OLD_DAEMON_UP));
var g1 = readout(OLD_DAEMON_UP);
check('G1/daemon-row-green', g1['Daemon (shaterd)'].state === 'good');
check('G1/plane-red', g1['Traffic plane'].state === 'bad',
'the holding plane must still be reported as blocking');
check('G1/config-unknown', g1['Configuration'].state === 'unknown',
'an old daemon says nothing about config_readable; absence is not failure');
check('G1/enabled-still-read', g1['Service enabled'].state === 'good',
'a missing config_readable must NOT suppress an enabled that means what it says');
show('G2. OLD shaterd, dead (plane:"" fallback)', OLD_DAEMON_DOWN);
check('G2/daemon-down', page.daemonState(OLD_DAEMON_DOWN) === 'down',
'plane:"" is the pre-daemon_answered stub signature; got ' +
page.daemonState(OLD_DAEMON_DOWN));
check('G2/daemon-row-red', readout(OLD_DAEMON_DOWN)['Daemon (shaterd)'].state === 'bad');
// --- H. the field beats the side effect -------------------------------------
// If the two signals ever disagree, the explicit contract wins. A stub that
// somehow carried a plane word (a future change to cmdStatus, a merged object)
// must still read as "no daemon answered" — that is the entire reason the field
// was added, and keying off `plane` first would quietly restore the old defect.
var STUB_WITH_PLANE = Object.assign({}, DAEMON_DOWN, { plane: 'full' });
show('H. daemon_answered:false but plane:"full"', STUB_WITH_PLANE);
check('H/field-wins', page.daemonState(STUB_WITH_PLANE) === 'down',
'daemon_answered:false must outrank a plane word; got ' +
page.daemonState(STUB_WITH_PLANE));
var h = readout(STUB_WITH_PLANE);
check('H/daemon-row-red', h['Daemon (shaterd)'].state === 'bad');
// And the fields the stub CANNOT know are dropped rather than rendered. The
// stub's contract lists plane/traffic/hash among "placeholders, not
// measurements"; a green "full — ruleset, routing and engine are all installed"
// over a daemon that never answered is the original defect in a new costume.
check('H/plane-row-unknown', h['Traffic plane'].state === 'unknown',
'a plane word from an object marked daemon_answered:false is a placeholder; ' +
'got ' + h['Traffic plane'].state + ' / ' + h['Traffic plane'].value);
var STUB_WITH_EVERYTHING = Object.assign({}, DAEMON_DOWN, {
plane: 'full', hash: 'cafebabe',
traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 }
});
var he = readout(STUB_WITH_EVERYTHING);
check('H/verdict-row-unknown', he['Traffic verdict'].state === 'unknown',
'got ' + he['Traffic verdict'].value);
check('H/hash-row-not-shown', he['Config hash'].value.indexOf('cafebabe') === -1,
'a hash nobody measured must not be printed as the running config: ' +
he['Config hash'].value);
check('H/no-green-plane-anywhere', lamps(STUB_WITH_EVERYTHING).filter(function(s, i) {
return s === 'good' && ['Traffic plane', 'Traffic verdict', 'Config hash']
.indexOf(page.statusReadout(STUB_WITH_EVERYTHING)[i].label) !== -1;
}).length === 0,
'no engine-side row may be green while daemon_answered is false');
// Control for the three above: the SAME values from a daemon that did answer are
// rendered in full. Without this, "dropped" would also pass on a page that
// dropped them unconditionally.
var ANSWERED_WITH_EVERYTHING = Object.assign({}, STUB_WITH_EVERYTHING,
{ daemon_answered: true });
var ha = readout(ANSWERED_WITH_EVERYTHING);
check('H/control-plane-shown', ha['Traffic plane'].state === 'good');
check('H/control-verdict-shown', ha['Traffic verdict'].state === 'good');
check('H/control-hash-shown', ha['Config hash'].value === 'cafebabe');
// A non-boolean daemon_answered is not a verdict and must not be coerced: it
// falls through to the fallback, which here proves the daemon live by itself.
var WEIRD_FLAG = Object.assign({}, ENGINE_UP, { daemon_answered: 'true' });
check('H/non-boolean-not-coerced', page.daemonState(WEIRD_FLAG) === 'up',
'a string is not the field; the plane fallback must decide instead');
var WEIRD_FLAG_NO_PLANE = { daemon_answered: 'yes' };
check('H/non-boolean-alone-is-unknown',
page.daemonState(WEIRD_FLAG_NO_PLANE) === 'unknown',
'with nothing else to go on, a non-boolean flag is not an answer');
// --- I/J. unrecognised plane words ------------------------------------------
// Closed positive list, recoverable default — a word this build does not know
// lands in unknown, never in the last-listed branch.
var FUTURE_PLANE = Object.assign({}, ENGINE_UP, { plane: 'partial' });
check('I/daemon-still-up', page.daemonState(FUTURE_PLANE) === 'up',
'the daemon SAID it answered; an unknown plane word does not unsay it');
check('I/plane-row-unknown', readout(FUTURE_PLANE)['Traffic plane'].state === 'unknown');
var OLD_FUTURE_PLANE = Object.assign({}, OLD_DAEMON_UP, { plane: 'partial' });
check('J/fallback-list-is-closed', page.daemonState(OLD_FUTURE_PLANE) === 'unknown',
'with no contract field, an unrecognised plane value proves nothing');
check('J/plane-row-unknown', readout(OLD_FUTURE_PLANE)['Traffic plane'].state === 'unknown');
// --- K. THE CONFIGURATION CANNOT BE READ ------------------------------------
// A live daemon with config_readable:false. enabled/kill_switch/panel_port are
// zero values; rendering them as readings is the inverted lie the panel already
// fixed on its side (planeState.ts protectionState / killSwitchReadout).
show('K. daemon alive, CONFIGURATION UNREADABLE', CONFIG_BAD);
check('K/daemon-up', page.daemonState(CONFIG_BAD) === 'up');
check('K/config-failed', page.configState(CONFIG_BAD) === 'failed');
var k = readout(CONFIG_BAD);
check('K/config-row-red', k['Configuration'].state === 'bad',
'got ' + k['Configuration'].state);
check('K/config-row-carries-the-error',
k['Configuration'].value.indexOf('no space left on device') !== -1,
'the daemon said WHY; dropping it makes the operator guess');
check('K/config-row-says-dont-switch-off', /do not turn anything off/i.test(k['Configuration'].value),
'the instinct here is to switch things off, and that is the one action that ' +
'makes it worse');
check('K/enabled-not-called-disabled', k['Service enabled'].state === 'unknown',
'enabled=false with config_readable=false is a zero value, not the owner\'s ' +
'choice; got ' + k['Service enabled'].state + ' / ' + k['Service enabled'].value);
check('K/enabled-says-not-known', /not known/.test(k['Service enabled'].value));
check('K/kill-switch-not-green', k['Kill-switch'].state === 'unknown',
'kill_switch:"" must not normalise to a green "fail-closed"; got ' +
k['Kill-switch'].state + ' / ' + k['Kill-switch'].value);
check('K/kill-switch-says-not-known', /not known/.test(k['Kill-switch'].value));
check('K/latch-lamp-out', k['Service latch'].state === 'unknown',
'the latch is readable but the comparison against globals.enabled is not');
check('K/latch-still-states-the-latch', /raised/.test(k['Service latch'].value),
'the fact survives even though the verdict does not');
check('K/plane-red', k['Traffic plane'].state === 'bad',
'the holding plane is what is installed, and it is blocking');
check('K/panel-port-unknown', page.panelTarget(CONFIG_BAD).known === false,
'panel_port:0 is a placeholder, not a port');
check('K/panel-port-falls-back-to-default', page.panelTarget(CONFIG_BAD).port === 8088);
check('K/panel-port-row-says-so', /could not be read/.test(k['Panel port'].value),
'got ' + k['Panel port'].value);
// The same fixture WITHOUT a table loaded: "not loaded" may only be the calm
// amber when the service is KNOWN to be off. With no readable configuration it
// is not known, so the alarming lamp is the correct one.
var CONFIG_BAD_NO_TABLE = Object.assign({}, CONFIG_BAD, { table: false });
check('K/table-absent-is-red',
readout(CONFIG_BAD_NO_TABLE)['nft table'].state === 'bad',
'enabled=false is a placeholder here and must not soften an absent firewall ' +
'table to amber');
// Control for the line above: with a READABLE configuration that says disabled,
// the same absent table IS the calm amber. Without this, "red" proves nothing.
var DISABLED_NO_TABLE = Object.assign({}, ENGINE_UP,
{ enabled: false, table: false, plane: 'none', kill_switch: 'open' });
check('K/table-absent-is-amber-when-really-disabled',
readout(DISABLED_NO_TABLE)['nft table'].state === 'warn',
'a service the owner switched off has no table on purpose');
// A plane:"none" whose kill-switch setting is unknown must not carry the
// "traffic reaches the WAN unprotected" claim — that sentence is earned by a
// KNOWN fail-closed setting.
var CONFIG_BAD_NO_PLANE = Object.assign({}, CONFIG_BAD, { plane: 'none' });
check('K/none-plane-claims-nothing-about-protection',
!/unprotected/.test(readout(CONFIG_BAD_NO_PLANE)['Traffic plane'].value),
'with an unreadable configuration the kill-switch setting is not known, so ' +
'"unprotected" is a claim this page cannot make');
// Control: with a readable configuration that says closed, the claim IS made.
var CLOSED_NO_PLANE = Object.assign({}, ENGINE_UP,
{ plane: 'none', kill_switch: 'closed' });
check('K/none-plane-does-claim-it-when-known',
/unprotected/.test(readout(CLOSED_NO_PLANE)['Traffic plane'].value) &&
readout(CLOSED_NO_PLANE)['Traffic plane'].state === 'bad',
'a known fail-closed setting with nothing installed IS the dangerous state');
// --- L. the launcher: a configured port is not a listening one ---------------
// shaterd starts the panel server in a goroutine and only LOGS a bind failure
// (cmd/shaterd/main.go: "panel server unavailable (daemon continues)"), and
// SHATER_PANEL_ADDR can disable it outright while apply.Status keeps reporting a
// port. Nothing on the wire says the panel is listening, so nothing on this page
// may say it either.
check('L/custom-port-used', page.panelTarget(CUSTOM_PORT).port === 9090 &&
page.panelTarget(CUSTOM_PORT).known === true);
check('L/no-answer-falls-back', page.panelTarget(NO_ANSWER).port === 8088 &&
page.panelTarget(NO_ANSWER).known === false);
check('L/garbage-falls-back', page.panelTarget(null).port === 8088);
var hintKnown = page.panelHint('router', page.panelTarget(CUSTOM_PORT));
process.stdout.write('\n=== L. launcher hint / tooltip ===\n');
process.stdout.write(' hint(known) ' + hintKnown + '\n');
check('L/hint-names-the-url', hintKnown.indexOf('http://router:9090/') !== -1,
'got ' + hintKnown);
check('L/hint-marks-the-port-unchecked', /not a checked one/.test(hintKnown),
'the hint must not read as "the panel is there": ' + hintKnown);
check('L/hint-has-no-placeholders-left', hintKnown.indexOf('%') === -1,
'unsubstituted placeholder in the hint: ' + hintKnown);
var hintUnknown = page.panelHint('router', page.panelTarget(CONFIG_BAD));
process.stdout.write(' hint(unknown) ' + hintUnknown + '\n');
check('L/hint-admits-the-guess', /built-in default/.test(hintUnknown),
'a port nobody reported must be named as this page\'s guess: ' + hintUnknown);
check('L/hint-unknown-has-no-placeholders-left', hintUnknown.indexOf('%') === -1,
'unsubstituted placeholder in the hint: ' + hintUnknown);
// Every tooltip, on every state, carries the port caveat. Closed list of the
// states, so a new branch added without the caveat fails here.
[['up', ENGINE_UP], ['engine-down', ENGINE_DOWN], ['daemon-down', DAEMON_DOWN],
['config-bad', CONFIG_BAD], ['no-answer', NO_ANSWER]].forEach(function(p) {
var t = page.panelTitle(p[1]);
process.stdout.write(' title(' + p[0] + ') ' + t + '\n');
check('L/title-caveat:' + p[0],
/configured one/.test(t) || /built-in default/.test(t),
'the tooltip promises a panel without saying the port is unverified: ' + t);
check('L/title-not-empty:' + p[0], typeof t === 'string' && t.length > 20);
});
// --- Report -----------------------------------------------------------------
process.stdout.write('\n');
if (failures.length) {
process.stdout.write('FAIL (' + failures.length + ')\n');
failures.forEach(function(f) { process.stdout.write(' - ' + f + '\n'); });
process.exit(1);
}
process.stdout.write('OK — all cases distinguished\n');
+41 -3
View File
@@ -13,8 +13,13 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=shater-core
PKG_VERSION:=0.2.0
PKG_RELEASE:=2
# Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
# SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
# full rationale — bug B4: v0.2.2…v0.2.6 all shipped as 0.2.0-r3). The literals
# are the manual/offline fallback only.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-2.0-or-later
@@ -35,6 +40,10 @@ define Package/shater-core
# shaterd : the daemon our init supervises (`shaterd run`)
# kmod-nft-tproxy : kernel TPROXY (shaterd emits the `inet shater` rules)
# kmod-nft-socket : socket match used by the tproxy divert chain
# kmod-tun : /dev/net/tun — the daemon opens the `shater-l3` TUN
# for L3 ingress (globals.l3_tunnel); usually built-in
# on stock images, but a slimmed image without it would
# make the option fail with a cryptic open() error.
# ip-full : `ip rule`/`ip route`/rt_tables for policy routing
# nftables-json : shaterd shells out to `nft`, and netplane/stats.go
# parses `nft -j list ...` — the JSON output only exists
@@ -44,7 +53,7 @@ define Package/shater-core
# ca-bundle : the daemon is CGO_ENABLED=0, so crypto/x509 has no
# host cert fallback — without /etc/ssl/certs every
# HTTPS subscription / .srs ruleset fetch fails.
DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full +nftables-json +ca-bundle
DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle
PKGARCH:=all
endef
@@ -75,6 +84,10 @@ define Package/shater-core/install
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/shater $(1)/etc/init.d/shater
$(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron
# START=21 one-shot that loads the persisted fail-closed plane before fw4's
# `lan -> wan ACCEPT` can be the only thing on the box (the main init is
# START=99, i.e. seconds of plaintext forwarding on every boot).
$(INSTALL_BIN) ./files/etc/init.d/shater-armor $(1)/etc/init.d/shater-armor
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
@@ -85,8 +98,33 @@ define Package/shater-core/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/shater $(1)/etc/config/shater
# THE SAME FILE AGAIN, READ-ONLY, AS DOCUMENTATION. /etc/config/shater is 271
# lines of which 248 are comment, and on the router it is the only description
# of the schema there is (PORTING.md does not ship). Being a conffile keeps an
# upgrade from replacing it, but it does NOT keep the daemon from rewriting it:
# the config write path replaces the whole package (`uci delete shater` + `uci
# import`), which drops every comment — and it runs without an operator, from
# the panel, the 6-hourly subscription refresh and the 25-second profile
# watcher. So the annotated original is installed a second time where nothing
# rewrites it, and the header of the live file points at it.
#
# INSTALL_DATA, not INSTALL_CONF: this copy is package metadata (refreshed by
# every upgrade so it documents the build actually installed), not user config.
$(INSTALL_DIR) $(1)/usr/share/shater
$(INSTALL_DATA) ./files/etc/config/shater $(1)/usr/share/shater/config.sample
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/etc/uci-defaults/30_shater-core $(1)/etc/uci-defaults/30_shater-core
# sysupgrade's "keep settings" walks /lib/upgrade/keep.d/*, and without this the
# node inventory in /etc/shater/subs does NOT survive a flash: the restored box
# has its rules and its groups and no nodes for them to point at, and the only
# repair is `sub update`, which needs the internet the tunnel was going to
# provide. Package metadata, not user config, so INSTALL_DATA and not
# INSTALL_CONF. (/etc/config/shater needs no entry — it is a conffile and
# sysupgrade already keeps it that way.)
$(INSTALL_DIR) $(1)/lib/upgrade/keep.d
$(INSTALL_DATA) ./files/lib/upgrade/keep.d/shater-core $(1)/lib/upgrade/keep.d/shater-core
endef
$(eval $(call BuildPackage,shater-core))
+127 -11
View File
@@ -12,8 +12,30 @@
# from this file. There is no separate xray/dnsmasq and no generated run.json.
#
# Full schema: docs-shater/PORTING.md (PART A "uci.go — /etc/config/shater
# schema") and shater/model. This file is installed as a conffile — your edits
# survive package upgrades.
# schema") and shater/model.
#
# WHAT SURVIVES WHAT. This file is installed as a conffile, so `apk upgrade
# shater-core` will not replace it: your VALUES survive a package upgrade.
#
# These COMMENTS do not survive the first write, and that write does not need
# you to make it. The daemon and the panel persist the whole package in one go
# (`uci delete shater` + `uci import`), and a package rebuilt by `uci import`
# keeps no comments and no hand-made blank lines; the sections come back in the
# daemon's own order. Three things write here with nobody at the keyboard: a save
# in the admin panel, a subscription refresh (cron, every 6 h) and the profile
# watcher switching profiles (it looks every 25 s). So expect the annotations
# below to be gone shortly after the box is first configured.
#
# THE ANNOTATED COPY IS KEPT: /usr/share/shater/config.sample is this same file,
# installed by the package where nothing rewrites it. Read it there (`cat
# /usr/share/shater/config.sample`) and copy the fragment you need. It is
# refreshed by each package upgrade, so it always documents the build you have.
#
# BEFORE THE FIRST CHANGE, the previous file is copied to
# /etc/shater/config.pre-v<schema>.bak — once per schema version, never
# overwritten afterwards. That copy is the one taken at the transition (a schema
# migration, or the first save on a newly installed build); it is not a rolling
# backup, and it is deliberately not carried across a sysupgrade.
#
config globals 'globals'
@@ -23,17 +45,84 @@ config globals 'globals'
option kill_switch 'closed'
# There is no dns_mode option: routing is decided by in-engine rule-sets and
# fake-IP is a resolver type (`config resolver` with type=fakeip + pool).
#
# Force ALL LAN plaintext DNS (:53) into the engine, INCLUDING queries the
# client sends to the router itself (the address DHCP hands out). ON by
# default: with it off, a client using the router as its resolver is answered
# by dnsmasq and forwarded to the ISP in the clear — no blocklists, no
# per-device DNS rules, no resolver detour — while a client that hard-codes
# 8.8.8.8 IS intercepted. The obedient client leaked; the evader did not.
#
# Set to '0' to opt out (dnsmasq answers router-addressed :53 again). Your
# explicit value is never overwritten: this file is a conffile, and the daemon
# always writes the option back as '1'/'0'.
#
# .lan and the private reverse (PTR) zones keep working: with at least one
# `config resolver` present the engine gets a synthetic server pointed at
# dnsmasq on 127.0.0.1:53 plus a rule that sends those suffixes to it; with no
# resolver at all the engine falls back to the system resolver, which is
# dnsmasq too. If you changed dnsmasq's domain away from `lan`, add a
# `config dns_rule` for it (only `lan` + RFC6303 reverse zones are built in).
#
# While the engine is DOWN the LAN is NOT left without DNS: the fail-closed
# holding plane hooks `forward` only, so dnsmasq still answers router-addressed
# :53 — unfiltered and in the clear, the documented trade-off (blocking it
# would also cut the daemon's own name resolution and its chance to recover).
# Queries aimed at an EXTERNAL resolver are dropped with the rest of the LAN's
# forwarded traffic.
option dns_intercept '1'
# Carry LAN ping through the tunnel. ON by default, and the alternative is
# why: without it a ping is decided by `untunnelable` below, whose rungs are
# "drop it" (block, the default) or "let it out of the WAN interface with the
# client's real IP on it" (icmp/direct). There was no setting in which ping
# both worked and stayed inside the tunnel. With this on, the engine opens a
# TUN, LAN ICMP is routed into it, and an outbound that speaks layer 3
# (WireGuard/AmneziaWG, or a direct route) carries the echo for real. An
# outbound that does not (vless/trojan/shadowsocks) makes the ping DROP —
# honestly: no reply is forged, ping reports loss. So a ping that used to
# "work" through such a node was a ping that was leaking.
#
# It costs a permanent TUN device plus the gVisor netstack behind it, about
# 2 MB of RSS for as long as the daemon runs.
#
# Set to '0' to opt out — worth it on a 32/64 MB router, or to bisect whether
# the L3 ingress is what broke something. `shaterd apply` will tell you what
# the off state costs. Your explicit value is never overwritten: this file is
# a conffile and the daemon always writes the option back as '1'/'0'.
#
# NOTE the interaction: with this ON, `untunnelable` no longer governs ping at
# all (the L3 route decision happens before the firewall chain its verdicts
# live in). It still governs ESP/AH/GRE/IGMP/SCTP, which no tunnel of ours can
# carry. `untunnelable 'icmp'` in particular stops meaning "block plus working
# ping" and is reported as such.
option l3_tunnel '1'
option ipv6 '1'
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
option fwmark_base '0x2000'
option table_base '0x2000'
# Seconds to auto-rollback an unconfirmed apply (0 = commit-confirm off).
# Seconds to auto-rollback an unconfirmed apply. SHIPPED AS 0, i.e.
# commit-confirm is OFF: `shaterd apply` arms nothing, and an apply that costs
# you SSH/LuCI access stays until you undo it by hand. Set a window (e.g.
# '120') to arm it, and run `shaterd confirm` inside that window to keep the
# new config. Note the option is written back only when NON-zero, so an
# explicit '0' disappears from this file on the first write by the daemon or
# the panel — absent and 0 are the same thing.
option confirm_timeout '0'
option schema_version '1'
# Master enable of the DNS blocklist/allowlist filter (D15). OFF by default;
# it needs at least one `config resolver` to have a DNS plane to filter with.
# See the "DNS filter" section at the end of this file.
option dns_filter '0'
option schema_version '2'
# LAN interception inbound. `network` is a UCI interface name; shaterd resolves
# it to its device (e.g. 'lan' -> br-lan) for the nft TPROXY plane. Enable
# globals above and adjust `network` to the interface(s) you want proxied.
#
# There is no per-inbound `sniff` option: since sing-box 1.11 sniffing is a
# leading route ACTION rule with no inbound matcher, so EVERY inbound is sniffed,
# always. Do not add one back — the hijack-dns rule matches the SNIFFED `dns`
# protocol, so a per-inbound sniff toggle would be a DNS-leak switch (D14, and
# the long argument at shater/model/model.go Inbound).
config inbound
option name 'lan'
option enabled '1'
@@ -42,7 +131,6 @@ config inbound
option tproxy_port '12345'
option tcp '1'
option udp '1'
option sniff '1'
# --- Commented examples (copy, uncomment, adjust, then enable globals) -------
#
@@ -62,7 +150,29 @@ config inbound
# list node 'my-node'
#
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
# Match on src / dst_domain / dst_ruleset / dst_ip / dst_port / proto.
# Match on src / dst_ruleset / dst_port / proto. A rule with NO matcher at all is
# the default route for everything that reached it.
#
# WHERE the traffic is going is named ONLY by dst_ruleset — one or more
# `config ruleset` names; the rule matches when ANY of them matches. There is no
# inline domain or address list on a rule (`dst_domain`/`dst_ip` were removed in
# schema v2): a destination list is written once as a ruleset, compiled into a
# .srs and shared by every rule that references it. `shaterd migrate` converts
# older configs automatically, creating a `rule-<name>` ruleset per rule.
#config ruleset
# option name 'blocked-video'
# option type 'domain'
# option source 'inline'
# list entry 'youtube.com'
# list entry 'suffix:googlevideo.com'
#
#config rule
# option name 'video-via-main'
# option enabled '1'
# option order '50'
# list dst_ruleset 'blocked-video'
# option target 'group:main'
#
#config rule
# option name 'all-via-main'
# option enabled '1'
@@ -76,18 +186,24 @@ config inbound
# record - tls_record_fragment (alternative; mutually exclusive w/ fragment)
# spoof - tls_spoof (inject a decoy ClientHello; needs root NET_RAW/NET_ADMIN)
# Point a rule's target at it for DPI-blocked-but-not-IP-blocked domains — direct
# and fragmented, no exit node, no extra binary. (The stronger external `byedpi`
# preset is Phase-2b.)
# and fragmented, no exit node, no extra binary. These three are the WHOLE set;
# the external desync egress that once stood beside them is removed (D29), and a
# `type 'byedpi'` egress left over from an older build is blocked, not routed.
#config egress
# option name 'frag'
# option type 'direct'
# option dpi 'fragment'
#
#config ruleset
# option name 'youtube'
# option source 'geosite'
# list category 'youtube'
#
#config rule
# option name 'youtube-fragment'
# option enabled '1'
# option order '50'
# list dst_domain 'geosite:youtube'
# list dst_ruleset 'youtube'
# option target 'egress:frag'
#
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
@@ -100,8 +216,8 @@ config inbound
#
# --- DNS filter (D15) -------------------------------------------------------
# Network-wide domain blocking, built on sing-box rule-sets + reject DNS rules.
# Turn it ON by setting `option dns_filter '1'` in `config globals` above (it is
# OFF by default). Filtering needs at least one `config resolver` (the in-engine
# Turn it ON by flipping `option dns_filter` to '1' in `config globals` above (it
# is shipped '0'). Filtering needs at least one `config resolver` (the in-engine
# DNS plane). A blocklist answers matched domains with NXDOMAIN; an allowlist
# always OVERRIDES the blocklists (allowlisted domains resolve normally).
#
+488 -7
View File
@@ -33,6 +33,30 @@
# be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
# reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
# background actor may resurrect interception behind a stopped daemon.
# * BEING REPLACED IS NOT BEING SWITCHED OFF. `restart` and `reload` (which is
# stop+start, i.e. every LuCI Save & Apply) both run through `stop`, and the
# daemon's SIGTERM teardown removes the fail-closed table unconditionally — it
# does not consult kill_switch at all. Between that teardown and the
# successor's first apply the init GUARANTEES a gap: it waits for the old
# process to exit (shater_wait_stopped), then runs `shaterd migrate`, then
# starts a daemon that still has to build an engine. So a restart is announced
# with RESTART_FLAG, which tells the outgoing daemon to leave the fail-closed
# holding plane STANDING — apply.TeardownExiting swaps it in with one nft
# transaction and then skips the delete, so the table is never absent, not even
# for the 80-90 ms the old arm-after-teardown order measured. A real `stop`
# raises no flag and therefore still means what it says.
# (A package UPGRADE does not come through here at all on apk v3: shater-core's
# script table is post-install / pre-deinstall / post-upgrade, with no
# pre-upgrade, so default_prerm — and its `stop` — runs only on REMOVAL.)
# * The FAIL-CLOSED PLANE MUST ALSO EXIST BEFORE THIS SCRIPT DOES. START=99 is
# after fw4 (19) and netifd (20), so at every boot the LAN forwards to the WAN
# in the clear for as long as it takes procd to decompress the daemon off
# flash and get an engine up. /etc/init.d/shater-armor (START=21) loads
# BOOT_ARMOR — a copy of the holding plane the daemon persists on every apply
# — to close that window. This script owns the DISARM half, and it owns it
# with a CLOSED LIST: an operator's `stop`, or a removal, and nothing else.
# Powering the box down must not — `shutdown` reaches stop_service too, and it
# is not a person switching the product off (see shater_stop_disarms).
# * The engine must never be permanently abandoned while interception stands:
# respawn retries are infinite (procd never gives up); a sustained-dead
# daemon is additionally escalated by the shater-cron watchdog.
@@ -47,6 +71,168 @@ PROG=/usr/bin/shaterd
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
# nothing reconciles before this init has run at boot.
ACTIVE_FLAG=/var/run/shater.active
# Written by `shaterd run`; the single-owner token this init waits on so a
# restart never overlaps a new data plane with the previous one's teardown.
PIDFILE=/var/run/shaterd.pid
# Raised around a restart/reload, read by the OUTGOING `shaterd run` at SIGTERM:
# present => "you are being replaced, leave the fail-closed plane standing";
# absent => "you are being switched off, take everything down". tmpfs, so a
# power cut can never make the next boot look like a restart.
RESTART_FLAG=/var/run/shater.restarting
# The persisted fail-closed holding plane. Written by the daemon on every apply,
# loaded by /etc/init.d/shater-armor at boot. Its PRESENCE is the arm token, so
# removing it here is how a deliberate stop stops the next boot from blocking.
BOOT_ARMOR=/etc/shater/boot.nft
# Seconds `start` will wait for a predecessor to finish its teardown. Must be
# >= term_timeout below (procd's hard cap on a predecessor's life after SIGTERM)
# so we never give up while procd is still letting it shut down cleanly.
STOP_WAIT_SECS=40
# WHICH ACTION rc.common was invoked with, frozen at source time.
#
# rc.common does, in this order:
# initscript=$1; action=${2:-help}; shift 2; ...; . "$initscript"; $action "$@"
# so `action` is ALREADY assigned when this file is sourced, and every action then
# runs as a function in THAT SAME shell. MEASURED on the target (ImmortalWrt
# 25.12.1 r37978) with a throwaway probe init script, not read off documentation:
#
# /etc/init.d/X restart -> stop_service action=[restart], start_service [restart]
# /etc/init.d/X stop -> stop_service action=[stop]
# /etc/init.d/X reload -> reload_service action=[reload]
# `reboot` -> stop_service action=[SHUTDOWN] <-- see below
# the boot after it -> start_service action=[boot]
#
# A previous probe reported this variable EMPTY and the emptiness was written up as
# the defect. It was the probe: `sh -x /etc/init.d/shater restart` bypasses the
# `#!/bin/sh /etc/rc.common` shebang, so rc.common never runs, never assigns
# `action`, and the variable reads empty no matter what this file does.
#
# Frozen into our own variable because `action` is a short, generic name that other
# framework helpers also use as a local; a snapshot taken before any function runs
# cannot be shadowed later.
SHATER_RC_ACTION="$action"
# --- what an action MEANS --------------------------------------------------
#
# THE BUG THESE TWO PREDICATES REPLACE (v0.2.17, measured on the live router).
# The old stop_service was `case $action in restart|reload) keep;; *) DISARM;; esac`
# — an open default that swept up every action nobody had enumerated. `reboot` is
# one of them: procd runs the K-links with the action `shutdown`, so the shutdown
# path deleted the arm token on the way down and the next boot had nothing to load.
# The mechanism destroyed itself at exactly the moment it exists for. Instrument
# reading from the router, one minute apart across a reboot:
#
# 13:28 /etc/shater/boot.nft present
# ---- reboot (stop_service action=[shutdown] -> old `*` branch -> rm)
# 18s at_S22: NO_TABLE armor_file=NO_FILE
#
# So both lists below are POSITIVE and CLOSED. An action nobody thought about —
# `shutdown` above all, but also whatever a future procd invents — falls through
# both and changes nothing. The default now fails in the recoverable direction: at
# worst a boot arms when it need not have, which costs the second before the daemon
# applies and is still gated by shater-armor's own four state refusals. The old
# default failed in the direction of the plaintext window the feature was built to
# close.
#
# They are predicates rather than an inline `case` so the test gate can execute the
# real thing: it sources THIS FILE in /bin/sh and calls them with every action procd
# actually uses (shater/cmd/shaterd/initscript_test.go). A comment claiming
# `shutdown` is handled is what shipped last time.
# True only for the ONE action that means "the operator switched the product off".
# Deliberately not `shutdown`: powering a router down is not turning a feature off.
#
# NOT sufficient on its own — see shater_stop_disarms. `stop` is also how the
# package manager's plumbing reaches us, and a package manager is not a person.
shater_action_disarms() {
case "$1" in
stop) return 0 ;;
*) return 1 ;;
esac
}
# Is a package manager in the middle of a transaction RIGHT NOW?
#
# This is a state, read at the moment the decision is made, exactly like
# shater-armor's four refusals — not a record of an event. The same question is
# already asked (for the same reason: prerm/postinst plumbing is not a user
# action) by the detached bring-up in /etc/uci-defaults/30_shater-core.
shater_pkg_transaction() {
pidof apk >/dev/null 2>&1 && return 0
pidof opkg >/dev/null 2>&1 && return 0
return 1
}
# Is the main service still enabled at boot? Same glob, and for the same reason,
# as shater-armor's own check: `/etc/init.d/shater enabled` would source procd.sh
# and take a blocking flock, which is not something to do from inside a package
# manager's transaction.
shater_rc_enabled() {
local f
for f in /etc/rc.d/S[0-9][0-9]shater; do
[ -e "$f" ] && return 0
done
return 1
}
# THE ACTUAL DISARM DECISION.
# $1 = action
# $2 = 1 when a package transaction is in flight
# $3 = 1 when the service is still enabled in rc.d
# All three are passed in rather than read inside, so the gate can drive every
# combination without a package manager or an /etc/rc.d.
#
# WHY IT IS NOT JUST THE ACTION. base-files' default_prerm runs, in this order:
#
# if [ "$PKG_UPGRADE" != "1" ]; then "$i" disable; fi
# "$i" stop
#
# so a package manager reaches stop_service wearing the operator's clothes. Two
# different intentions arrive as the same action, and the difference between them
# is readable at the moment of the decision:
#
# REMOVAL — prerm has ALREADY run `disable`, so S99shater is gone. The product
# is going away; the armor goes with it. (It is belt-and-braces even
# so: shater-armor refuses to arm without that symlink, and the whole
# init script is about to be deleted anyway.)
# REPLACED — the service is still enabled, so something intends to bring it
# back. That is not an operator switching anything off, and deleting
# the armor here would leave the next boot unprotected. "The next
# apply will rewrite it" is not an answer: the armor exists precisely
# to cover a reboot, and a reboot between an update and the first
# apply is how this product is deployed.
#
# MEASURED, because the paragraph above is about a path I got wrong once already.
# On THIS target (apk-tools 3.0.5, ImmortalWrt 25.12.1) shater-core's script table
# is post-install / pre-deinstall / post-upgrade, with NO pre-upgrade — so an apk
# UPGRADE never executes default_prerm and never calls `stop` at all. Verified with
# a real `apk fix --reinstall shater-core` while sampling the armor file: 245 625
# samples, zero disappearances, even with this guard mutated off. The upgrade half
# of this predicate is therefore defence-in-depth for a shape that is one
# `pre-upgrade` script (or a returning opkg lane) away, NOT a fix for an observed
# failure. The removal half is live today.
shater_stop_disarms() {
shater_action_disarms "$1" || return 1
# No package manager involved => a person typed it. The escape hatch must work.
[ "$2" = "1" ] || return 0
# A package transaction that has NOT disabled the service is replacing it.
[ "$3" = "1" ] && return 1
return 0
}
# True when a successor is coming, so the outgoing daemon should leave the
# fail-closed holding plane standing instead of removing it.
#
# `shutdown` is deliberately NOT a handoff either: nothing is coming, and the
# kernel that would hold the plane is going away with it. Leaving the flag down
# there also keeps the marker's meaning exact — it says "you are being replaced",
# and at shutdown nothing is.
shater_action_handoff() {
case "$1" in
restart|reload) return 0 ;;
*) return 1 ;;
esac
}
# --- helpers ---------------------------------------------------------------
@@ -66,6 +252,209 @@ _slog() {
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
}
# A line the operator gets EVEN WITH globals.log_syslog=0, without going behind
# that setting's back.
#
# log_syslog is a statement about ONE destination: the syslog stream (see _slog
# above — the same toggle silences the daemon's own stderr->logread fan-out).
# Honouring it by staying silent everywhere turns "keep syslog quiet" into "never
# tell me the config could not be brought forward", which is not what it says and
# not what anybody means by it. So the refusal goes somewhere else instead:
#
# * THIS SCRIPT'S OWN STDERR, unconditionally. That is not the syslog stream; it
# is the reply to whoever invoked the script. Typed by hand it lands on the
# operator's terminal at the moment they are looking at it; run from
# 30_shater-core inside `apk add` / `opkg install` it lands in the package
# manager's output, which is the one screen an installing operator does read.
# At boot it goes to procd's stderr (console) — not durable, hence the file.
# * /etc/shater/migrate-failed, on flash, written on failure and REMOVED on the
# first success. That is the durable half: it survives the reboot nobody
# watched, `cat` reads it, and its absence is the honest all-clear. Written
# AFTER the stderr line on purpose — a full /overlay is one of the named
# causes of the failure it is reporting, so it must never be the only channel.
# * syslog too, but only when log_syslog allows it — that channel keeps
# obeying the operator exactly as before.
#
# One call, one text, three destinations, so the wording cannot drift between
# them. Failures only: _shout is not a status line.
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
_shout() {
echo "shater: $*" >&2
_slog -p daemon.err "$*"
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
}
# --- `shaterd migrate`: which of the four things happened --------------------
#
# The schema version on disk, as an integer. 0 for "absent" and 0 for anything
# non-numeric, DELIBERATELY the same two answers model.readSchemaVersion gives
# (`strconv.Atoi` of a garbage value is 0 with the error dropped) — this number
# is only ever used to name a version in a message, and a shell that disagreed
# with the binary about what v0 means would print a version the binary never saw.
shater_schema_version() {
local v
v=$(uci -q get shater.globals.schema_version) || v=""
case "$v" in
"") echo 0 ;;
*[!0-9]*) echo 0 ;;
*) echo "$v" ;;
esac
}
# shater_migrate_class <rc> <output-of-shaterd-migrate> — prints EXACTLY one of:
#
# ok the binary reported success (it may or may not have had work)
# downgrade REFUSED: the config on disk is NEWER than this build
# unreadable /etc/config/shater could not be read at all
# failed it failed for a reason this script does not recognise
#
# A CLOSED POSITIVE LIST, and the last rung is the point of it. Until v0.2.19 all
# four of these were reported with ONE sentence, and that sentence described only
# the third one: "routing rules that still carry the removed dst_domain/dst_ip
# options stay DISABLED until this succeeds. Free space on /overlay and re-run".
# On a DOWNGRADE every clause of that is false — nothing is disabled, /overlay is
# not the problem, and re-running does not help, because the fix is to put the
# newer package back. A confident wrong diagnosis costs more than no diagnosis.
#
# `downgrade` is recognised from the binary's own words. That is a CONTRACT with
# shater/model: both refusals — model.migrateWith's "config schema v%d newer than
# this build (v%d); upgrade the package" and model.ErrSchemaTooNew's "config
# schema newer than this build" — contain the substring matched below, and
# TestMigrateDowngradeSignatureIsAContract (shater/cmd/shaterd) fails if either
# stops containing it. If the wording is ever changed anyway, this degrades to
# `failed`, which names itself as unrecognised and quotes the binary verbatim —
# the recoverable side. It cannot degrade into one of the confident branches.
shater_migrate_class() {
local rc="$1" out="$2"
[ "$rc" = "0" ] && { echo ok; return 0; }
case "$out" in
*"newer than this build"*) echo downgrade; return 0 ;;
esac
# Asked LAST, so a refusal we can name is never re-labelled as an I/O problem.
# `uci export` fails both when the file is missing and when it does not parse,
# which is the same thing from here: nothing can be said about a schema that
# cannot be read.
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
echo failed
}
# Run the migration and report it. Called from start_service and mirrored by
# /etc/uci-defaults/30_shater-core; see the long note at the call site for why
# this never refuses to start.
shater_migrate() {
local before after out rc class
before=$(shater_schema_version)
out=$("$PROG" migrate 2>&1)
rc=$?
class=$(shater_migrate_class "$rc" "$out")
after=$(shater_schema_version)
case "$class" in
ok)
# The all-clear is the ABSENCE of the breadcrumb, so a fixed router stops
# claiming to be broken the moment it is fixed.
rm -f "$SHATER_MIGRATE_BREADCRUMB"
# Nothing to do is not news; obeys log_syslog like every other status line.
[ "$before" = "$after" ] && return 0
_slog -p daemon.info \
"UCI schema migrated: v$before -> v$after. The config as it was at v$before was copied to /etc/shater/config.pre-v$before.bak before the first change."
;;
downgrade)
_shout "UCI schema migration REFUSED — this is a DOWNGRADE, not a broken config. /etc/config/shater carries schema v$after, which is NEWER than this build understands, so nothing was migrated and nothing on disk was changed. Your settings are intact; they are also unchangeable, because the daemon and the panel refuse every config write for the same reason and a save from the panel will fail too. Nothing on this router fixes it: install a shater build that understands schema v$after — the one that ran here before the downgrade (docs-shater/INSTALL.md has the pinned per-version feed). Starting anyway, so the panel stays reachable. '$PROG migrate' said: ${out:-no output}"
;;
unreadable)
_shout "UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so this script cannot even say which schema is on disk. A config that is missing or does not parse is neither migrated nor repaired here. Starting anyway — the daemon will come up on whatever it can parse, which may be nothing, leaving it inert with the panel still reachable. Check /etc/config/shater by hand; an /etc/shater/config.pre-v*.bak copy from an earlier migration may be next to it. '$PROG migrate' said: ${out:-no output}"
;;
failed)
_shout "UCI schema migration FAILED for a reason this script does not recognise; the config on disk is still at schema v$after. Starting anyway: refusing to start would take the admin panel down with it, and the panel is the only way to fix the box. The mundane cause is a full /overlay, where 'uci commit' cannot write — check 'df /overlay' first, then re-run '$PROG migrate' or restart the service.$(
[ "$after" = "1" ] && printf ' %s' "While the config stays at v1, routing rules that still carry the removed dst_domain/dst_ip options are held DISABLED by the daemon and reported as such — those rules are not in force."
) '$PROG migrate' said: ${out:-no output}"
;;
*)
# shater_migrate_class returns a closed set and every member of it is
# handled above, so this is unreachable. It exists to say "this script
# disagrees with itself" out loud instead of picking one of the confident
# branches and being wrong quietly — which is the exact failure the closed
# list replaced.
_shout "INTERNAL: '$PROG migrate' produced a result /etc/init.d/shater cannot classify (class='$class', rc=$rc). That is a bug in this script, not a state of the router. Starting anyway. Output was: ${out:-no output}"
;;
esac
}
# Announce/withdraw "this daemon is being replaced, not switched off". Read by
# `shaterd run` when it receives SIGTERM.
shater_mark_restart() {
mkdir -p "$(dirname "$RESTART_FLAG")" 2>/dev/null
: > "$RESTART_FLAG"
}
shater_clear_restart() { rm -f "$RESTART_FLAG"; }
# Remove the persisted boot armor, so the LAN is NOT blocked at the next boot
# before the daemon starts. Called from exactly two places, both of which are a
# statement about the PRODUCT rather than about this process: an operator typing
# `stop`, and a daemon binary that is no longer on the box. In neither case is
# anything going to come along and replace the armor with a real data plane, and a
# kill switch with nothing behind it is just a brick.
#
# NOT called on the shutdown path. That is the whole fix — see
# shater_action_disarms.
shater_disarm_boot() { rm -f "$BOOT_ARMOR"; }
# Echo the pid of a LIVE `shaterd run`, or fail. The pidfile is written by the
# daemon itself and removed only by the daemon that owns it, AFTER its teardown
# has completed — so "pidfile names a live process" is precisely "the previous
# data plane has not been dismantled yet".
shater_daemon_pid() {
local pid
pid=$(cat "$PIDFILE" 2>/dev/null) || return 1
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null || return 1
echo "$pid"
}
# Block until no predecessor daemon is left, bounded by STOP_WAIT_SECS.
#
# WHY THIS EXISTS. procd's `stop` is ASYNCHRONOUS: rc.common's `restart` is
# literally `stop; start`, and the `service delete` ubus call returns the moment
# procd has SENT SIGTERM — not when the instance is gone. `start` therefore
# re-adds the instance while the outgoing `shaterd run` is still executing its
# honest teardown (engine close, then `nft delete table`, `ip rule`/`ip route`
# removal and the per-iface sysctl restore). The result is that `restart` is NOT
# equivalent to `stop` + pause + `start`: the new plane is stood up on top of
# kernel state the old one has not finished removing, which is what B3 (DNS to
# the router's own LAN address dead after a restart, and never recovering) came
# out of. Waiting here restores the equivalence, and costs literally nothing when
# there is no predecessor — the check runs before the first sleep.
#
# Returning non-zero does NOT abort the start: the daemon carries its own
# single-owner guard and will refuse (or wait) on its side. Better to hand the
# decision to the process that can actually see the plane than to leave the box
# with no service at all.
shater_wait_stopped() {
local i=0 pid
pid=$(shater_daemon_pid) || return 0
_slog -p daemon.info \
"restart: waiting for the previous shaterd (pid $pid) to finish tearing the data plane down"
while [ "$i" -lt "$STOP_WAIT_SECS" ]; do
sleep 1
i=$((i + 1))
shater_daemon_pid >/dev/null || {
_slog -p daemon.info "restart: previous shaterd exited after ${i}s; starting a fresh one"
return 0
}
done
_slog -p daemon.warn \
"restart: previous shaterd (pid $pid) still alive after ${STOP_WAIT_SECS}s — starting anyway"
return 1
}
# --- procd lifecycle -------------------------------------------------------
start_service() {
@@ -81,15 +470,52 @@ start_service() {
# Guard: never claim to run without the daemon binary. A half-removed/failed
# shaterd upgrade must degrade to "plugin off", not to a box that thinks
# interception is live with nothing behind it.
#
# "Plugin off" now has to include DISARMING. With the boot armor in play, a
# missing binary is the one case where the fail-closed plane could stand
# forever with nothing able to replace it: the armor loads at START=21, the
# daemon never starts, and every later boot repeats it. The product being gone
# is not a security event — it is an uninstall — so the plane comes down and
# the LAN returns to plain routing, loudly.
if [ ! -x "$PROG" ]; then
shater_clear_restart
shater_disarm_boot
rm -f "$ACTIVE_FLAG"
nft delete table inet shater 2>/dev/null
_slog -p daemon.err \
"shaterd binary missing/not executable at $PROG — refusing to start (LAN stays on plain routing)"
"shaterd binary missing/not executable at $PROG — refusing to start; the fail-closed plane and its boot armor have been REMOVED (LAN back to plain routing, unprotected). Reinstall shaterd."
return 0
fi
# Do not stand a new data plane up on top of one that is still being taken
# down. On `restart` procd has only just SIGTERMed the previous instance and
# returned; this is the handshake that makes `restart` == `stop` + pause +
# `start`. It also keeps `migrate` below from rewriting UCI underneath a
# daemon that is still reading it. No-op (and no delay) when nothing is
# running, which is the boot case.
shater_wait_stopped
# The predecessor is gone and has already consumed the flag (it reads it in its
# SIGTERM handler). Withdraw it now, so a LATER `stop` is unambiguous even if
# this start fails further down.
shater_clear_restart
# Bring the UCI schema forward before the daemon reads it (idempotent;
# refuses a newer schema) so an upgraded package never applies a stale config.
"$PROG" migrate >/dev/null 2>&1
#
# THE FAILURE IS REPORTED, NOT SWALLOWED, AND IT IS NAMED. This is the only
# place the schema migration runs at boot (`shaterd run`, the SIGHUP reconcile
# and the panel's config write all read UCI directly), so if it fails here it
# does not get retried until the next start — which is also why this, and not
# the uci-defaults call, is the report that matters: it comes back at every
# boot and every restart for as long as the problem lasts.
#
# The three outcomes are three different problems with three different fixes
# (free space / put the newer package back / the file is unreadable), and
# shater_migrate says which one it was instead of asserting the middle one at
# all of them. Start regardless in every case: refusing to start would take the
# admin panel down with it, and the panel is the only way to fix the box.
shater_migrate
procd_open_instance shater
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
@@ -111,7 +537,16 @@ start_service() {
procd_set_param stderr 1
# Give the daemon room to run its honest teardown (engine.Close + netplane
# restore) before procd SIGKILLs it.
procd_set_param term_timeout 10
#
# 30s, not 10s: an engine holding a few hundred outbounds closes its
# urltest/observatory goroutines and flushes experimental.cache_file to FLASH
# before the netplane teardown even starts, and on eMMC/NAND that alone can
# outlast 10s. A SIGKILL there aborts the teardown at an arbitrary point and
# leaves the plane HALF removed — the nft table gone but the policy routing
# still installed, or vice versa — which is precisely the class of leftover
# state the successor's idempotent fast-path cannot see and never repairs.
# Shutdown is bounded by procd either way; we are only choosing where.
procd_set_param term_timeout 30
procd_close_instance
# Mark the stack live for hotplug/cron — but ONLY when interception is
@@ -128,6 +563,45 @@ start_service() {
}
stop_service() {
# Say WHY we are stopping before procd sends the signal, because the daemon
# cannot tell from the signal alone and the answer changes what it leaves in
# the kernel. Two INDEPENDENT questions, and the old code conflated them into
# one two-armed `case` whose else-branch answered both wrongly for `shutdown`:
#
# 1. IS A SUCCESSOR COMING (this process only)? restart / reload.
# Raise RESTART_FLAG so the outgoing daemon replaces its data plane with
# the fail-closed HOLDING plane instead of removing it. The gap until the
# successor applies is not a moment: this script waits out the old
# process, runs `shaterd migrate`, then starts a daemon that must build an
# engine — all of it, before this flag existed, with `lan -> wan ACCEPT`
# and nothing else.
#
# 2. IS THE PRODUCT BEING SWITCHED OFF (across boots)? `stop` — and only
# `stop`, and only when a PERSON is behind it (shater_stop_disarms; the
# package manager reaches us through `stop` too). Then the boot armor goes
# with it, so the next boot does not quietly reinstate what the operator
# just switched off — the same rule ACTIVE_FLAG has always enforced for
# hotplug/cron.
#
# `shutdown` answers NO to both, which is the defect this replaced: a reboot is
# not a successor and it is certainly not an operator switching the product off.
# It is the boot the armor exists for. An upgrade answers NO to the second for
# the same kind of reason.
if shater_action_handoff "$SHATER_RC_ACTION"; then
shater_mark_restart
else
shater_clear_restart
fi
local in_pkg=0 rc_en=0
shater_pkg_transaction && in_pkg=1
shater_rc_enabled && rc_en=1
if shater_stop_disarms "$SHATER_RC_ACTION" "$in_pkg" "$rc_en"; then
shater_disarm_boot
elif [ "$in_pkg" = "1" ] && shater_action_disarms "$SHATER_RC_ACTION"; then
_slog -p daemon.info \
"stop came from a package transaction that left the service enabled — keeping the boot armor, so being replaced cannot leave the next boot unprotected"
fi
# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
# what we are about to tear down. procd then sends SIGTERM to `shaterd run`,
# which runs its OWN honest teardown (engine.Close + netplane restore) — we
@@ -141,10 +615,17 @@ stop_service() {
reload_service() {
# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
# reload_config). Simplest correct behaviour: stop + start. `stop` clears the
# flag and SIGTERMs the daemon (honest teardown); `start` re-guards on
# enabled and, if still enabled, launches a fresh `shaterd run` that reads
# the new UCI and applies it. When the stack is disabled, `start` is a no-op,
# so a disable+apply cleanly tears everything down.
# flag and SIGTERMs the daemon (honest teardown); `start` WAITS for that
# teardown to actually finish (shater_wait_stopped) and then launches a fresh
# `shaterd run` that reads the new UCI and applies it. When the stack is
# disabled, `start` is a no-op, so a disable+apply cleanly tears everything
# down. Because the wait lives in start_service, this path gets the same
# stop-then-start ordering guarantee as `restart`.
#
# Marked EXPLICITLY as well as via SHATER_RC_ACTION: this is the path a routine
# Save & Apply takes, so it is the one that must not depend on reading an
# rc.common variable correctly. Belt and braces, one line.
shater_mark_restart
stop
start
}
@@ -0,0 +1,162 @@
#!/bin/sh /etc/rc.common
# /etc/init.d/shater-armor — the fail-closed plane, before the daemon exists.
#
# WHAT THIS CLOSES
#
# /etc/init.d/shater is START=99. By then fw4 (START=19) has long since loaded
# `lan -> wan ACCEPT` and netifd (START=20) has brought the LAN bridge up, so the
# router forwards LAN traffic to the WAN in the clear from the moment the link
# comes up until `shaterd run` has been decompressed off flash, has waited out any
# predecessor, has migrated UCI, has read the config and has installed its first
# table. On router-class hardware with a UPX-packed binary that is seconds — and
# they are exactly the seconds in which Wi-Fi finishes associating and every
# client on the network reconnects and starts talking. `kill_switch=closed` was
# configured the whole time and covered none of it.
#
# There was nothing in the package that could cover it either: no /etc/nftables.d
# include, no `nft -f` in uci-defaults. Protection existed only inside a Go
# process that had not started yet.
#
# HOW
#
# The daemon persists a copy of its fail-closed HOLDING plane (the same ruleset it
# installs when the engine is down: one forward chain, LAN-to-LAN and router
# traffic accepted, everything else from the diverted devices dropped) to
# $ARMOR on every apply. This script loads it early. When the daemon comes up it
# replaces the table atomically — the ruleset begins with `delete table` and adds
# its own in one netlink transaction — so there is never a moment with no table.
#
# `iifname` matches by NAME at packet time, not by ifindex at load time, so
# loading this before netifd has created br-lan is fine: the rules simply start
# matching when the device appears. That is why START can sit here rather than
# racing netifd.
#
# START=21: after fw4 (19) and netifd (20), because fw4's own start tears its
# table down and rebuilds it and we do not want to be in the middle of that, and
# because there is nothing to protect before the LAN device is being created. The
# residual exposure is the fraction of a second between netifd's `ifup` and this
# script, against seconds-to-a-minute before.
#
# THE ESCAPE HATCHES (a kill switch that cannot be switched off is a brick)
#
# These are STATE checks, evaluated here, at the moment of arming — not a record
# of something that happened on the way down. That distinction is the whole
# lesson of v0.2.17: the arm token was deleted by an EVENT on the shutdown path
# ("this looks like a stop"), and since `reboot` also runs the K-links, the
# mechanism reliably erased itself on the one transition it was built for. An
# event on the way down cannot be trusted to describe the world on the way up; a
# question asked on the way up can be.
#
# * $ARMOR only exists while the daemon's last applied config was BOTH enabled
# and fail-closed. `globals.enabled=0` and `kill_switch=open` each remove it
# at the next apply, and an operator typing `/etc/init.d/shater stop` removes
# it there and then. Powering the box off does NOT.
# * We refuse to arm when the main service is disabled in rc.d, or when the
# daemon binary is gone — in either case nothing would ever come along to
# replace the armor with a real data plane. These two are what makes a
# genuinely uninstalled/disabled product safe REGARDLESS of what the file
# says, which is why they are checked here rather than trusted to have been
# acted on earlier.
# * We refuse to arm when UCI can be read AND says the stack is disabled. A
# config that cannot be read is NOT a refusal: that case is precisely why the
# armor is a file rather than a query.
# * The chain hooks `forward` only, so SSH, LuCI and the admin panel (all input
# hook, to the router's own addresses) stay reachable. The operator can always
# get in and undo this.
#
# Note what a bare `/etc/init.d/shater stop` does NOT mean: it does not survive a
# reboot, because S99shater is still linked and procd starts the daemon again. So
# "stopped" is not a durable off-state and this script must not be designed as if
# it were — the durable ones are `disable` (no S??shater) and `globals.enabled=0`,
# and those are the two refusals above.
#
# busybox ash only — no bashisms.
START=21 # after firewall (19) and network (20), long before shater (99)
STOP=89
ARMOR=/etc/shater/boot.nft
PROG=/usr/bin/shaterd
# Syslog line that honors globals.log_syslog, like the other two inits. An
# unreadable UCI leaves the option empty => ON, which is what we want here: the
# one boot where the config cannot be read is the boot worth logging.
_slog() {
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater-armor "$@"
}
# Is the MAIN service enabled at boot? Answered by looking for its rc.d symlink
# rather than by running `/etc/init.d/shater enabled`: that is a USE_PROCD script,
# so every action of it sources procd.sh, which takes a blocking flock — and this
# runs at START=21, in the middle of boot, for a question a glob answers exactly
# as well. The START number is not hardcoded; any S<NN>shater counts.
shater_service_enabled() {
local f
for f in /etc/rc.d/S[0-9][0-9]shater; do
[ -e "$f" ] && return 0
done
return 1
}
start() {
# No saved plane => the stack has never applied an enabled, fail-closed config
# (or it was explicitly switched off). Nothing to do, and nothing to say.
[ -f "$ARMOR" ] || return 0
[ -s "$ARMOR" ] || {
_slog -p daemon.err "$ARMOR is empty — NOT arming; the LAN is unprotected until shaterd starts"
return 0
}
# Never arm something nothing can disarm.
[ -x "$PROG" ] || {
_slog -p daemon.err \
"$PROG is missing — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
return 0
}
shater_service_enabled || {
_slog -p daemon.warn \
"the shater service is disabled in rc.d — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
return 0
}
# A READABLE config that says "off" wins over the saved plane (it means the
# daemon was stopped before it could disarm). An UNREADABLE config does not:
# that is the case this whole mechanism exists for.
en=$(uci -q get shater.globals.enabled 2>/dev/null)
if [ -n "$en" ] && [ "$en" != "1" ]; then
rm -f "$ARMOR"
_slog -p daemon.info "globals.enabled=$en — boot armor removed, not arming"
return 0
fi
command -v nft >/dev/null 2>&1 || {
_slog -p daemon.err "nft is not installed — cannot arm; the LAN is unprotected until shaterd starts"
return 0
}
# Validate before loading: a truncated/incompatible snapshot must not leave a
# half-built table behind on the one boot it is needed.
if ! nft -c -f "$ARMOR" >/dev/null 2>&1; then
_slog -p daemon.err \
"$ARMOR did not validate (nft -c) — NOT arming; the LAN is unprotected until shaterd starts"
return 0
fi
if nft -f "$ARMOR" >/dev/null 2>&1; then
_slog -p daemon.warn \
"fail-closed plane armed from $ARMOR: LAN->WAN forwarding is BLOCKED until shaterd applies. SSH, LuCI and the admin panel stay reachable."
else
_slog -p daemon.err \
"could not load $ARMOR — the LAN is unprotected until shaterd starts"
fi
return 0
}
stop() {
# Deliberately a NO-OP. By the time anything stops this service the daemon owns
# `inet shater`, and deleting the table here would dismantle a LIVE data plane
# on the strength of a service that only ever ran for one second at boot. The
# disarm paths that matter live where the decision is actually made:
# /etc/init.d/shater stop (operator switched it off) and the daemon itself
# (globals.enabled=0 / kill_switch=open).
return 0
}
+295 -28
View File
@@ -3,11 +3,12 @@
# plus the data-plane watchdog (v0.2).
#
# A tiny procd-supervised loop that, once per tick, checks every enabled
# subscription and url-ruleset against its per-item `update_interval` and runs
# subscription against its per-item `update_interval` and runs
# shaterd sub update <name> (subscriptions)
# shaterd ruleset update <name> (url rulesets)
# when the item is due, then a single `shaterd reconcile` if anything changed
# (the daemon's config-hash gate rebuilds the engine only on a real change).
# `config ruleset` items are NOT touched here — see shater_run_due for who owns
# their refresh and where the remaining gap is.
#
# RELIABILITY CONTRACT (same "железно" posture as /etc/init.d/shater):
# * The loop body is fully INERT unless globals.enabled=1 AND the main shater
@@ -27,6 +28,15 @@
# the main service is STOPPED (tears interception down — fail-open, LAN
# returns to plain routing); with kill_switch=closed the rules stay
# (blocked-by-design) and we log loudly.
# * CRASH-LOOP WATCHDOG: the dead-daemon counter above cannot see the failure
# it matters most for. /etc/init.d/shater sets `respawn 3600 5 0`, so a
# daemon that dies a few seconds into startup is back within 5s and a single
# `pidof` per 60s tick nearly always finds a process — the counter resets and
# never reaches WATCHDOG_TICKS, while the fail-closed plane keeps the LAN shut
# and the panel (served BY the daemon) never comes up. So the tick's sleep is
# spent SAMPLING the daemon's identity instead of sleeping blind, and a tick in
# which several different daemons lived is counted as churn. See
# shater_churn_scan / shater_churn_verdict / shater_churn_action.
# * The loop never self-exits (procd would respawn-churn an exiting body);
# it idles on its guards instead. busybox ash only — no bashisms.
@@ -42,14 +52,34 @@ INIT_SCRIPT=/etc/init.d/shater-cron
SHATER_INIT=/etc/init.d/shater
SHATERD=/usr/bin/shaterd
ACTIVE_FLAG=/var/run/shater.active
# Raised by /etc/init.d/shater around a restart/reload and cleared by the
# successor's start_service. Read here ONLY as a "a person/package asked for this
# bounce" veto on the crash-loop verdict — never as a liveness signal.
RESTART_FLAG=/var/run/shater.restarting
# Written by `shaterd run` itself (main.go writePidfile) before it builds anything,
# and removed by that same process on a clean exit. It is the only handle that
# names THE daemon: `pidof shaterd` also matches the short-lived CLI verbs this
# very loop runs (`sub update`, `reconcile`, `schedule due`).
PIDFILE=/var/run/shaterd.pid
STAMP_DIR=/var/run/shater/cron
TICK=60 # seconds between due-checks
RETRY_SECS=300 # backoff before retrying a FAILED fetch
WATCHDOG_TICKS=5 # consecutive dead-daemon ticks before escalating
DEFAULT_SUB_INTERVAL=6h
DEFAULT_RS_INTERVAL=24h
DEFAULT_BL_INTERVAL=24h # url blocklist refresh interval (D16)
# --- crash-loop watchdog tuning --------------------------------------------
#
# Every number here is chosen against ONE question: what can a legitimate restart
# produce? A legitimate bounce (`restart`, LuCI Save & Apply -> reload, a package
# transaction) replaces the daemon EXACTLY ONCE, and it is announced twice over —
# /etc/init.d/shater raises RESTART_FLAG in stop_service and clears ACTIVE_FLAG for
# the duration. A crash loop is announced by nothing and repeats without bound.
LOOP_POLL=5 # seconds between identity samples inside one tick
LOOP_MIN_GENS=3 # distinct daemons in ONE tick that count as churn
LOOP_WINDOWS=2 # consecutive churn ticks before we call it a loop
LOOP_REPORT_TICKS=30 # do not repeat the report more often than this
# --- helpers ---------------------------------------------------------------
shater_enabled() {
@@ -132,8 +162,33 @@ shater_stamp_retry() {
# Walk anonymous `config subscription` / `config ruleset` sections by index and
# run any that are due. Echoes non-empty on stdout if at least one item updated.
#
# WHERE A SUBSCRIPTION'S BYTES TRAVEL, and what this loop sees when they cannot.
#
# `shaterd sub update <name>` honours the subscription's own `fetch_via`:
# * fetch_via != proxy — fetched DIRECT by the short-lived CLI process, exactly
# as it always was. Needs no daemon; works at cold start and first boot.
# * fetch_via == proxy — DELEGATED to the running daemon over its control
# socket, because only the daemon owns the engine the fetch has to travel
# through. Until 2026-07-27 the CLI printed one `daemon.warn` line and
# fetched DIRECT instead, so every tick of THIS loop and every fetch-at-boot
# put the feed URL and the router's real address on the plain WAN — while the
# panel's Refresh button (the same operation, through the daemon) worked, so
# it looked configured. It now FAILS instead, exit 1.
#
# CONSEQUENCE HERE, stated rather than discovered later: with a proxy-fetched
# subscription and no live daemon, the `if` below takes the else arm every time —
# one syslog line and shater_stamp_retry, i.e. a fresh attempt every RETRY_SECS
# (300s) for as long as the daemon is down. That is ~288 lines a day per such
# subscription. It is NOT the `ruleset update` situation this file used to have:
# there the work had no owner and the retry could never succeed, whereas here the
# retry succeeds within RETRY_SECS of the daemon coming back, and the loop only
# runs at all while `shater_enabled && shater_active` — a dead daemon is already
# being escalated by shater_watchdog, and with kill_switch=open it stops the stack
# (clearing ACTIVE_FLAG), which makes this loop inert. Deliberately left noisy:
# a subscription that is silently going stale is worse than a repeated line.
shater_run_due() {
local i name en ivl secs stamp src changed=""
local i name en ivl secs stamp changed=""
# Subscriptions.
i=0
@@ -159,28 +214,36 @@ shater_run_due() {
i=$(( i + 1 ))
done
# Rulesets (only url sources auto-update; others have nothing to fetch).
i=0
while uci -q get "shater.@ruleset[$i]" >/dev/null 2>&1; do
name=$(uci -q get "shater.@ruleset[$i].name")
src=$(uci -q get "shater.@ruleset[$i].source")
if [ -n "$name" ] && [ "$src" = "url" ]; then
ivl=$(uci -q get "shater.@ruleset[$i].update_interval")
secs=$(shater_ivl_secs "$ivl" "$DEFAULT_RS_INTERVAL")
stamp="$STAMP_DIR/rs.$(shater_safe_name "$name")"
if shater_due "$stamp" "$secs"; then
if "$SHATERD" ruleset update "$name" >/dev/null 2>&1; then
shater_stamp "$stamp"
changed=1
else
_slog -p daemon.warn \
"ruleset update '$name' failed; retrying in ${RETRY_SECS}s"
shater_stamp_retry "$stamp" "$secs"
fi
fi
fi
i=$(( i + 1 ))
done
# RULE-SETS ARE NOT UPDATED FROM HERE, AND NEVER WERE.
#
# There used to be a second loop that ran `shaterd ruleset update <name>` for
# every `config ruleset` with source=url. That verb has never existed: it
# printed a note and exited 0, so this loop stamped the item as freshly updated
# and raised `changed`, which cost a reconcile per item per interval and told
# the operator the list was current when not one byte had been fetched. The verb
# now exits non-zero (shater/cmd/shaterd/main.go, notImpl), which turns the same
# loop into one failed attempt and one syslog line every RETRY_SECS — ~288 lines
# a day, per rule-set, about work that has no owner here. Noise in the log hides
# real problems as effectively as a lie about success does.
#
# WHO REFRESHES A url RULE-SET NOW, so the next reader does not think this was
# forgotten. `source=url` splits into two shapes in shater/generate/ruleset.go:
#
# * the URL serves an engine-native .srs/.json -> it stays a REMOTE rule-set
# and sing-box owns fetch/cache/refresh through RemoteRuleSet.UpdateInterval
# on the running box. This cron loop never had anything to contribute.
# * the URL serves a plain-text list -> it is compiled locally into
# /etc/shater/lists/<tag>.srs, and that artifact is refreshed by the
# GENERATOR, "when missing or older than update_interval" — i.e. only when
# something else already caused a generate. Nothing schedules one, so this
# shape has NO periodic refresh at all today. That is a real gap, and it is
# stated here rather than papered over with a call to a verb that does
# nothing: closing it needs a daemon-side timer (or a real `ruleset update`),
# not a shell loop, because only the daemon can force a rebuild past the
# config-hash gate.
#
# `config blocklist` url items are a different mechanism and DO refresh — see
# shater_run_due_blocklists below.
[ -n "$changed" ] && echo 1
}
@@ -256,6 +319,171 @@ shater_watchdog() {
echo "$dead"
}
# --- crash-loop watchdog ----------------------------------------------------
#
# THE HOLE. shater_watchdog above answers "is a daemon there?" once every TICK
# seconds. /etc/init.d/shater sets `respawn 3600 5 0`, so a daemon that dies a few
# seconds into startup is back 5s later and that one sample nearly always finds a
# process: the dead-counter resets, never reaches WATCHDOG_TICKS, and the single
# failure the watchdog exists for — a new binary or a bad config that cannot get
# an engine up while the fail-closed plane holds the LAN shut — is the one it can
# never see. The daemon also serves the panel, so in that state the operator has
# neither internet nor a way to look at the box.
#
# THE SIGNAL. Not "is it there" but "is it the SAME one". The tick's sleep is
# spent taking an identity sample every LOOP_POLL seconds instead of sleeping
# blind, and a tick in which LOOP_MIN_GENS different daemons lived is a churn
# tick. LOOP_WINDOWS consecutive churn ticks is the verdict.
#
# WHY A LEGITIMATE RESTART CANNOT REACH IT. Four independent reasons, in order of
# how much they are relied on:
#
# 1. A bounce replaces the daemon ONCE. One restart scores 2 generations in the
# tick it happens in and 1 in every tick after, so it cannot even produce a
# single churn tick at LOOP_MIN_GENS=3, let alone LOOP_WINDOWS of them in a
# row. Reaching the verdict takes >= 4 replacements inside 2 consecutive
# minutes, >= 2 in each.
# 2. Bounces are ANNOUNCED. /etc/init.d/shater raises RESTART_FLAG in
# stop_service and clears ACTIVE_FLAG for the whole stop->start, and either
# one seen in any sample of a tick discards that tick outright.
# 3. The panel's Apply does not restart anything: it writes UCI and applies over
# the daemon's control socket, in process. Only `restart`, a LuCI Save &
# Apply (config.change -> reload) and a package transaction bounce the
# daemon, and a human cannot produce those at four a minute.
# 4. The sample names THE daemon via its pidfile, not `pidof shaterd` — the
# short-lived CLI verbs this very loop runs share that process name.
#
# WHAT IT DOES NOT COVER, stated rather than implied: a daemon that dies
# INSTANTLY (well under a second) is almost never caught alive by a 5s sample, so
# it scores few generations and this detector stays quiet. That case is exactly
# the one the existing dead-tick counter does see — its `pidof` misses too, tick
# after tick — so the two cover opposite ends and are deliberately left as two
# independent instruments rather than merged into one clever number.
# One identity sample: echoes the pid of the live `shaterd run`, or "-" for none.
#
# Through the PIDFILE, which `shaterd run` writes before it builds anything and
# removes on a clean exit, because that is the only handle that names THE daemon:
# `pidof shaterd` also matches `shaterd sub update` / `reconcile` / `schedule due`.
# /proc/<pid>/comm is checked so a stale pidfile whose pid has been reused by an
# unrelated process cannot read as a live daemon. No forks: `read` is a builtin.
shater_sample_pid() {
local pid="" comm=""
[ -r "$PIDFILE" ] && read -r pid 2>/dev/null < "$PIDFILE"
case "$pid" in
''|*[!0-9]*) echo -; return ;;
esac
[ -r "/proc/$pid/comm" ] && read -r comm 2>/dev/null < "/proc/$pid/comm"
[ "$comm" = "shaterd" ] || { echo -; return; }
echo "$pid"
}
# shater_churn_scan <sample>... -> "<generations> <absent-samples>"
#
# A GENERATION is one distinct daemon lifetime observed during the tick: a live
# pid that differs from the last live pid seen. A daemon that simply keeps running
# therefore scores exactly 1 generation and 0 absent samples for as long as it
# runs — the signal is flat unless something is actually being replaced.
#
# A GAP (samples with no daemon at all, e.g. procd's 5s respawn hole) is counted
# but does NOT by itself open a new generation: only a different pid does. An
# earlier draft reset the comparison across a gap so that "same pid seen again
# after a gap" would score two. That case cannot occur — a respawn always gets a
# fresh pid — so it was unfalsifiable code, and resetting also meant a momentarily
# unreadable pidfile could inflate the count. Not resetting is both simpler and
# the safer direction.
#
# Pure: no I/O, no globals, every input on the command line. That is what lets the
# gate drive it with synthetic sample streams instead of a live router.
shater_churn_scan() {
local gens=0 absent=0 last="" s
for s in "$@"; do
if [ "$s" = "-" ]; then
absent=$(( absent + 1 ))
continue
fi
[ "$s" = "$last" ] || gens=$(( gens + 1 ))
last="$s"
done
echo "$gens $absent"
}
# shater_churn_verdict <gens> <samples> <announced> <churn-so-far>
# -> the new consecutive-churn-tick count
#
# Also pure. `announced`=1 means a sample during the tick saw RESTART_FLAG up or
# ACTIVE_FLAG down, i.e. /etc/init.d/shater said out loud that it was bouncing the
# daemon: that tick proves nothing and resets the run. A tick with no samples at
# all (the first pass through the loop) likewise scores 0 rather than guessing.
shater_churn_verdict() {
local gens="$1" n="$2" announced="$3" churn="$4"
[ "$announced" = "1" ] && { echo 0; return; }
[ "$n" -gt 0 ] || { echo 0; return; }
if [ "$gens" -ge "$LOOP_MIN_GENS" ]; then
echo $(( churn + 1 ))
return
fi
echo 0
}
# shater_churn_action <churn-ticks> <kill_switch> -> none | log | stop
#
# WHAT TO DO, and why it is not our call to make twice. A crash loop leaves the
# box in the same state a dead daemon does — no engine, fail-closed plane standing
# — so the answer is the one the operator already gave with kill_switch, not a new
# policy invented here:
#
# open The operator asked for connectivity over interception. Stop the stack,
# exactly as shater_watchdog does for a sustained-dead daemon: the plane
# comes down and the LAN returns to plain routing. It also disarms the
# boot armor, so the NEXT boot is clean too instead of repeating the loop
# behind a closed LAN. Nothing else can end the loop: procd's retries are
# infinite by design.
# closed The operator asked for blocked-over-leaking. Blocked is what they get,
# and opening their LAN from a background loop would be the opposite of
# what the knob says. Report it loudly and let the person decide; the
# message names the one command that opens it.
#
# The list is POSITIVE and CLOSED, and the fall-through goes to `log`: an absent
# or unrecognised kill_switch is the model's documented default ("closed", see
# shater/model/model.go DefaultGlobals), and `log` is the recoverable side — it
# changes nothing and can be acted on, where a wrong `stop` silently drops a
# household onto the unproxied WAN.
#
# NOTE (not changed here, deliberately): shater_watchdog above answers the same
# question with `if closed ... else stop`, so for an ABSENT kill_switch it fails
# open — the opposite of the documented default. It is left alone because that
# behaviour predates this file's crash-loop work; it is reported upward instead.
shater_churn_action() {
local churn="$1" ks="$2"
[ "$churn" -ge "$LOOP_WINDOWS" ] || { echo none; return; }
case "$ks" in
open) echo stop ;;
closed) echo log ;;
*) echo log ;;
esac
}
# Sleep out one tick in LOOP_POLL slices, sampling the daemon's identity as we go.
# Publishes CHURN_SAMPLES / CHURN_N / CHURN_ANNOUNCED for the next pass of loop().
# Deliberately NOT a subshell (globals must survive), and it always returns 0 so a
# false `[ -f ]` at the end cannot look like a failure.
shater_tick_sample() {
local slept=0
CHURN_SAMPLES=""
CHURN_N=0
CHURN_ANNOUNCED=0
while [ "$slept" -lt "$TICK" ]; do
sleep "$LOOP_POLL"
slept=$(( slept + LOOP_POLL ))
CHURN_SAMPLES="$CHURN_SAMPLES $(shater_sample_pid)"
CHURN_N=$(( CHURN_N + 1 ))
[ -f "$RESTART_FLAG" ] && CHURN_ANNOUNCED=1
[ -f "$ACTIVE_FLAG" ] || CHURN_ANNOUNCED=1
done
return 0
}
# loop: the foreground body supervised by procd. Never exits on its own — it
# idles while disabled/inactive so procd is not respawn-churned by a
# self-exiting body when the stack is off.
@@ -274,7 +502,12 @@ loop() {
# the flock immediately and keeps children (sleep/shaterd) from inheriting
# it. A no-op where fd 1000 is not open (older procd.sh without procd_lock).
exec 1000>&-
local changed dead=0
local changed dead=0 churn=0 quiet=0 scan gens absent ks act
# No tick has been sampled yet on the first pass; shater_churn_verdict scores
# an empty tick as 0 rather than guessing.
CHURN_SAMPLES=""
CHURN_N=0
CHURN_ANNOUNCED=0
mkdir -p "$STAMP_DIR"
while :; do
if shater_enabled && shater_active; then
@@ -299,10 +532,44 @@ loop() {
"$SHATERD" schedule due >/dev/null 2>&1
fi
dead=$(shater_watchdog "$dead")
# Crash-loop verdict on the tick that has just elapsed. Unquoted on
# purpose: CHURN_SAMPLES is a whitespace-separated token list and word
# splitting is how it becomes arguments.
scan=$(shater_churn_scan $CHURN_SAMPLES)
gens=${scan%% *}
absent=${scan##* }
churn=$(shater_churn_verdict "$gens" "$CHURN_N" "$CHURN_ANNOUNCED" "$churn")
ks=$(uci -q get shater.globals.kill_switch)
act=$(shater_churn_action "$churn" "$ks")
case "$act" in
stop)
_slog -p daemon.crit \
"shaterd is CRASH-LOOPING: $gens distinct daemons in the last ${TICK}s (absent in $absent of $CHURN_N samples), $churn such windows in a row — it is being respawned faster than it can bring an engine up. kill_switch=open, so shater is being STOPPED: interception comes down and the LAN returns to plain, UNPROXIED routing. Find the reason with 'logread -e shaterd', then '/etc/init.d/shater start'."
"$SHATER_INIT" stop
churn=0
quiet="$LOOP_REPORT_TICKS"
;;
log)
# Rate-limited: a standing condition, not an event. Never
# silent for good, though — an operator who looks at the log an
# hour later must still find it being said.
if [ "$quiet" -le 0 ]; then
_slog -p daemon.crit \
"shaterd is CRASH-LOOPING: $gens distinct daemons in the last ${TICK}s (absent in $absent of $CHURN_N samples), $churn such windows in a row — it is being respawned faster than it can bring an engine up. kill_switch=${ks:-closed} keeps the fail-closed plane standing, so the LAN stays blocked and the admin panel is down with the daemon that serves it. Nothing is decided for you: find the reason with 'logread -e shaterd', or open the LAN with '/etc/init.d/shater stop'."
quiet="$LOOP_REPORT_TICKS"
fi
churn=0
;;
esac
[ "$quiet" -gt 0 ] && quiet=$(( quiet - 1 ))
else
dead=0
churn=0
quiet=0
fi
sleep "$TICK"
# Sleeps out the tick, sampling the daemon's identity while it does.
shater_tick_sample
done
}
@@ -36,31 +36,344 @@ mkdir -p /etc/shater
# transaction, and any /etc/init.d/* invocation in that window risks blocking
# the transaction on rc.common's per-service flock (procd_lock).
# Seed the built-in preset packs (disabled) so the LuCI Rules page renders their
# toggles. Idempotent: only creates a section that does not yet exist.
seed_preset() {
local sid="$1" name="$2" s n
uci -q get "shater.$sid" >/dev/null 2>&1 && return 0
# A pack section may already exist under a DIFFERENT section id (created by
# the LuCI seeding or an older release) — match by pack name, not just id,
# or we would duplicate the toggle.
for s in $(uci -q show shater 2>/dev/null | sed -n "s/^shater\.\([^.=]*\)=preset$/\1/p"); do
n=$(uci -q get "shater.$s.name")
[ "$n" = "$name" ] && return 0
# NO preset packs are seeded here, and the ones older releases seeded are removed.
#
# Until now this script created three `config preset` sections (block_ads,
# ru_bypass, private; all `enabled=0`) "so the LuCI Rules page renders their
# toggles". Both halves of that stopped being true in v0.2, and what was left was
# a knob wired to nothing:
#
# * `preset` IS NOT A SECTION TYPE. The type switch in model.ParseUCIExport
# (shater/model/uci.go) has no `preset` branch, and an unknown section type is
# dropped on the floor rather than rejected — TestUnknownSectionAndOptionIgnored
# pins that a config carrying one still parses, because the daemon has to come
# up on whatever it finds. So `uci set shater.block_ads.enabled=1; uci commit`
# edited the file and changed NOTHING about the running router, and there was
# no error anywhere to say so. Nothing in shater/, panel/ or luci-app-shater
# reads the type either.
# * v0.2's luci-app-shater is a launcher for the daemon's own admin panel. There
# is no LuCI Rules page for the toggles to appear on.
# * The sections did not even persist. model.writeUCIWith replaces the WHOLE
# package (`uci delete shater` + `uci import`), so the first save from the
# panel deleted all three. A placeholder that erases itself reads as "this
# broke", not as "this was never here" — which is worse than its absence.
#
# So the packs are not "missing": nothing in v0.2 lost a feature when the sections
# stopped being written, because nothing ever read them. And re-adding the seed is
# not how presets would come back. v0.1's packs were xray `geosite:`/`geoip:`
# matcher lists materialised into synthetic rules (`xrayctl/preset.go` on the v0.1
# branch); in v0.2 a rule's destination IS a `config ruleset` (schema v2), so the
# same pack is an ordinary ruleset + rule — which the panel's Routing page already
# builds, geosite/geoip sources included. Anything richer needs a section type the
# parser knows about, which has to land in shater/model FIRST; seeding UCI ahead
# of the parser only produces silence.
#
# The purge is narrow and safe by construction: it matches on the section TYPE
# being exactly `preset`, and that type is read by no consumer, so there is no
# setting to lose. Bounded and re-querying each round because a `config preset`
# may also be ANONYMOUS (`shater.@preset[0]`), where deleting from a list captured
# up front would shift the remaining indices out from under it.
purge_presets() {
local s n=0 changed=""
[ -f /etc/config/shater ] || return 0
while [ "$n" -lt 32 ]; do
s=$(uci -q show shater 2>/dev/null |
sed -n 's/^shater\.\([^.=]*\)=preset$/\1/p' | head -n 1)
[ -n "$s" ] || break
uci -q delete "shater.$s" || break
changed=1
n=$((n + 1))
done
uci set "shater.$sid=preset"
uci set "shater.$sid.name=$name"
uci set "shater.$sid.enabled=0"
[ -n "$changed" ] && uci -q commit shater
return 0
}
if uci -q get shater.globals >/dev/null 2>&1 || [ -f /etc/config/shater ]; then
seed_preset block_ads block-ads
seed_preset ru_bypass ru-bypass
seed_preset private private
uci -q commit shater
fi
purge_presets
# Introduce the daemon-created `shater-l3*` TUN to fw4 (L3 ingress, D-L3). The
# daemon policy-routes LAN ICMP into that device from OUR nft table
# `inet shater`, but nftables runs EVERY table on every packet and a drop in
# any one of them wins — an accept in `inet shater` cannot override fw4. And
# fw4 WILL drop this forward: netifd knows nothing about a device the daemon
# creates at runtime, so it belongs to no zone and falls into fw4's zone-less
# defaults (REJECT). The device has to be declared to fw4 itself; it cannot be
# fixed from our own table.
#
# Seeded UNCONDITIONALLY (not gated on globals.l3_tunnel): uci-defaults run
# once, so gating on the option would require re-running this script when the
# option is flipped later — which never happens. An idle zone is harmless: its
# device match is a plain iifname/oifname STRING compare that simply never hits
# while the TUN does not exist.
#
# Idempotency: `config zone`/`config forwarding` are normally ANONYMOUS
# sections, and a naive `uci add firewall zone` would append a duplicate on
# every re-run (uci-defaults re-run on package upgrade/reinstall). The zone is
# NAMED instead, guarded by an existence check — a re-run re-finds the section
# and touches nothing. The forwardings are named too where the name is free, but
# their guard is a scan of the actual src/dest pairs, which is stronger; see
# seed_l3_forwarding below.
seed_l3_zone() {
# No fw4 on this image (bare nftables build) => nothing drops the forward
# on fw4's behalf and there is nothing to punch through.
[ -f /etc/config/firewall ] || return 0
if ! uci -q get firewall.shater_l3 >/dev/null; then
uci set firewall.shater_l3=zone
uci set firewall.shater_l3.name='shater_l3'
uci set firewall.shater_l3.input='REJECT'
uci set firewall.shater_l3.output='ACCEPT'
uci set firewall.shater_l3.forward='REJECT'
uci set firewall.shater_l3.masq='0'
# INERT TODAY, kept for the day it is not. mtu_fix clamps forwarded TCP
# MSS to the route MTU — but the L3 TUN is 65535 (deliberately: at any
# smaller value the kernel fragments into the device, and the flow
# dispatcher refuses to judge a fragment and lets the stack forge the
# echo reply — see l3MTU in shater/generate/inbound.go), so the clamp has
# nothing to clamp to. And only ICMP is ever marked into this device, so
# no TCP rides here to be clamped in the first place. It earns its keep
# the moment either of those changes; removing it would make that day
# silent.
uci set firewall.shater_l3.mtu_fix='1'
# `list device`, deliberately NOT the usual `list network`: fw4
# resolves a zone's networks through netifd, and netifd never learns
# about a device the daemon creates at runtime — a stub interface
# (proto none) would need to be brought UP to contribute an l3_device,
# and nothing ever brings it up, so `list network` resolves to an
# EMPTY device set and fw4 keeps dropping the forward. `list device`
# instead compiles to an iifname/oifname STRING match, valid before
# the TUN exists and matching from the moment shaterd creates it —
# no netifd involvement and no firewall reload at enable time. Do not
# "normalize" this to `list network` in a refactor; it breaks silently.
#
# The WILDCARD is load-bearing too. The daemon no longer opens one fixed
# device: it alternates between `shater-l3a` and `shater-l3b` so that a
# new engine generation never has to reopen the name the previous one is
# still holding (that collision — TUNSETIFF: device or resource busy —
# took the whole LAN down on the production router, because the recovery
# path rebuilt the same config and hit the same busy name). fw4 compiles
# `shater-l3*` to `iifname "shater-l3*"` / `oifname "shater-l3*"`,
# verified on ImmortalWrt 25.12.1 with nftables 1.1.6, so ONE zone covers
# every slot and no firewall reload is needed when the slot changes.
uci add_list firewall.shater_l3.device='shater-l3*'
fi
seed_l3_forwardings
uci -q commit firewall
}
# EVERY zone gets a forwarding into shater_l3, not just `lan`.
#
# The bug this closes is silent by construction. The daemon's divert set is built
# from every enabled `config inbound`'s network PLUS every device a rule names
# through an `iface:`/`zone:` source (shater/netplane/nft.go, nftDivertRefs) — so
# on a router with several LAN zones, ICMP from ALL of them is marked and routed
# into the TUN by our table. Our table then accepts it and fw4 drops it anyway,
# because the forward is judged in `forward_<source zone>` and only `lan` had a
# jump to `accept_to_shater_l3`. Result: ping through the tunnel works from one
# subnet and not from the next, with nothing in any log to say why — fw4's drop
# is the zone's policy verdict, not a rule with a name. The owner's production
# router has a single LAN zone, which is exactly why this went unnoticed; his
# second router has three.
#
# Every zone, including an uplink zone, and that is deliberate rather than lazy:
#
# - The alternative is guessing which zones hold clients, and every available
# signal is wrong somewhere. `masq='1'` marks the WAN on a stock config and
# also marks a double-NAT LAN. The name `wan*` is a convention, not a rule.
# A guess that is wrong reintroduces exactly the silent breakage above, while
# a superfluous entry costs a line of ruleset.
# - A forwarding into shater_l3 permits nothing on its own. It authorises the
# forward of packets ROUTED INTO the TUN, and the only thing that routes a
# packet there is our own fwmark rule, which matches solely on the divert
# device set. A packet arriving on the WAN is not marked and never reaches
# this decision; if an operator ever puts a WAN device in the divert set,
# they meant to and this is the entry that makes it work.
# - The reverse direction is NOT opened: no `src shater_l3` forwarding exists,
# so nothing comes out of the TUN into a zone by way of these sections. The
# engine's own replies return on the conntrack `established,related accept`
# at the top of fw4's forward chain.
#
# LIMIT, stated because it is not obvious: this is a SNAPSHOT. uci-defaults run
# at first boot and on package install/upgrade, so a zone created AFTER the last
# shater-core install has no forwarding until the next one. Re-running this
# script (or reinstalling the package) re-seeds. The durable fix belongs in the
# daemon, which recomputes the divert set on every apply and already knows which
# zones are in it; it is deliberately not attempted from here.
seed_l3_forwardings() {
uci -q show firewall 2>/dev/null |
sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p" |
while read -r sid; do
zone=$(uci -q get "firewall.$sid.name")
# Unnamed zone: fw4 cannot reference it from a forwarding either.
[ -n "$zone" ] || continue
# Our own zone: a forwarding from shater_l3 to itself is meaningless.
[ "$zone" = "shater_l3" ] && continue
seed_l3_forwarding "$zone"
done
}
# One `config forwarding` <zone> -> shater_l3, created only if no such forwarding
# exists yet.
#
# The guard scans the ACTUAL src/dest pairs rather than trusting a section id,
# which covers all three ways one can already be there: the legacy named section
# `shater_l3_fwd` seeded by earlier releases (src=lan), the per-zone names this
# function writes, and an anonymous one an operator added by hand. Without that,
# a re-run — uci-defaults re-run on every package upgrade — would append a
# duplicate for `lan` on every upgrade.
seed_l3_forwarding() {
local zone="$1" sid found
found=$(uci -q show firewall 2>/dev/null |
sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p" |
while read -r f; do
[ "$(uci -q get "firewall.$f.dest")" = "shater_l3" ] || continue
[ "$(uci -q get "firewall.$f.src")" = "$zone" ] || continue
echo yes
break
done)
[ -n "$found" ] && return 0
# Section ids are [a-zA-Z0-9_] only, while a zone name may legally carry a
# hyphen — sanitise, and keep the legacy id for `lan` so an existing install
# is recognised as already seeded rather than gaining a second section.
if [ "$zone" = "lan" ]; then
sid="shater_l3_fwd"
else
sid="shater_l3_fwd_$(printf '%s' "$zone" | sed 's/[^a-zA-Z0-9_]/_/g')"
fi
# The id may still be taken — by a section for a DIFFERENT zone whose name
# sanitises to the same thing, or by something else entirely. Fall back to an
# anonymous section rather than overwrite: the src/dest scan above is what
# makes this idempotent, the name is only there to be readable.
if uci -q get "firewall.$sid" >/dev/null; then
sid=$(uci add firewall forwarding) || return 0
else
uci set "firewall.$sid=forwarding"
fi
uci set "firewall.$sid.src=$zone"
uci set "firewall.$sid.dest=shater_l3"
}
seed_l3_zone
# Upgrade path for routers seeded by a pre-slot build.
#
# The block above only runs when the zone does NOT exist, which is exactly right
# for idempotency and exactly wrong here: an already-installed router has the
# zone with the OLD exact device `shater-l3`, that name matches no slot, and fw4
# would go back to dropping the forward — i.e. LAN ping through the tunnel dies
# silently on upgrade while everything reports healthy. Rewrite it in place.
#
# Narrow on purpose: only the literal legacy entry is replaced, and only when the
# wildcard is not already listed, so an operator who added devices of their own
# keeps them and a re-run changes nothing (uci-defaults re-run on every package
# upgrade). No `fw4 reload` here — uci-defaults run before the firewall starts on
# boot, and on a package upgrade the daemon's next apply is what needs the zone,
# not this script.
migrate_l3_zone_wildcard() {
[ -f /etc/config/firewall ] || return 0
uci -q get firewall.shater_l3 >/dev/null || return 0
devs=$(uci -q get firewall.shater_l3.device) || return 0
case " $devs " in
*" shater-l3* "*) return 0 ;; # already migrated
*" shater-l3 "*) ;; # legacy exact name present
*) return 0 ;;
esac
uci -q del_list firewall.shater_l3.device='shater-l3'
uci add_list firewall.shater_l3.device='shater-l3*'
uci -q commit firewall
}
migrate_l3_zone_wildcard
# Bring the UCI schema forward on upgrade (idempotent; refuses a newer schema).
[ -x /usr/bin/shaterd ] && /usr/bin/shaterd migrate >/dev/null 2>&1
#
# THE RESULT IS NO LONGER THROWN AWAY. `>/dev/null 2>&1` discarded stdout, stderr
# AND the exit status, so a refusal here was indistinguishable from success — on
# the one screen the operator who caused it is actually looking at.
#
# WHY THIS STILL `exit 0`s (the script ends with one, and this block does not
# change that). A uci-defaults script that exits non-zero is NOT deleted and runs
# again at every boot. That is the wrong trade here, three times over:
#
# * This file does far more than migrate — it seeds rt_tables, the shater_l3
# fw4 zone and its per-zone forwardings, applies sysctl, and launches a
# DETACHED BRING-UP that enables and RESTARTS shater/shater-cron and reloads
# the firewall. Re-running all of that at every boot to carry one bit of "the
# migration failed" would bounce the tunnel on every boot, after S99 had
# already started it. One recoverable failure would become permanent churn.
# * The exit status is not a reporting channel: nothing reads a uci-defaults
# script's status, and neither procd nor the package manager surfaces it. It
# buys no diagnosis, only the re-run.
# * The retry it would buy already exists, and is better. /etc/init.d/shater
# runs the same migration on EVERY start with the full classified report, so
# a failure is retried at every boot and every restart regardless. And
# re-running cannot fix either real cause anyway: a downgrade is fixed by
# installing the right package, a full /overlay by freeing space.
#
# So: capture the status, name the cause, record it durably, and exit 0. The
# script has done everything it can do, and the failure is not lost.
#
# The classification is the same closed positive list /etc/init.d/shater uses
# (shater_migrate_class there, with the long argument for why the list is closed
# and why `downgrade` is matched on the binary's own words). It is repeated here
# rather than shared because the package installs no shell library the two could
# both source; TestMigrateClassifiersAgree (shater/cmd/shaterd) runs both over
# the same inputs and fails if they ever disagree.
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
shater_migrate_class() {
local rc="$1" out="$2"
[ "$rc" = "0" ] && { echo ok; return 0; }
case "$out" in
*"newer than this build"*) echo downgrade; return 0 ;;
esac
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
echo failed
}
# stderr, unconditionally: inside `apk add` / `opkg install` that is the package
# manager's own output, i.e. the installing operator's screen. Plus the durable
# breadcrumb on flash, which /etc/init.d/shater removes on the first successful
# migration. Deliberately NOT syslog — globals.log_syslog may be off by the
# operator's choice, and this path honours it by using other channels instead.
shater_migrate_shout() {
echo "shater: $*" >&2
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
}
run_migrate() {
local out rc class schema
[ -x /usr/bin/shaterd ] || return 0
out=$(/usr/bin/shaterd migrate 2>&1)
rc=$?
class=$(shater_migrate_class "$rc" "$out")
schema=$(uci -q get shater.globals.schema_version)
case "$schema" in ""|*[!0-9]*) schema=0 ;; esac
case "$class" in
ok)
rm -f "$SHATER_MIGRATE_BREADCRUMB"
;;
downgrade)
shater_migrate_shout "install: UCI schema migration REFUSED — /etc/config/shater is schema v$schema and the shater build being installed understands an older one, so this is a DOWNGRADE. Nothing was migrated and nothing on disk was changed: your settings are intact, and also unchangeable, because the daemon and the panel refuse every config write for the same reason. Install a build that understands schema v$schema again (docs-shater/INSTALL.md has the pinned per-version feed). 'shaterd migrate' said: ${out:-no output}"
;;
unreadable)
shater_migrate_shout "install: UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so the schema on disk cannot even be named. Check the file by hand before configuring anything. 'shaterd migrate' said: ${out:-no output}"
;;
failed)
shater_migrate_shout "install: UCI schema migration FAILED for a reason this script does not recognise; the config is still at schema v$schema. The mundane cause is a full /overlay ('uci commit' cannot write) — check 'df /overlay'. /etc/init.d/shater retries this on every start and reports it there too. 'shaterd migrate' said: ${out:-no output}"
;;
*)
# Unreachable: shater_migrate_class returns a closed set, all of it
# handled above. Named rather than swept up, so a script that disagrees
# with itself says so instead of picking a confident branch and being
# wrong quietly.
shater_migrate_shout "install: INTERNAL — 'shaterd migrate' produced a result this script cannot classify (class='$class', rc=$rc). That is a bug in /etc/uci-defaults/30_shater-core. Output was: ${out:-no output}"
;;
esac
return 0
}
run_migrate
# Apply our sysctl knobs NOW (boot applies them via procd's sysctl service, but
# on a live opkg/apk install nothing else re-reads sysctl.d — without this, an
@@ -110,8 +423,27 @@ SHATER_BRINGUP='
done
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron enable
# The boot-time fail-closed armor. `enable` only — it is a one-shot that loads
# the persisted holding plane at START=21, and running it NOW would install a
# block on a live box moments before the daemon replaces it anyway. It has to
# be enabled here regardless of whether the stack is on: the file it loads only
# exists while the daemon wants it to, so an enabled-but-unarmed service is a
# no-op, and enabling it later would mean the first boot after an upgrade is
# the one boot still exposed.
[ -x /etc/init.d/shater-armor ] && /etc/init.d/shater-armor enable
[ -x /etc/init.d/shater ] && /etc/init.d/shater restart
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart
# Fold the seeded shater_l3 zone into the LIVE ruleset — matters on a live
# opkg/apk install only, where firewall started long before our commit and
# nothing else would re-read it until the next reboot. Gated on the fw4
# table actually being loaded: at FIRST boot this job can run before the
# S19 firewall start, and an early reload would install a ruleset built
# from a half-initialized netifd AND make the later start a no-op (fw4
# start skips when its table already exists). No table => the pending S19
# start reads the committed config by itself, no reload needed.
if nft list tables 2>/dev/null | grep -q "inet fw4"; then
[ -x /etc/init.d/firewall ] && /etc/init.d/firewall reload
fi
exit 0
'
SHATER_TMO=""
@@ -0,0 +1,80 @@
# /lib/upgrade/keep.d/shater-core — what sysupgrade and LuCI "Backup" must carry
# out of /etc/shater.
#
# HOW THIS FILE IS READ. /sbin/sysupgrade (base-files, list_static_conffiles):
#
# find $(sed -ne '/^[[:space:]]*$/d; /^#/d; p' \
# /etc/sysupgrade.conf /lib/upgrade/keep.d/* 2>/dev/null) \
# \( -type f -o -type l \) $filter 2>/dev/null
#
# so blank lines and lines starting with '#' are stripped, and every other line is
# a path handed to `find`: a directory is recursed, a path that does not exist is
# silently skipped (hence a trailing '/' for the two directories, and no need to
# guard for a fresh install that has neither). The result is tarred and, on a real
# sysupgrade, HELD IN RAM across the flash — which is why this is a per-file
# decision and not simply "/etc/shater/".
#
# WHY IT EXISTS. Everything the product knows besides /etc/config/shater lives in
# /etc/shater, and nothing shipped a keep.d entry for it. A "keep settings"
# sysupgrade, or a LuCI backup restored onto a new router, therefore produced a
# box whose config looked complete and whose node inventory was EMPTY — silently.
#
# /etc/config/shater is NOT listed here: it is declared in
# Package/shater-core/conffiles, and sysupgrade backs CHANGED conffiles up on its
# own (list_changed_conffiles). Listing it again would work, but it would claim
# ownership of a mechanism that already covers it.
# THE NODE INVENTORY. Subscription-fetched nodes deliberately live OUTSIDE UCI
# (shater/model/subcache.go) — one JSON file per subscription. Without them the
# restored box has groups and rules that reference nodes which do not exist, so no
# tunnel comes up, and the only repair is `sub update`, which needs the internet
# the tunnel was supposed to be providing. Indented JSON: a few hundred KiB even
# for a several-hundred-node subscription.
/etc/shater/subs/
# THE BOOT-ARMOR ARM TOKEN. Its PRESENCE is what lets /etc/init.d/shater-armor
# (START=21) load the fail-closed plane before fw4's `lan -> wan ACCEPT` is the
# only rule on the box. Without it the first boot after a restore forwards LAN to
# WAN in the clear until the daemon has built an engine. One small nft script.
/etc/shater/boot.nft
# COMPILED LIST ARTIFACTS (.srs). Losing these fails SILENTLY in the worst
# direction: a missing LOCAL rule-set is left out of the generated config and the
# engine starts perfectly happily with the filtering simply gone
# (shater/generate/ruleset.go, compiledListRuleSet). "It will re-download itself"
# is NOT true for them either — a compiled url list is rebuilt only by the next
# generate, and nothing schedules one (see the note in /etc/init.d/shater-cron
# about `ruleset update`). Cheap to keep: compiled .srs is 3-6% of the source
# text (~80 KiB for a 150k-domain list), under a 4 MiB soft cap.
/etc/shater/lists/
# ALERT DE-DUPLICATION STATE. A few hundred bytes mapping subscription -> when its
# expiry warning last fired. Without it every subscription already announced
# announces itself again on the restored box — the exact re-alert storm the file
# was created to prevent (shater/alert/expiry.go).
/etc/shater/alert-state.json
# DELIBERATELY NOT KEPT. Each of these is history or cache, and the backup is
# built in RAM:
#
# /etc/shater/stats.db Traffic/query HISTORY, not configuration. Bounded
# only by globals.stats_disk_limit_mb, whose default is
# 64 MB and whose 0 means UNLIMITED — one file able to
# outweigh everything else here by two orders of
# magnitude, and the only entry whose loss costs the
# operator nothing but a chart.
# /etc/shater/cache.db sing-box's own cache (8 MiB cap, deleted above it).
# Rebuilt on demand by design, and a stale rule-set
# cache carried onto a different box is worse than no
# cache at all.
# /etc/shater/shaterd.log A log (capped by globals.log_max_kb). A restored box
# wants its own log, and this one carries the DNS query
# history of the box it came from — which is not
# something to move into an archive a person then puts
# somewhere else.
#
# ON SECRECY, since this archive routinely ends up in cloud storage: subs/*.json
# carries every node's credentials (UUID/password/keys). That is not a NEW
# exposure — /etc/config/shater already carries the subscription URLs and every
# manual node's credentials, and it is already in the backup as a conffile — but a
# shater backup is a secret-bearing file and should be treated as one.
+13 -4
View File
@@ -34,8 +34,17 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=shaterd
PKG_VERSION:=0.2.0
PKG_RELEASE:=2
# VERSIONING — derived from the git tag, NOT hand-maintained here (bug B4).
# ci/version.sh turns `git describe` into SHATER_PKG_VERSION/SHATER_PKG_RELEASE
# (tag vX.Y.Z -> X.Y.Z + r1; off-tag -> last tag + r<commits+1>), and
# ci/build-feed-apk.sh exports them into the SDK build env. ci/sdk-build-apk.sh
# then ASSERTS that the produced .apk really carries that version, so a lost env
# can never silently ship a stale one again.
# The literals below are ONLY the manual/offline fallback (no CI, no git) — they
# are not "the release version"; releases are named by the tag.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-3.0-or-later
@@ -95,8 +104,8 @@ define Package/shaterd/install
$(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd
endef
# This package ships ONLY the binary — no init script — so opkg's default
# postinst never touches the running service. On `opkg upgrade shaterd` the new
# This package ships ONLY the binary — no init script — so the package manager's
# postinst never touches the running service. On `apk upgrade shaterd` the new
# ELF lands at /usr/bin/shaterd while the OLD image keeps running from its
# unlinked inode: the upgrade silently has no effect until the next reboot, and
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
+26
View File
@@ -18,6 +18,32 @@ type URLTestOutboundOptions struct {
// lx: SPEC 019 v2 — load-balancing.
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
// lx: health board §5.C — SelfCheck stands the group's OWN background
// health-check up or down. nil/absent == true, so every existing config keeps
// today's behaviour.
//
// Why this exists at all: a urltest group probes its members BY ITSELF — a
// warm-up sweep at PostStart and a ticker for as long as traffic keeps
// touching it — and it dials the members' outbounds DIRECTLY, from the
// router, over whatever the default WAN route is. For a group that traffic
// actually flows through, that is exactly right: the probe travels the same
// path the connections do. But for a group NO routing rule reaches, that
// same probe measures a path nothing uses — and it stores the result under
// the members' BASE tags, which every health consumer then reads as "the
// node's health". A node that is blocked on the direct WAN and perfectly
// alive behind a tunnel therefore reads "dead" the moment such a group
// probes it; the reading is not merely stale, it is FALSE, and it poisons
// the shared board for everyone (selection, the panel, the observatory's
// freshness gate). SelfCheck=false is how the control plane stands such a
// group's own schedule down: the shater engine computes which groups the
// applied rules actually reach (the observatory's used-set) and disables
// the self-check on the rest, so the ONLY prober left is the observatory —
// which probes along the real dial paths and nothing else.
//
// The flag suppresses only the group's own SCHEDULE (the PostStart warm-up
// and the Touch ticker). An EXPLICIT CheckOutbounds/URLTest call — the
// adapter interface a human or an API invokes on purpose — still works.
SelfCheck *bool `json:"self_check,omitempty"`
}
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --test src/*.test.ts"
},
"dependencies": {
"react": "^18.3.1",
+381
View File
@@ -262,6 +262,150 @@
}
}
/* ---- fixture band (dev builds only; see App.tsx MockBanner) ----
Deliberately outside the crit/amber vocabulary: nothing is wrong with the
router, there is no router. The hazard hatch is the service-sticker language a
piece of network hardware already uses for "this unit is not in service". */
.mock-band {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
margin-top: calc(var(--u, 8px) * 2);
padding: 10px 14px;
border: 1px dashed var(--faint);
border-radius: 9px;
background: repeating-linear-gradient(
-45deg,
var(--sink),
var(--sink) 9px,
var(--panel) 9px,
var(--panel) 18px
);
}
.mock-band-tag {
flex-shrink: 0;
align-self: flex-start;
padding: 3px 7px;
border: 1px solid var(--faint);
border-radius: 4px;
background: var(--raised);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--dim);
}
.mock-band-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mock-band-headline {
font-family: var(--font-mono);
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
}
.mock-band-detail {
font-size: 12.5px;
line-height: 1.5;
color: var(--dim);
max-width: 76ch;
}
.mock-band-detail code {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--ink);
}
/* ---- commit-confirm band (every page except Apply, which has the full panel) ----
Same plate as the protection banner so the two read as one family; the seconds
are the loud element because they are the only thing that is running out. */
.cfm-band {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
margin-top: calc(var(--u, 8px) * 2);
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--groove));
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 10%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
.cfm-band-count {
display: flex;
align-items: baseline;
gap: 2px;
flex-shrink: 0;
font-family: var(--font-mono);
color: var(--amber);
}
.cfm-band-num {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.cfm-band-unit {
font-size: 11px;
letter-spacing: 0.06em;
}
.cfm-band-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.cfm-band-headline {
font-family: var(--font-mono);
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
}
.cfm-band-detail {
font-size: 12.5px;
line-height: 1.5;
color: var(--dim);
max-width: 76ch;
}
.cfm-band-actions {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1);
flex-shrink: 0;
}
.cfm-band-link {
padding: 6px 11px;
border: 1px solid var(--groove);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: none;
color: var(--ink);
background: var(--raised);
}
.cfm-band-link:hover {
border-color: var(--accent);
color: var(--accent);
}
@media (max-width: 720px) {
.cfm-band {
flex-wrap: wrap;
}
.cfm-band-actions {
width: 100%;
justify-content: flex-end;
}
}
/* ---- last-apply findings (Overview) ----
Severity carries the colour; the accent is reserved for interactive controls. */
.findings {
@@ -288,6 +432,18 @@
letter-spacing: 0.06em;
color: var(--faint);
}
/* Findings come from the last SUCCESSFUL apply. When the config on disk was
refused, that is a different configuration from the one the reader just saved —
and under a heading reading "Last apply" a clean list means "your edit is
fine". A sentence about the whole list, so it gets its own line above it rather
than a third cell in the header, where 390 px left it four words a column. */
.findings-stale {
margin: calc(var(--u, 8px) * 1.25) 0 0;
max-width: 76ch;
font-size: 12px;
line-height: 1.5;
color: var(--crit);
}
.findings-list {
margin: calc(var(--u, 8px) * 1.5) 0 0;
padding: 0;
@@ -312,6 +468,13 @@
.finding--warning {
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
}
/* The daemon's "the list is capped" disclosure. Dashed, because the row is about
what ISN'T here — it must not read as one more finding to work through. */
.finding--truncated {
border-style: dashed;
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
background: var(--panel);
}
.finding-copy {
flex: 1;
min-width: 0;
@@ -405,3 +568,221 @@
color: var(--dim);
max-width: 74ch;
}
/* ---- inline rename (shared) ----
The pencil-in-the-row interaction: click the ✎ beside a name, type over it,
Enter commits / Esc cancels / blur commits. Lifted out of Devices.css when
Nodes grew the same affordance — one interaction, one set of rules, so the two
pages can never drift apart. `--locked` is the same control with the action
withheld: it stays visible and focusable-looking so a missing rename reads as
a stated rule, not a dead button. */
.inline-rename {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 5px;
background: none;
color: var(--faint);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.inline-rename:hover:not(:disabled) {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.inline-rename:focus-visible {
color: var(--accent);
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.inline-rename:disabled {
opacity: 0.5;
cursor: default;
}
/* Withheld, not broken: keep the glyph readable and let the cursor say "there is
a reason" rather than dimming it into invisibility. */
.inline-rename--locked {
opacity: 0.75;
cursor: help;
}
.inline-rename--locked:hover {
color: var(--dim);
background: none;
}
.inline-rename-input {
min-width: 0;
max-width: 24ch;
padding: 4px 8px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--sink);
color: var(--ink);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 1px 2px var(--shadow) inset;
}
.inline-rename-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.inline-rename-input:disabled {
opacity: 0.55;
}
/* ---- pre-apply hazard band (Apply.tsx ApplyRiskBand ← planeState.applyRisk) ----
Shown on Apply and Overview when pressing the button below would leave the
network with no way out.
It borrows the CRIT vocabulary because the outcome really is crit-severity, but
a forecast must not be mistaken for an observed fault — the panel's other crit
bands all report something that has already happened. Two things keep them
apart: the band leads with an eyebrow naming the tense, and it ends in the
ACCENT rather than in more red. Crit says how bad this is; the accent marks the
controls that prevent it. Deliberately taller and quieter-edged than
.plane-band, because unlike a banner this one is meant to be read, not
glanced at. */
.risk-band {
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
margin-top: calc(var(--u, 8px) * 2);
padding: 14px 16px 15px;
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
border-left: 3px solid var(--crit);
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
.risk-band-hd {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.25);
}
.risk-eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 700;
letter-spacing: var(--track-label-wide, 0.24em);
text-transform: uppercase;
color: var(--crit);
}
.risk-headline {
margin: 0;
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.35;
color: var(--ink);
}
.risk-detail,
.risk-undo {
margin: 0;
max-width: 76ch;
font-size: 12.5px;
line-height: 1.55;
color: var(--dim);
}
/* The missing auto-rollback is the part that decides whether a mistake here costs
two minutes or an SSH session, so it is the one line drawn at full ink. */
.risk-undo.hot {
color: var(--ink);
font-weight: 600;
}
.risk-steps {
margin: calc(var(--u, 8px) * 0.5) 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
max-width: 76ch;
}
.risk-steps li {
position: relative;
padding-left: 18px;
font-size: 12.5px;
line-height: 1.55;
color: var(--ink);
}
/* A square tick in the accent — the panel's "this is a control you touch" colour,
and the only accent in a band that is otherwise entirely red. */
.risk-steps li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 1px;
background: var(--accent);
}
@media (max-width: 560px) {
.risk-band {
padding: 12px 13px 13px;
}
.risk-detail,
.risk-undo,
.risk-steps li {
font-size: 12px;
}
}
/* ---- the config on disk is not the one running (Overview.tsx NotAppliedBand) ----
Same crit vocabulary and the same internals as .risk-band, and deliberately so:
the severity is identical. What separates them is the TENSE, which the eyebrow
states — the hazard band forecasts what a button would do, this one reports a
state the box is already in. So this band ends in crit rather than in the
accent: there is nothing to prevent any more, and the control that clears it is
an edit to the configuration, not a button on this page.
It sits ABOVE the findings because it says what the findings are about. */
.stale-band {
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
margin-top: calc(var(--u, 8px) * 2);
padding: 14px 16px 15px;
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
border-left: 3px solid var(--crit);
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
/* The step that refused — the one word an operator acts on, so it is the one
thing here drawn at full ink inside a line of body copy. */
.stale-stage {
color: var(--ink);
font-weight: 700;
}
/* The daemon's reason, verbatim. Monospaced because it is machine text quoted
into prose, and boxed so a long generator error cannot be mistaken for our own
sentence about it. */
.stale-cause {
padding: 8px 10px;
border: 1px solid var(--groove);
border-radius: 6px;
background: color-mix(in srgb, var(--sink) 75%, transparent);
font-size: 11.5px;
line-height: 1.5;
color: var(--ink);
overflow-wrap: anywhere;
}
@media (max-width: 560px) {
.stale-band {
padding: 12px 13px 13px;
}
.stale-cause {
font-size: 11px;
}
}
+145 -9
View File
@@ -1,12 +1,14 @@
import './App.css'
import { useCallback, useEffect, useState } from 'react'
import { Faceplate, FaceplateHeader, Led, Module } from './components'
import { Button, Faceplate, FaceplateHeader, Led, Module } from './components'
import type { LedVariant } from './components'
import { ApiError, MOCK, getStatus } from './api'
import { ApiError, MOCK, confirm as apiConfirm, getStatus } from './api'
import type { Status } from './api'
import { usePendingConfirm } from './pendingConfirm'
import { bootstrapSession } from './session'
import { ROUTES, navigate, useRoute } from './router'
import { protectionState } from './planeState'
import { engineState, protectionState, serviceIntent } from './planeState'
import { truncationNote } from './findings'
import type { Route } from './router'
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
@@ -99,12 +101,102 @@ export function App() {
footer={<StatusBar status={status} />}
>
<Nav route={route} />
<MockBanner />
<PlaneBanner status={status} route={route} />
<ConfirmBand route={route} onChanged={() => void refreshStatus()} />
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
</Faceplate>
)
}
/**
* The commit-confirm countdown, on every page.
*
* The daemon arms an auto-rollback on EVERY apply, but only the Apply page ever
* said so: press Apply on Routing, read "Applied", walk away, and the router
* reverts a minute later with nothing on screen having mentioned it. This band
* carries that deadline — and the button that stops it — to wherever the operator
* actually is.
*
* Suppressed on Apply, which renders the full control room for the same window
* (and reads the same record, so a reload no longer loses the countdown there
* either).
*/
function ConfirmBand({ route, onChanged }: { route: Route; onChanged: () => void }) {
const armed = usePendingConfirm()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Keeping the config is the only action offered here; rolling back early is a
// deliberate act with its own before/after readout, and that lives on Apply.
const keep = useCallback(async () => {
setBusy(true)
setError(null)
try {
const r = await apiConfirm()
if (r.error) setError(r.error)
} catch (e) {
setError(e instanceof Error ? e.message : 'request failed')
} finally {
setBusy(false)
onChanged()
}
}, [onChanged])
if (!armed || route === 'apply') return null
return (
<div className="cfm-band" role="alert">
<Led variant="amber" pulse />
<div className="cfm-band-count" role="timer" aria-label={`${armed.remaining} seconds until auto-rollback`}>
<span className="cfm-band-num">{armed.remaining}</span>
<span className="cfm-band-unit">s</span>
</div>
<div className="cfm-band-copy">
<span className="cfm-band-headline">This config is live but not kept</span>
<span className="cfm-band-detail">
{error
? `Couldn’t keep it — ${error}. Try again, or open Apply.`
: 'Every apply arms an auto-rollback. Keep this config before the timer runs out, or the router reverts to the last-good one.'}
</span>
</div>
<div className="cfm-band-actions">
<Button variant="primary" onClick={() => void keep()} disabled={busy}>
{busy ? 'Keeping…' : 'Keep this config'}
</Button>
<a className="cfm-band-link" href="#/apply" onClick={() => navigate('apply')}>
Apply page
</a>
</div>
</div>
)
}
/**
* Says, on every page, that nothing on screen came from a router.
*
* Only a DEV build can ever render this — the fixtures are not in a production
* bundle (api.ts initMockBackend), so an operator cannot reach this state at all.
* It is here for the person who CAN: a footer line reading "DEMO DATA" is easy to
* work past for an afternoon and then screenshot into a bug report, and every
* number above it is invented.
*/
function MockBanner() {
if (!MOCK) return null
return (
<div className="mock-band" role="status">
<span className="mock-band-tag">FIXTURES</span>
<div className="mock-band-copy">
<span className="mock-band-headline">No router is being read</span>
<span className="mock-band-detail">
Every reading on this page is invented by <code>src/mock.ts</code> for offline
development. Drop <code>?mock</code> from the address to talk to a daemon.
</span>
</div>
</div>
)
}
/**
* The protection state, pinned under the nav on every page EXCEPT Overview
* (which shows the same state as its own headline readout — see planeState.ts).
@@ -130,12 +222,15 @@ function PlaneBanner({ status, route }: { status: Status | null; route: Route })
const criticals = (status.warnings ?? []).filter((w) => w.severity === 'critical').length
const state = protectionState(status)
// The published list is capped at 50, so with a note attached the count is a
// floor. Say "at least" rather than quoting a total the daemon didn't send.
const atLeast = truncationNote(status.warnings) ? 'At least ' : ''
// Wording comes from the shared source of truth so the banner and Overview can
// never describe the same router differently.
const headline = state.alarm
? state.headline
: `${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply`
: `${atLeast}${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply`
const detail = state.alarm
? state.detail
: 'Something you configured isn’t in effect. Review the findings before relying on it.'
@@ -175,7 +270,9 @@ function Page({
if (route === 'dns') return <DNS />
if (route === 'targets') return <Targets />
if (route === 'devices') return <Devices />
if (route === 'insights') return <Insights />
// Insights takes `status` for ONE reason: so it can say that its ten empty
// sections are empty because the service is off. It polls its own stats.
if (route === 'insights') return <Insights status={status} />
if (route === 'settings') return <Settings />
if (route === 'profiles') return <Profiles />
if (route === 'apply') return <Apply />
@@ -225,14 +322,46 @@ function StatusBar({ status }: { status: Status | null }) {
)
}
/**
* The one lamp that is on screen no matter which page you are on.
*
* It used to read `status.running`, which the daemon hardcoded to `true` — so the
* "Offline" branch could never be reached and the plate said "Online" through an
* engine that had failed to start. It now asks {@link engineState}, whose whole
* job is to be able to answer "down", and refuses to guess when nothing has been
* reported: an unlit socket, not a green light.
*
* AND IT ASKS WHETHER THE ENGINE WAS SUPPOSED TO BE RUNNING. `engineState` alone
* cannot tell a crashed engine from one nobody started: `Status.running` is the
* DAEMON's liveness and `engine_running` is the ENGINE's, and neither is the
* operator's switch. So the lamp above every page on a correctly installed,
* not-yet-configured router was crit "Engine down" — the product's most visible
* lamp reporting a failure that had not happened. {@link serviceIntent} is the
* missing question, and only a positive `off` takes this branch: with the
* configuration unreadable the intent is `unknown` and the crit stands, because
* that is the case where the LAN really has been cut off.
*/
function masterIndicator(
phase: Phase,
status: Status | null,
): { label: string; variant: LedVariant; pulse?: boolean } {
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
if (status.running && status.active) return { label: 'Online', variant: 'on', pulse: true }
if (status.running) return { label: 'Standby', variant: 'amber' }
return { label: 'Offline', variant: 'crit' }
const engine = engineState(status)
if (engine === 'down' && serviceIntent(status) === 'off') {
// Named for the act, not the symptom: "Switched off" says a person did this
// and can undo it, where "Engine down" says the appliance broke.
return { label: 'Switched off', variant: 'off' }
}
switch (engine) {
case 'down':
return { label: 'Engine down', variant: 'crit' }
case 'up':
return status.active
? { label: 'Online', variant: 'on', pulse: true }
: { label: 'Standby', variant: 'amber' }
default:
return { label: 'Unknown', variant: 'off' }
}
}
function UnauthPlate() {
@@ -240,8 +369,15 @@ function UnauthPlate() {
<Faceplate ariaLabel="shater — not authenticated" header={<FaceplateHeader wordmark="SHATER" subline="v0.2 · openwrt appliance" />}>
<div className="plate-msg">
<Module name="Session" value="LOCKED" led={{ variant: 'amber' }}>
{/* THE ONLY RECOVERY INSTRUCTION THE PRODUCT GIVES, so it has to point at
the real menu entry. It said "System → shater"; the page is registered
at `admin/services/shater` (luci-app-shater/root/usr/share/luci/menu.d/
luci-app-shater.json, title "Shater"), which LuCI renders under
SERVICES. Anyone reading this line has just lost access to the panel
and is looking for the one door back — sending them to the wrong menu
costs far more than its size. */}
<p className="placeholder-note">
No active session. Open the panel from the LuCI menu (System → shater →{' '}
No active session. Open the panel from the LuCI menu (Services → Shater →{' '}
<strong>Open panel</strong>) to hand off a fresh access token.
</p>
</Module>
+181
View File
@@ -0,0 +1,181 @@
// Editing an alert channel — and, above all, editing a secret the panel refuses
// to show.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// What these protect:
//
// 1. AN EMPTY TOKEN BOX KEEPS THE STORED TOKEN. The row masks the bot token
// deliberately, so it cannot be prefilled; an empty box that meant "clear it"
// would destroy a secret on every save of an unrelated field, and the only
// way to get it back is BotFather. This is the project's rule — a save may
// clear only a field the editor was able to SHOW — applied to the one field
// that can never be shown.
// 2. A TYPED TOKEN STILL REPLACES. Otherwise the fix for a typo'd token is no
// fix at all.
// 3. THE OTHER TYPE'S SETTINGS SURVIVE A TYPE SWITCH, for the same reason: the
// form in Telegram mode renders no URL box, so it may not clear a URL.
// alert/notifier.go's buildPayload reads only the selected type's fields, so
// carrying them costs nothing and switching back costs nothing either.
// 4. VALIDATION KNOWS THE DIFFERENCE between "no token was given" and "no token
// exists". The add form's rule ("Telegram needs a bot token") is one an edit
// can never satisfy, and that is exactly why they now share a validator.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Alert } from './api'
import {
buildAlert,
draftFromAlert,
hasStoredToken,
validateAlertDraft,
TOKEN_KEEP_HINT,
} from './alertEdit.ts'
import type { AlertDraft } from './alertEdit.ts'
const tg: Alert = {
Name: 'tg',
Enabled: true,
Type: 'telegram',
Token: '123456:REAL-SECRET',
ChatID: '-1001',
Events: ['killswitch'],
}
const hook: Alert = {
Name: 'hook',
Enabled: false,
Type: 'webhook',
URL: 'https://hooks.example.com/abc?key=xyz',
Events: ['apply_fail'],
}
const draft = (over: Partial<AlertDraft> = {}): AlertDraft => ({
name: 'tg',
type: 'telegram',
token: '',
chatId: '-1001',
url: '',
events: ['killswitch'],
via: 'direct',
fallback: false,
...over,
})
// --- 1 + 2. the secret -------------------------------------------------------
test('an edit form starts with the token box EMPTY, never prefilled', () => {
const d = draftFromAlert(tg, 'direct')
assert.equal(d.token, '', 'the token must not be handed back to the browser')
// Everything the form CAN show is prefilled, so an edit is an edit and not a
// re-entry exercise.
assert.equal(d.chatId, '-1001')
assert.deepEqual(d.events, ['killswitch'])
})
test('saving with an empty token box KEEPS the stored token', () => {
// The whole point: fix the chat ID without going back to BotFather.
const next = buildAlert(tg, draft({ chatId: '-1002' }))
assert.equal(next.Token, '123456:REAL-SECRET', 'an unshown secret must never be cleared by a save')
assert.equal(next.ChatID, '-1002')
})
test('a typed token replaces the stored one, trimmed', () => {
const next = buildAlert(tg, draft({ token: ' 999:NEW-SECRET ' }))
assert.equal(next.Token, '999:NEW-SECRET')
})
test('the interface SAYS that empty means keep — it is not left to be inferred', () => {
assert.equal(hasStoredToken(tg), true)
assert.equal(hasStoredToken(hook), false)
assert.equal(hasStoredToken(null), false)
assert.match(TOKEN_KEEP_HINT, /leave this empty to keep/i)
})
// --- 3. a type switch is not a delete ----------------------------------------
test('switching telegram → webhook keeps the token and chat ID stored', () => {
const next = buildAlert(
tg,
draft({ type: 'webhook', url: 'https://hooks.example.com/x', token: '' }),
)
assert.equal(next.Type, 'webhook')
assert.equal(next.URL, 'https://hooks.example.com/x')
assert.equal(next.Token, '123456:REAL-SECRET', 'the form showed no token box — it may not clear one')
assert.equal(next.ChatID, '-1001')
})
test('saving a telegram channel does not clear a URL the form never showed', () => {
const both: Alert = { ...tg, URL: 'https://hooks.example.com/keep' }
const next = buildAlert(both, draft({ chatId: '-1003' }))
assert.equal(next.URL, 'https://hooks.example.com/keep')
})
test('a field the form DID show can be emptied — that is the difference', () => {
// In webhook mode the URL box is on screen, so clearing it is a decision the
// operator made and can see. (Validation refuses to save it; the builder's job
// is only to not invent a value.)
const next = buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: '' }))
assert.equal(next.URL, undefined)
})
test('the row switch owns Enabled — an edit never flips it', () => {
assert.equal(buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: hook.URL! })).Enabled, false)
assert.equal(buildAlert(tg, draft()).Enabled, true)
// A NEW channel arrives on.
assert.equal(buildAlert(null, draft({ token: 't' })).Enabled, true)
})
test('delivery: a direct channel carries no Via and no Fallback key at all', () => {
const direct = buildAlert(tg, draft({ via: 'direct', fallback: true }))
assert.equal('Via' in direct, false)
assert.equal('Fallback' in direct, false)
const routed = buildAlert(tg, draft({ via: 'group:eu', fallback: true }))
assert.equal(routed.Via, 'group:eu')
assert.equal(routed.Fallback, true)
// Turning the detour back off drops both — a stale Fallback on a direct channel
// would describe a retry path that does not exist.
const back = buildAlert(routed, draft({ via: 'direct', fallback: false }))
assert.equal('Via' in back, false)
assert.equal('Fallback' in back, false)
})
// --- 4. validation ------------------------------------------------------------
test('an edit with an empty token box passes, because one is already stored', () => {
assert.equal(validateAlertDraft(draft(), new Set(['tg']), tg), null)
})
test('a NEW telegram channel with no token is refused', () => {
const problem = validateAlertDraft(draft({ name: 'fresh', token: '' }), new Set(), null)
assert.notEqual(problem, null)
assert.match(problem!, /bot token/i)
})
test('converting a webhook to telegram demands a token — there is none to keep', () => {
const problem = validateAlertDraft(
draft({ name: 'hook', type: 'telegram', token: '', chatId: '-1' }),
new Set(['hook']),
hook,
)
assert.notEqual(problem, null)
assert.match(problem!, /none is stored/i)
})
test('renaming to its own name is allowed; colliding with another is not', () => {
const taken = new Set(['tg', 'other'])
assert.equal(validateAlertDraft(draft({ name: 'tg' }), taken, tg), null)
const problem = validateAlertDraft(draft({ name: 'other' }), taken, tg)
assert.match(problem!, /already exists/i)
})
test('the remaining shape rules still bite', () => {
assert.match(validateAlertDraft(draft({ name: ' ' }), new Set(), tg)!, /name/i)
assert.match(validateAlertDraft(draft({ chatId: '' }), new Set(), tg)!, /chat ID/i)
assert.match(validateAlertDraft(draft({ events: [] }), new Set(), tg)!, /event/i)
assert.match(
validateAlertDraft(draft({ type: 'webhook', url: 'hooks.example.com' }), new Set(), tg)!,
/http/i,
)
})
+143
View File
@@ -0,0 +1,143 @@
import type { Alert } from './api'
/**
* Editing an alert channel that already exists — and, in particular, editing a
* secret the panel refuses to show.
*
* WHY THIS MODULE EXISTS. Alerts could be created and toggled, and their delivery
* path (Via/Fallback) changed, but Type / Token / ChatID / URL / Events were
* write-once: the only way to fix a typo in a chat ID was to delete the channel
* and build it again. For the bot token that is worse than tedious — the row
* masks it deliberately (`secret hidden`), so "re-enter it" means going back to
* BotFather for a token you already own.
*
* THE RULE THIS FILE ENCODES. A save may clear only a field the editor was able
* to SHOW. The token is never shown, so an empty token box cannot mean "erase the
* stored token" — it means "keep it". The same reasoning covers the fields of the
* OTHER channel type: while the form is in Telegram mode it shows no URL box, so
* a save in Telegram mode leaves a stored URL alone (and vice versa). That is
* also why switching type is non-destructive — the settings of the other kind sit
* there unused until you switch back. Nothing reads them meanwhile:
* alert/notifier.go `buildPayload` switches on Type and touches only that type's
* fields.
*
* It lives outside `pages/Alerts.tsx` because it is the part that must be TESTED,
* and the panel's runner is `node --test src/*.test.ts`: plain modules only, no
* JSX, no DOM (same reason as ruleset.ts and egressEdit.ts).
*/
export type AlertType = 'telegram' | 'webhook'
/** The live fields of either alert form (add or edit). */
export interface AlertDraft {
name: string
type: AlertType
/** Telegram bot token. EMPTY MEANS "keep whatever is stored" — never "clear it". */
token: string
chatId: string
url: string
events: string[]
/** Canonical delivery value from the picker; 'direct' (or '') ⇒ no detour. */
via: string
fallback: boolean
}
const HTTP_RE = /^https?:\/\//i
/** Does this channel already hold a bot token? Drives which hint the form shows. */
export function hasStoredToken(a: Alert | null): boolean {
return !!(a?.Token ?? '').trim()
}
/** Prefill an edit form from a stored channel. The token box always starts EMPTY. */
export function draftFromAlert(a: Alert, via: string): AlertDraft {
return {
name: a.Name,
type: a.Type === 'webhook' ? 'webhook' : 'telegram',
token: '',
chatId: a.ChatID ?? '',
url: a.URL ?? '',
events: [...(a.Events ?? [])],
via,
fallback: a.Fallback ?? false,
}
}
/**
* What the form is allowed to submit, or the message to show instead.
*
* `stored` is the channel being edited (null when adding). It is what makes the
* token rule work in both directions: an edit passes with an empty token box
* because one is already stored, and a NEW Telegram channel — or one being
* converted from a webhook, which has no token — still has to be given one.
*/
export function validateAlertDraft(
d: AlertDraft,
taken: ReadonlySet<string>,
stored: Alert | null,
): string | null {
const nm = d.name.trim()
if (!nm) return 'Give the alert a name.'
if (nm !== (stored?.Name ?? '') && taken.has(nm)) return `An alert named “${nm}” already exists.`
if (d.type === 'telegram') {
if (!d.token.trim() && !hasStoredToken(stored)) {
return 'Telegram needs a bot token — none is stored for this alert yet.'
}
if (!d.chatId.trim()) return 'Telegram needs a chat ID.'
} else if (!HTTP_RE.test(d.url.trim())) {
return 'Enter an http(s):// webhook URL.'
}
if (d.events.length === 0) return 'Pick at least one event to notify on.'
return null
}
/**
* Build the channel a save writes.
*
* The stored channel is spread in first, so anything this form does not model
* survives untouched — the same idiom the rule editor uses for Order/Enabled/Kill.
* Enabled is deliberately taken from storage too: the row's own switch owns it,
* and an edit form that has no switch must not decide it.
*/
export function buildAlert(stored: Alert | null, d: AlertDraft): Alert {
const typed = d.token.trim()
// Telegram fields: the token box, when filled, replaces; when empty it keeps.
// In webhook mode the box is not rendered at all, so it can never speak here.
const token = d.type === 'telegram' && typed ? typed : (stored?.Token ?? '')
const chatId = d.type === 'telegram' ? d.chatId.trim() : (stored?.ChatID ?? '')
const url = d.type === 'webhook' ? d.url.trim() : (stored?.URL ?? '')
const routed = d.via !== '' && d.via !== 'direct'
const out: Alert = {
...(stored ?? ({} as Alert)),
Name: d.name.trim(),
Enabled: stored ? stored.Enabled : true,
Type: d.type,
Events: [...d.events],
}
// Written when non-empty, dropped when the operator emptied a field the form
// actually showed (a webhook URL in webhook mode, a chat ID in Telegram mode).
// `delete` rather than `''` so the PUT body stays the shape the add form sends.
if (token) out.Token = token
else delete out.Token
if (chatId) out.ChatID = chatId
else delete out.ChatID
if (url) out.URL = url
else delete out.URL
if (routed) out.Via = d.via
else delete out.Via
if (routed && d.fallback) out.Fallback = true
else delete out.Fallback
return out
}
/** Said under the token box when one is already stored. */
export const TOKEN_KEEP_HINT =
'A bot token is stored. Leave this empty to keep it — type a new one only to replace it. It is never shown back.'
/** Said under the token box when there is nothing to keep. */
export const TOKEN_NEW_HINT = 'Stored secretly and never shown again — you can replace it later.'
/** Said when an edit switches an existing channel to the other type. */
export const TYPE_SWITCH_NOTE =
'The other type’s settings stay stored and unused, so you can switch back without entering them again.'
+1201 -85
View File
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
// The configuration on disk was refused, and everything else on the page is
// about a different one.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// THE MEASUREMENT THIS EXISTS FOR (stand, shater/apply/apply.go): with a config
// the engine could not accept, reconciliation retried it every 60 seconds — each
// retry a full engine swap that failed and rolled back — while the status said
// `engine_running: true` and carried the OLD config's hash and the OLD config's
// warnings, and named the cause nowhere.
//
// WHAT THESE PROTECT:
//
// 1. THE REFUSED STATE AND THE APPLIED STATE MUST NOT RENDER ALIKE, and the
// refused one must NAME THE STAGE.
// 2. THE CONTROL, BOTH WAYS. On a healthy box nothing new appears at all —
// otherwise "it warns when refused" is satisfiable by a helper that warns
// always. And a daemon that does not publish the field must not be alarmed
// either, because there is no evidence about it.
// 3. IT MUST NOT CONTRADICT `engine_running`. True is TRUE in this state. The
// reading has to say WHICH configuration is running, not deny that one is.
// 4. THE REST OF THE PAGE IS DISOWNED BY NAME. "Refused" alone leaves the hash,
// the traffic verdict and the findings reading as if they were about the
// edit that was just saved.
// 5. NO BROWSER-COMPUTED ELAPSED TIME. The router has no RTC.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Status } from './api.ts'
import { appliedState, rejectedReading, shortHash } from './appliedConfig.ts'
const status = (over: Partial<Status>): Status => ({
running: true,
enabled: true,
active: true,
table: true,
hash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
version: '0.2.19',
engine_running: true,
config_readable: true,
plane: 'full',
...over,
})
/** A box whose saved edit was refused at validation while the tunnel kept working. */
const refused = status({
config_applied: false,
apply_error: 'rule "kids": target "de-hysteria" resolves to nothing',
apply_error_stage: 'the schema gate',
apply_attempts: 7,
apply_failed_since_unix: 1_700_000_000,
})
const healthy = status({ config_applied: true, apply_error: '', apply_error_stage: '' })
// ---- the three states -------------------------------------------------------
test('the three states are three, and false is not folded into absent', () => {
assert.equal(appliedState(healthy), 'applied')
assert.equal(appliedState(refused), 'rejected')
assert.equal(appliedState(status({})), 'unknown') // a daemon without the field
assert.equal(appliedState(null), 'unknown')
})
test('a refused configuration produces a statement, and it names the stage', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r, 'the refusal produced nothing to show')
assert.equal(r.stage, 'schema')
assert.match(r.stageText, /the schema gate/)
assert.match(r.stageHint, /did not pass validation/)
// The daemon's reason, verbatim — not summarised into a mood.
assert.match(r.cause, /target "de-hysteria" resolves to nothing/)
})
// ---- the controls, both ways ------------------------------------------------
test('CONTROL: a healthy box gets nothing new at all', () => {
// Without this, every assertion above is satisfied by a helper that reports a
// refusal unconditionally.
assert.equal(rejectedReading(healthy, '09:14:00'), null)
})
test('CONTROL: a daemon that does not publish the field is not alarmed either', () => {
// Absent is not false. There is no evidence this box's hash is stale, and
// stamping "your edit is not in effect" across the page on no evidence is the
// same class of lie as hiding it when it is true.
assert.equal(rejectedReading(status({}), ''), null)
assert.equal(rejectedReading(null, ''), null)
})
test('CONTROL: refused and applied cannot be drawn the same way', () => {
// The one assertion the whole defect reduces to. It fails if the two states
// ever produce the same screen.
const a = rejectedReading(healthy, '09:14:00')
const b = rejectedReading(refused, '09:14:00')
assert.notEqual(a === null, b === null, 'refused and applied produced the same rendering')
assert.equal(a, null)
assert.ok(b)
})
// ---- what it says, and what it must not say ---------------------------------
test('it does NOT contradict engine_running — it says which config is running', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
assert.match(r.scope, /The engine IS running/)
assert.match(r.scope, /PREVIOUS configuration/)
// The running config named by its own short hash, so the two can be told apart.
assert.match(r.scope, /a1b2c3d4e5f6/)
})
test('the hash, the traffic verdict and the findings are disowned BY NAME', () => {
// "It was refused" is not enough. Every other reading on the page stays green
// and keeps describing the configuration that is running.
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
for (const named of [/config hash/, /where traffic goes/, /every finding/]) {
assert.match(r.scope, named)
}
assert.match(r.scope, /Your edit is not in effect/)
})
test('a stopped engine is not told the tunnel still works', () => {
const r = rejectedReading(status({ ...refused, engine_running: false }), '09:14:00')
assert.ok(r)
assert.doesNotMatch(r.scope, /tunnel still works/)
assert.match(r.scope, /Nothing of this configuration is running/)
// …and the rest of the page is still disowned, because the readings are still
// there and still about something else.
assert.match(r.scope, /every finding/)
})
test('persistence says whether anything is still trying, with the router’s clock', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
assert.match(r.persistence, /Tried 7 times/)
assert.match(r.persistence, /first refused at 09:14:00 on the router’s clock/)
assert.match(r.persistence, /retried on a widening interval/)
assert.match(r.persistence, /saving any change to the configuration cancels the wait/)
})
test('no elapsed time is computed in the browser — the router has no RTC', () => {
// Passing no formatted clock must not make the panel invent one from
// `apply_failed_since_unix` against the browser's clock.
const r = rejectedReading(refused, '')
assert.ok(r)
assert.doesNotMatch(r.persistence, /minute\(s\)|ago|for \d+ /)
assert.match(r.persistence, /Tried 7 times\./)
})
// ---- the closed stage vocabulary --------------------------------------------
test('the three stages are recognised and each says something different', () => {
const seen = new Set<string>()
for (const [phrase, kind] of [
['the schema gate', 'schema'],
['building the engine configuration', 'generate'],
['starting the engine', 'engine'],
] as const) {
const r = rejectedReading(status({ ...refused, apply_error_stage: phrase }), '')
assert.ok(r)
assert.equal(r.stage, kind)
assert.match(r.stageText, new RegExp(phrase))
assert.ok(r.stageHint.length > 0, `${kind} has no hint`)
seen.add(r.stageHint)
}
assert.equal(seen.size, 3, 'two stages tell the operator the same thing')
})
test('an unrecognised stage is shown verbatim and labelled, never guessed', () => {
const r = rejectedReading(status({ ...refused, apply_error_stage: 'installing the data plane' }), '')
assert.ok(r)
assert.equal(r.stage, 'unrecognised')
assert.match(r.stageText, /installing the data plane/)
assert.match(r.stageText, /does not recognise/)
assert.equal(r.stageHint, '', 'a stage we cannot name must not carry advice about another one')
})
test('a stage the daemon never named is its own state, not one of the three', () => {
const r = rejectedReading(status({ ...refused, apply_error_stage: '' }), '')
assert.ok(r)
assert.equal(r.stage, 'unnamed')
assert.match(r.stageText, /did not name/)
})
test('a refusal with no recorded reason still reads as a refusal', () => {
const r = rejectedReading(status({ ...refused, apply_error: '' }), '')
assert.ok(r)
assert.match(r.cause, /recorded no reason/)
assert.notEqual(r.cause, '', 'an empty cause line reads as “nothing wrong”')
})
test('shortHash matches the daemon’s own short form, prefix or not', () => {
assert.equal(shortHash('a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
assert.equal(shortHash('abc'), 'abc')
assert.equal(shortHash(''), '')
// A prefixed hash must shorten to the SAME twelve characters, or the band and
// the Engine module's hash row print two different strings for one config on
// one screen — in the one state where telling configs apart is the whole job.
assert.equal(shortHash('sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
})
test('the band names the running config exactly as the hash row shortens it', () => {
const h = 'sha256:9f7c0abc12345678'
const r = rejectedReading(status({ ...refused, hash: h }), '')
assert.ok(r)
assert.match(r.scope, new RegExp(`\\(${shortHash(h)}\\)`))
assert.doesNotMatch(r.scope, /sha256:/)
})
+208
View File
@@ -0,0 +1,208 @@
// Is the configuration on disk the one that is RUNNING — and if not, what is
// everything else on the screen actually describing?
//
// Backend contract: shater/apply/apply.go — Status.ConfigApplied / ApplyError /
// ApplyErrorStage / ApplyAttempts / ApplyFailedSinceUnix, and Applier.rejected,
// the record that makes them possible.
//
// THE MEASUREMENT THIS EXISTS FOR. With a configuration on disk the engine
// cannot accept, the daemon's reconciliation retried it every 60 seconds — each
// retry a full engine swap that failed and rolled back — while the status body
// said `engine_running: true` and carried the OLD configuration's hash and the
// OLD configuration's warnings. The reason for the refusal appeared nowhere in
// it. The owner watched a healthy engine while the tunnel restarted once a
// minute and their edit did nothing.
//
// `engine_running: true` is TRUE in that state and this module never contradicts
// it. The whole job here is to say WHICH configuration is running, and to mark
// every value on the page that describes that older one instead of the one the
// operator just saved.
import type { Status } from './api'
/**
* A CLOSED set of three, and `unknown` is a real member.
*
* applied — the configuration on disk IS the one running.
* rejected — it was read and REFUSED. Something else is running.
* unknown — the key is ABSENT: nobody measured it, so there is nothing to
* assert in either direction.
*
* WHY `unknown` IS NOT FOLDED INTO `rejected`. `false` is a MEASURED refusal and
* earns the alarm; absence is not a measurement at all, and the daemon spends a
* pointer to keep the two apart (apply.Status.ConfigApplied is a *bool with
* `omitempty`). It has to: `false` publishes "THE CONFIGURATION ON DISK HAS NOT
* BEEN APPLIED … your edit is not in effect", so with a plain bool that alarm
* would be the ZERO VALUE OF THE TYPE — raised by any object that simply never
* mentioned the field, such as the offline stub `shaterd status` prints with no
* daemon answering, over a data plane that may have been running for weeks.
*
* A live status always carries one of the two verdicts, so absence here means
* either that stub or a daemon older than the field — and neither is evidence
* that this router's hash is stale. Stamping "your edit is not in effect" across
* a page on no evidence is the same class of lie as hiding it when it is true.
* `unknown` therefore claims nothing: it neither raises the alarm nor certifies
* the page. This is the same treatment `plane` and `traffic` already get.
*/
export type AppliedState = 'applied' | 'rejected' | 'unknown'
export function appliedState(status: Status | null | undefined): AppliedState {
const v = status?.config_applied
if (v === true) return 'applied'
if (v === false) return 'rejected'
return 'unknown'
}
/** The stage vocabulary, normalised. POSITIVE and CLOSED — `unrecognised` is how
* a future fourth stage arrives, and it is shown verbatim rather than guessed
* at. `unnamed` is the daemon saying nothing, which is not the same thing. */
export type ApplyStage = 'schema' | 'generate' | 'engine' | 'unnamed' | 'unrecognised'
/** The three phrases apply.go publishes (stageSchemaGate / stageGenerate /
* stageEngine), verbatim. Matched exactly: the daemon writes these constants, so
* a value that is merely close is a value this build does not know. */
const STAGES: Record<string, ApplyStage> = {
'the schema gate': 'schema',
'building the engine configuration': 'generate',
'starting the engine': 'engine',
}
export interface RejectedReading {
stage: ApplyStage
/** The step, named. Always non-empty — silence here is itself reported. */
stageText: string
/** What the operator does about this stage. '' when the stage is not known. */
stageHint: string
/** The daemon's reason, verbatim, or a statement that it gave none. */
cause: string
/** Whether anything is still trying, and how long it has been like this. */
persistence: string
/**
* WHAT THE REST OF THE PAGE IS ABOUT. This is the sentence the defect was
* missing: it is not enough to say the config was refused, because every other
* reading on screen stays green and keeps describing the configuration that is
* running.
*/
scope: string
}
/**
* The full statement of a standing refusal — or `null` when there is nothing to
* state, which is every status that is not `rejected`.
*
* `hash` is the short form of the RUNNING configuration's hash, and it is in the
* text on purpose: the single most misleading thing about this state is that
* `hash` in the same status body looks entirely normal. It names the config the
* operator is actually running so the two can be told apart.
*
* NO ELAPSED TIME IS COMPUTED HERE. `apply_failed_since_unix` is the router's
* clock, the router has no RTC, and a browser-side "failing for 12 minutes" would
* be fiction whenever the two clocks differ. The attempt COUNT carries
* persistence; the instant is handed back to the caller to format against the
* router's clock, like every other router timestamp in this panel.
*/
export function rejectedReading(
status: Status | null | undefined,
/** Already-formatted router-clock time of `apply_failed_since_unix`; '' when
* there is none. The caller owns formatting — this module has no locale. */
sinceClock = '',
): RejectedReading | null {
if (appliedState(status) !== 'rejected') return null
const raw = (status?.apply_error_stage ?? '').trim()
const stage: ApplyStage = raw === '' ? 'unnamed' : (STAGES[raw] ?? 'unrecognised')
const err = (status?.apply_error ?? '').trim()
const attempts = status?.apply_attempts ?? 0
return {
stage,
stageText: stageText(stage, raw),
stageHint: stageHint(stage),
// A refusal whose reason was not recorded is still a refusal, and saying so is
// the point — an empty line here would read as "no reason, so probably fine".
cause: err || 'The daemon recorded no reason for the refusal.',
persistence: persistenceText(attempts, sinceClock),
scope: scopeText(status),
}
}
function stageText(stage: ApplyStage, raw: string): string {
switch (stage) {
case 'schema':
return 'the schema gate'
case 'generate':
return 'building the engine configuration'
case 'engine':
return 'starting the engine'
case 'unrecognised':
// Verbatim, and labelled. A step this build does not know is still the step
// that refused, and guessing which of the three it resembles would send the
// operator to the wrong page.
return `a step this panel does not recognise — the daemon calls it “${raw}”`
case 'unnamed':
return 'a step the daemon did not name'
}
}
function stageHint(stage: ApplyStage): string {
switch (stage) {
case 'schema':
return 'The configuration did not pass validation, so nothing was built from it. The cause names the setting.'
case 'generate':
return 'The configuration is valid and could not be turned into an engine configuration — a reference that resolves to nothing, or a combination the generator refuses.'
case 'engine':
return 'The configuration built, and the engine would not come up on it — a port already taken, or a transport that fails at start.'
case 'unrecognised':
case 'unnamed':
return ''
}
}
/**
* Is anything still trying, and since when.
*
* The retry is on a widening interval and ANY edit cancels the wait, so "it will
* be retried" is true and worth saying: an operator who reads "refused" alone
* cannot tell whether the box has given up.
*/
function persistenceText(attempts: number, sinceClock: string): string {
const tries =
attempts > 0
? `Tried ${attempts} time${attempts === 1 ? '' : 's'}`
: 'The daemon did not say how many times it has been tried'
const since = sinceClock ? `, first refused at ${sinceClock} on the router’s clock` : ''
return `${tries}${since}. It is retried on a widening interval, and saving any change to the configuration cancels the wait and tries it again at once.`
}
/**
* The sentence that names what everything else on screen describes.
*
* It is built from what the status actually carries, so it never promises a
* reading that is not on the page: with no `hash` there is no hash to disown.
*/
function scopeText(status: Status | null | undefined): string {
const running = shortHash(status?.hash ?? '')
const engineUp = status?.engine_running === true
const head = engineUp
? `The engine IS running — on the PREVIOUS configuration${running ? ` (${running})` : ''}, not this one. That is why the tunnel still works.`
: `Nothing of this configuration is running${running ? `; the hash shown is ${running}` : ''}.`
return `${head} Everything else on this page describes that older configuration: the config hash, where traffic goes, and every finding below. Your edit is not in effect.`
}
/**
* The leading 12 characters of a config hash — the same short form the daemon's
* own warning uses (apply.shortHash), so the two texts name the config
* identically.
*
* The `sha256:` prefix is stripped first. The daemon's hash is bare hex
* (engine.hashOptions), but a prefixed form exists in fixtures and older data, and
* without the strip the twelve characters would be spent on the word "sha256:" —
* so this band and the Engine module's hash row would print two different strings
* for one configuration, on one screen, in the one state where telling two
* configurations apart is the whole job. Overview's hash row calls THIS function
* for that reason; there is deliberately only one normalisation.
*/
export function shortHash(h: string): string {
const bare = h.replace(/^sha256:/, '')
return bare.length <= 12 ? bare : bare.slice(0, 12)
}
+287
View File
@@ -0,0 +1,287 @@
// applyRisk — the warning shown BEFORE the apply that takes the network down.
//
// Traced through the daemon: nothing rejects an empty config (model.Validate is
// advisory, generate errors only on a nil model, the engine starts because the
// outbound list always holds direct+block); with the kill-switch closed and no
// catch-all rule generate/route.go sets `route.final = "block"`; and the tproxy
// divert for the shipped `lan` inbound is installed, so every TCP connection and
// UDP flow from the LAN is handed to the engine and dropped.
//
// A predictive warning is only worth having if it is quiet on configs that are
// fine, so every "it fires" case below is paired with the single-field change
// that must silence it. Four conditions, four ways for the danger to be absent.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyRisk, isCatchAllRule, isInterceptingInbound } from './planeState.ts'
import type { ApplyRisk, ApplyRiskInput, CatchAllRule } from './planeState.ts'
/** The shipped config with the service switched on and nothing else changed:
* fail-closed, one enabled tproxy inbound, no rules, no resolvers, no window. */
function shipped(over: Partial<ApplyRiskInput> = {}): ApplyRiskInput {
return {
Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 0 },
Rules: [],
Inbounds: [{ Enabled: true, Type: 'tproxy' }],
Resolvers: [],
...over,
}
}
/** A rule with no matcher of any kind — the effective catch-all (route.Final). */
function catchAll(over: Partial<CatchAllRule> = {}): CatchAllRule {
return { Enabled: true, ...over }
}
// --- it fires on the dangerous config ---------------------------------------
test('warns before an apply that blocks every device', () => {
const r = applyRisk(shipped())
assert.ok(r, 'the shipped config, switched on, blocks everything')
// Says what the ACTION does, in traffic terms, not what is wrong with a field.
assert.match(r.headline, /cuts every device off the internet/)
assert.match(r.detail, /drops it/)
// Only certain claims: TCP and UDP are what tproxy diverts. ICMP depends on
// the Untunnelable policy, so it is not promised here either way.
assert.match(r.detail, /TCP connection and UDP flow/)
// What still works, so nobody power-cycles a router they can still reach.
assert.match(r.detail, /reach each other and this panel/)
// And the fix, naming the page and the shape of the rule.
assert.match(r.steps[0], /Routing page/)
assert.match(r.steps[0], /no conditions/)
})
test('names the missing auto-rollback, and the value to set', () => {
const r = applyRisk(shipped())
assert.ok(r)
assert.equal(r.noAutoRollback, true)
assert.match(r.undo, /no auto-rollback/)
assert.match(r.undo, /SSH/)
// The README's documented first-apply value, which the panel never mentioned.
assert.ok(
r.steps.some((s) => /120 seconds/.test(s) && /Settings/.test(s)),
'the confirm-window remedy must be offered, with the documented value',
)
})
test('an armed confirm window changes the undo line, not the warning', () => {
const r = applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }))
assert.ok(r, 'a confirm window does not make blocking the network unremarkable')
assert.equal(r.noAutoRollback, false)
assert.match(r.undo, /120s/)
assert.match(r.undo, /reverts to the last-good one/)
assert.doesNotMatch(r.undo, /SSH/)
// ...and it stops offering a remedy the operator has already applied.
assert.ok(!r.steps.some((s) => /120 seconds/.test(s)))
})
test('zero resolvers is named, because DNS is the one thing that still leaves', () => {
// CONTROL FIRST: one configured resolver and the step is gone. A DNS warning
// that appears whatever the DNS config says is not a reading of the DNS config.
const withResolver = applyRisk(shipped({ Resolvers: [{}] }))
assert.ok(withResolver)
assert.ok(!withResolver.steps.some((s) => /DNS page/.test(s)))
const without = applyRisk(shipped())
assert.ok(without)
const dns = without.steps.find((s) => /DNS page/.test(s))
assert.ok(dns, 'with no resolver, lookups go to the provider unprotected — say so')
assert.match(dns, /in the clear/)
// IT IS NOT ONLY THE QUERIES ADDRESSED TO THE ROUTER. The shipped
// `dns_intercept '1'` pulls a query aimed at a resolver the device picked for
// ITSELF into the engine too, and it leaves as the same clear UDP/53 — measured
// both ways on the stand, each producing its own plaintext packet on the WAN.
// The earlier wording covered only the router-addressed half, which reads as a
// promise that the other half is contained. It is not.
assert.match(dns, /to the router or to a resolver they picked themselves/)
// ...and encrypted DNS is not the way out of it either: :853 out of the LAN
// measured connects=0, because the plan rejects it.
assert.match(dns, /:853/)
// The claim the detail below is not allowed to contradict.
assert.match(dns, /only thing that still leaves this network/)
})
// --- the band may not contradict itself -------------------------------------
//
// MEASURED on the stand: in this exact state 0 packets left the WAN across the
// whole run (27 in the control that differs only by an added catch-all) — except
// for exactly 2, both plaintext UDP/53. So the DNS step is right and the detail's
// "nothing reaches the internet" was wrong by those 2 packets.
//
// The wording fix alone does not survive the next editor, because the two halves
// live 15 lines apart and each reads fine on its own. What follows is therefore
// not a check of the words but of the INVARIANT between them: no clause of the
// band may claim that nothing leaves while another clause names something that
// does. Edit either half back without the other and this fails.
/** The band is one utterance to one operator, so a claim in the steps and a claim
* in the detail are claims in the same breath. Flatten it to clauses. */
function bandClauses(r: ApplyRisk): string[] {
return [r.headline, r.detail, r.undo, ...r.steps]
.join(' ')
.split(/[.;:](?=\s|$)/)
.map((c) => c.trim())
.filter(Boolean)
}
/** "…<verb> … <the outside>" — a clause that talks about traffic leaving. */
const EXIT = /\b(?:reach(?:es)?|leaves?|leaving|gets? out|goes? out|going out)\b.*?\b(?:internet|this network|your provider)\b/i
/** Universal negation, and ONLY universal negation: "No resolver is configured"
* must NOT count, or the instrument goes blind on the very clause it exists to
* see. */
const NONE = /\bnothing\b|\bno traffic\b|\bnone of it\b/i
/** What turns "nothing leaves" into a survivable claim by admitting an exception. */
const QUALIFIED = /\belse\b|\bexcept\b|\bapart from\b|\bother than\b|\baside from\b/i
const saysNothingLeaves = (c: string) => EXIT.test(c) && NONE.test(c) && !QUALIFIED.test(c)
const saysSomethingLeaves = (c: string) => EXIT.test(c) && !NONE.test(c)
test('the band never says both "nothing leaves" and "DNS leaves"', () => {
// The instrument, proved on a fabricated band before it is trusted on a real
// one: the absolute claim IS detectable, and it IS detected next to the DNS
// clause. Without this, a regex that matches nothing would pass silently.
const control = bandClauses({
headline: 'x',
detail: 'Devices can still reach each other and this panel; nothing reaches the internet.',
undo: 'x',
steps: ['It is the only thing that still leaves this network.'],
noAutoRollback: true,
})
assert.ok(control.some(saysNothingLeaves), 'the absolute-claim detector must be able to fire')
assert.ok(control.some(saysSomethingLeaves), 'the something-leaves detector must be able to fire')
// Now the real bands, in every shape that renders one.
const bands = [
shipped(),
shipped({ Resolvers: [{}] }),
shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }),
shipped({ Rules: [catchAll({ Enabled: false })] }),
shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })], Resolvers: [{}, {}] }),
].map((cfg) => {
const r = applyRisk(cfg)
assert.ok(r, 'this config must still produce a band, or the check below is vacuous')
return r
})
for (const r of bands) {
const clauses = bandClauses(r)
const denies = clauses.filter(saysNothingLeaves)
const admits = clauses.filter(saysSomethingLeaves)
assert.ok(
!(denies.length > 0 && admits.length > 0),
`the band contradicts itself:\n denies: ${JSON.stringify(denies)}\n admits: ${JSON.stringify(admits)}`,
)
}
// AND THE INSTRUMENT IS LOOKING AT THE LIVE TEXT. Without a resolver the band
// must contain a clause admitting that something leaves — if that ever stops
// being true, the loop above passes for the wrong reason.
const dangerous = applyRisk(shipped())
assert.ok(dangerous)
assert.ok(
bandClauses(dangerous).some(saysSomethingLeaves),
'the no-resolver band must still name the traffic that gets out',
)
// The detail's half of the invariant, pinned by hand so the one-word edit that
// breaks it is named in the failure and not just inferred.
assert.match(dangerous.detail, /nothing else reaches the internet/)
assert.doesNotMatch(dangerous.detail, /nothing reaches the internet/)
// CONTROL: with a resolver configured nothing is claimed to leave at all, so
// the invariant is satisfied by the other side of the same test.
const safe = applyRisk(shipped({ Resolvers: [{}] }))
assert.ok(safe)
assert.equal(bandClauses(safe).filter(saysSomethingLeaves).length, 0)
})
// --- the four ways it must stay silent (the controls) -----------------------
test('silent when the service will be off — no plane is built at all', () => {
assert.equal(
applyRisk(shipped({ Globals: { Enabled: false, KillSwitch: 'closed', ConfirmTimeout: 0 } })),
null,
)
})
test('silent when the kill-switch is open — unmatched traffic leaves, it is not dropped', () => {
assert.equal(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'open', ConfirmTimeout: 0 } })),
null,
)
// ...and the daemon's own normalisation is what decides "closed", so every
// spelling that blocks on the router must still warn here.
for (const spelling of ['closed', 'Closed', ' closed ', '', 'CLOSED', 'whatever']) {
assert.ok(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: spelling, ConfirmTimeout: 0 } })),
`kill_switch '${spelling}' blocks on the router, so it must warn here`,
)
}
// Only a case-insensitive, trimmed "open" is fail-open.
assert.equal(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: ' Open ', ConfirmTimeout: 0 } })),
null,
)
})
test('silent when nothing intercepts — no enabled tproxy inbound diverts anything', () => {
assert.equal(applyRisk(shipped({ Inbounds: [] })), null)
assert.equal(applyRisk(shipped({ Inbounds: null })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: false, Type: 'tproxy' }] })), null)
// socks/http/dokodemo are local listeners; they intercept nothing on their own.
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'socks' }] })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'http' }] })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'dokodemo' }] })), null)
// An absent/empty Type IS tproxy (model.Inbound.EffectiveType), so it warns.
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: '' }] })))
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true }] })))
})
test('silent when a default rule exists — that is what stops final being block', () => {
assert.equal(applyRisk(shipped({ Rules: [catchAll()] })), null)
// A DISABLED catch-all is not one: it is not emitted, so final stays block.
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Enabled: false })] })))
// Neither is a rule that only matches SOME traffic.
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Src: ['192.168.1.0/24'] })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Proto: 'tcp' })] })))
// One catch-all among specific rules is still a catch-all.
assert.equal(
applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' }), catchAll()] })),
null,
)
})
test('an UNMIGRATED rule is never the default — the case that would hide the warning', () => {
// Its destination is still in schema-v1 options the parser no longer reads, so
// "no matchers" means "unreadable destination", not "matches everything" — and
// the daemon holds it disabled. Counting it would silence this warning on
// exactly the config that needs it.
assert.equal(isCatchAllRule(catchAll({ LegacyDst: ['example.com'] })), false)
assert.ok(
applyRisk(shipped({ Rules: [catchAll({ LegacyDst: ['example.com'] })] })),
'an unmigrated rule must not be mistaken for a default route',
)
// CONTROL: the same rule once migrated does silence it.
assert.equal(applyRisk(shipped({ Rules: [catchAll({ LegacyDst: [] })] })), null)
})
// --- the small predicates, directly -----------------------------------------
test('isInterceptingInbound is a closed positive list', () => {
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'TPROXY' }), true)
assert.equal(isInterceptingInbound({ Enabled: true, Type: ' tproxy ' }), true)
assert.equal(isInterceptingInbound({ Enabled: true }), true)
// Anything the panel does not know about must NOT be assumed to intercept —
// an open `!== 'socks'` test would warn about a router that diverts nothing.
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'something-new' }), false)
assert.equal(isInterceptingInbound({ Enabled: false }), false)
})
test('null and missing input produce no warning rather than a guess', () => {
assert.equal(applyRisk(null), null)
assert.equal(applyRisk(undefined), null)
assert.equal(applyRisk(shipped({ Rules: null, Resolvers: null })) !== null, true)
})
+137
View File
@@ -0,0 +1,137 @@
// A BLOCKED CHAIN IS A FIELD NOW, NOT A SENTENCE.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THIS PROTECTS. The prober walks a chain in order and stops at the first
// hop that does not answer, so the chain's exit is never dialled. There is no
// end-to-end measurement, and the daemon correctly files the row `source:''` —
// which a client reading `source` strictly puts in the "nobody looked" bucket.
// That is the wrong colour: a probe DID run, at the hop, and it failed.
//
// The panel used to keep such a row red by matching a fragment of the daemon's
// error sentence. It was the last place prose decided anything here, and a
// reworded message would have silently turned a red row grey. `blocked_by` is
// the same fact as a NUMBER.
//
// 1. A NON-ZERO blocked_by KEEPS THE ROW RED, though nothing measured it.
// 2. THE CONTROL: zero does NOT. Written so that a helper which reds
// everything, or one which reds nothing, fails.
// 3. NO PROSE. The daemon's sentence can be reworded to anything at all and
// the colour does not move.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import { blockedHop, readingTone, testReading } from './testResult.ts'
const chainRow = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'ewan-wg-subs',
selected: '',
kind: 'chain',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error: '',
tested_unix: 1_700_000_000,
source: '',
blocked_by: 0,
...over,
})
// --- 1 + 2. red on a hop, not red on zero -------------------------------------
test('a chain blocked at a hop stays RED, though nothing measured its exit', () => {
const blocked = chainRow({
blocked_by: 3,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
})
assert.equal(blockedHop(blocked), 3, 'the hop is 1-based and comes off the field')
assert.equal(testReading(blocked), 'not-measured', 'there is still no end-to-end measurement')
assert.equal(readingTone(blocked), 'crit', 'but a probe did run, at the hop, and failed')
})
test('CONTROL: blocked_by = 0 is NOT red — the escalation is exactly one case', () => {
// Every non-chain result carries 0. If this went red too, the field would have
// replaced a narrow prose match with a blanket one, which is worse than what
// it replaced.
const quiet = chainRow({
blocked_by: 0,
error:
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
})
assert.equal(blockedHop(quiet), 0)
assert.equal(
readingTone(quiet),
'unknown',
'an unlit lamp: painting this red reports a fault nobody has found',
)
assert.notEqual(readingTone(quiet), 'crit')
})
test('CONTROL: the helper can still say a row is good, and a measured failure bad', () => {
// Without these, "0 is not crit" would also be satisfied by a classifier that
// never says anything at all.
const alive = chainRow({ ok: true, delay_ms: 42, source: 'observatory' })
const dead = chainRow({
source: 'observatory',
error: 'the observatory’s probe through this path failed',
})
assert.equal(readingTone(alive), 'good')
assert.equal(readingTone(dead), 'crit')
assert.equal(blockedHop(alive), 0, 'a working chain names no blocking hop')
})
// --- 3. the prose no longer decides anything -----------------------------------
test('the daemon may reword its sentence freely — the colour comes off the field', () => {
// This is the regression the change exists for. Under the old string match,
// every one of these rows would have gone quiet grey.
for (const error of [
'hop 3 did not respond',
'chain stopped at hop 3',
'',
'a sentence this build has never seen',
]) {
const r = chainRow({ blocked_by: 3, error })
assert.equal(readingTone(r), 'crit', JSON.stringify(error))
}
})
test('…and the old sentence WITHOUT the field no longer reds a row on its own', () => {
// The mirror: prose alone is not evidence any more. A daemon that sends the
// sentence and no field is one this panel does not ship with, and guessing
// from its wording is exactly the coupling that was removed.
const proseOnly = chainRow({
blocked_by: undefined,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
})
assert.equal(blockedHop(proseOnly), 0)
assert.equal(readingTone(proseOnly), 'unknown')
})
test('a nonsense hop number claims nothing — the escalation is not entered by accident', () => {
for (const raw of [0, -1, 0.5, NaN, Infinity, '3' as unknown as number]) {
assert.equal(blockedHop(chainRow({ blocked_by: raw })), 0, String(raw))
}
assert.equal(blockedHop(chainRow({ blocked_by: 1 })), 1, 'one IS a valid hop — 1-based')
assert.equal(blockedHop(chainRow({ blocked_by: 4.9 })), 4, 'a fractional hop floors to a real one')
})
test('a measured failure is never downgraded by a missing hop, nor upgraded by one', () => {
const measuredFail = chainRow({
source: 'observatory',
blocked_by: 0,
error: 'the observatory’s probe through this path failed',
})
assert.equal(readingTone(measuredFail), 'crit')
const measuredOK = chainRow({ ok: true, source: 'observatory', blocked_by: 3 })
assert.equal(
readingTone(measuredOK),
'good',
'blocked_by only ever escalates a not-measured row; it cannot overturn a measurement',
)
})
+132
View File
@@ -0,0 +1,132 @@
// TWO KINDS OF ROW ON ONE BOARD, and they may not look alike.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THIS PROTECTS. Starting a test run used to replace the results outright,
// so pressing Test on one NODE blanked every group and chain card on the Targets
// screen — the daemon threw true measurements away and nothing on screen
// explained it. It no longer does: the rows a run will not itself re-measure are
// carried forward (engine.startTestRun), capped at 64, oldest evicted first.
//
// That fixes one lie and opens the door to another. A card can now show a
// reading taken twenty minutes ago beside a card showing one taken a second ago,
// and if both are drawn as a bare timestamp the whole board reads "as of now".
//
// Attribution is NOT by comparing timestamps: the router has no RTC, so its
// clock can sit far from the browser's and any computed "n minutes ago" would be
// fiction. The daemon publishes `scope`, the set of names this run covers.
//
// 1. THIS RUN'S ROW AND A CARRIED ROW ARE DIFFERENT ON SCREEN. Written so
// that drawing them identically fails.
// 2. THE CONTROL. The same helper must produce the plain, unqualified stamp
// too, or "they differ" is satisfied by a function that marks everything.
// 3. AN EMPTY SCOPE IS "CANNOT ATTRIBUTE", NOT "EVERYTHING IS CARRIED". A run
// always covers at least one target, so an empty set only ever means the
// daemon published none — and the panel's normalizer turns an absent field
// into exactly that.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import { originStamp, rowOrigin } from './testResult.ts'
const row = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'auto',
selected: 'nl-reality-2',
kind: 'group',
delay_ms: 42,
exit_ip: '185.12.34.56',
exit_country: 'NL',
ok: true,
error: '',
tested_unix: 1_700_000_000,
source: 'observatory',
...over,
})
const CLOCK = '14:02:11'
// --- 1 + 2. the distinction, and the control ---------------------------------
test('a row this run measured and a row it carried are attributed differently', () => {
const scope = ['stealth']
assert.equal(rowOrigin(row({ group: 'stealth' }), scope), 'this-run')
assert.equal(
rowOrigin(row({ group: 'auto' }), scope),
'carried',
'this run never touched `auto`; its reading is from an earlier press',
)
})
test('and they are DRAWN differently — the same fact, at the pixel level', () => {
const mine = originStamp('this-run', CLOCK)
const carried = originStamp('carried', CLOCK)
assert.notEqual(
mine.text,
carried.text,
'a screen that stamps both with the same bare time says the board is current when half of it is not',
)
assert.match(carried.text, /earlier run/, 'the words have to name the fact, not hint at it')
assert.equal(mine.text, CLOCK, 'this run needs no qualifier — it IS the reading just taken')
assert.notEqual(mine.hint, carried.hint)
})
test('CONTROL: the same helper does produce an unqualified stamp', () => {
// Without this, "they differ" would also be satisfied by a function that
// stamped EVERY row "earlier run" — which would be a different lie, and the
// one that makes an operator distrust a number they just asked for.
for (const origin of ['this-run', 'unattributed'] as const) {
assert.equal(
originStamp(origin, CLOCK).text,
CLOCK,
`${origin} must not be dressed as carried`,
)
assert.doesNotMatch(originStamp(origin, CLOCK).text, /earlier/)
}
})
test('a carried row says so even when it carries no timestamp at all', () => {
// The worst case for silence: nothing to print, and the row is still not this
// run's. `tested_unix: 0` reaches here as an empty clock string.
const carried = originStamp('carried', '')
assert.equal(carried.text, 'earlier run')
assert.notEqual(carried.text, originStamp('this-run', '').text)
assert.equal(originStamp('this-run', '').text, '', 'and an unstamped fresh row prints nothing')
})
// --- 3. an empty scope claims nothing ------------------------------------------
test('an EMPTY scope is "cannot attribute", not "everything is carried"', () => {
// The panel's normalizer turns an absent `scope` into `[]` before this sees
// it, so this branch is the pre-scope daemon. Reading it as a real scope would
// stamp "earlier run" on every row of a run that had just measured them all.
for (const scope of [[], undefined, null]) {
assert.equal(rowOrigin(row({}), scope), 'unattributed', JSON.stringify(scope))
}
assert.equal(originStamp('unattributed', CLOCK).text, CLOCK)
assert.doesNotMatch(
originStamp('unattributed', CLOCK).hint,
/earlier run/,
'saying nothing is the honest answer here; guessing "carried" is not',
)
})
test('a run over every target leaves nothing carried', () => {
const all = ['auto', 'stealth', 'via-tunnel']
for (const group of all) {
assert.equal(rowOrigin(row({ group }), all), 'this-run', group)
}
})
test('no row is not a row from an earlier run', () => {
assert.equal(rowOrigin(undefined, ['auto']), 'unattributed')
assert.equal(rowOrigin(null, ['auto']), 'unattributed')
})
test('the three origins produce three distinct hints', () => {
const hints = new Set(
(['this-run', 'carried', 'unattributed'] as const).map((o) => originStamp(o, CLOCK).hint),
)
assert.equal(hints.size, 3, 'three different situations, three different explanations')
})
+15 -1
View File
@@ -1,4 +1,5 @@
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange. */
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange;
* .btn.crit is the solid-red destructive commit. */
.btn {
display: inline-block;
padding: 7px 12px;
@@ -30,3 +31,16 @@
color: #fff;
filter: brightness(1.05);
}
/* Destructive commit. The fill is crit stepped a little toward black so white
* label text clears 4.5:1 in BOTH themes — the raw --crit is bright enough in
* dark mode to fall under it. Red here always means "this removes something". */
.btn.crit {
border-color: transparent;
background: color-mix(in srgb, var(--crit) 88%, #000);
color: #fff;
}
.btn.crit:hover {
color: #fff;
filter: brightness(1.08);
}
+15 -7
View File
@@ -1,19 +1,27 @@
import './Button.css'
import { forwardRef } from 'react'
import type { ButtonHTMLAttributes } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** `primary` is the solid-orange call to action; `ghost` is the default. */
variant?: 'ghost' | 'primary'
/**
* `primary` is the solid-orange call to action; `crit` is the solid-red
* destructive commit (delete, remove) — semantic crit, never the accent;
* `ghost` is the default.
*/
variant?: 'ghost' | 'primary' | 'crit'
}
export function Button({ variant = 'ghost', className, type, ...rest }: ButtonProps) {
/** Ref-forwarding so a dialog can park focus on a specific button. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'ghost', className, type, ...rest },
ref,
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={['btn', variant === 'primary' ? 'primary' : '', className]
.filter(Boolean)
.join(' ')}
className={['btn', variant === 'ghost' ? '' : variant, className].filter(Boolean).join(' ')}
{...rest}
/>
)
}
})
+49 -3
View File
@@ -1,11 +1,49 @@
import { useEffect, useState } from 'react'
/**
* The panel's wall clock, in the SAME timezone as every timestamp under it.
*
* It used to read `getUTCHours()` and print "UTC", while `format.ts` renders every
* log line, connection event and date through `toLocaleTimeString` — i.e. the
* browser's zone. In Moscow that put two clocks three hours apart on one plate,
* and the header was the one nobody could reconcile: the router's "started" time
* read later than the current time while the uptime said it had been up for hours.
*
* So the clock follows the rest of the panel — local, and it SAYS which offset
* that is, because a bare "12:41:07" beside a router in another zone is the
* ambiguity that started this. The zone label is the browser's UTC offset, not an
* abbreviation: "MSK"/"CEST" are not derivable everywhere, an offset always is.
*
* This is the BROWSER's clock, not the router's — the appliance has no RTC. Every
* router-sourced instant in the panel is converted to this clock before it is
* shown, which is what makes one label at the top honest for the whole page.
*/
function zoneLabel(d: Date): string {
// getTimezoneOffset() is minutes WEST of UTC, so the sign is inverted.
const min = -d.getTimezoneOffset()
if (min === 0) return 'UTC'
const sign = min < 0 ? '−' : '+'
const a = Math.abs(min)
const h = Math.floor(a / 60)
const m = a % 60
return `UTC${sign}${h}${m ? `:${String(m).padStart(2, '0')}` : ''}`
}
function format(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} UTC`
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${zoneLabel(d)}`
}
/** Live UTC readout, tabular digits, ticking once a second. */
/** The full zone name, for the title — "Europe/Moscow" says more than "+3" does. */
function zoneName(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
} catch {
return ''
}
}
/** Live local readout, tabular digits, ticking once a second. */
export function Clock({ className }: { className?: string }) {
const [now, setNow] = useState(() => format(new Date()))
@@ -14,5 +52,13 @@ export function Clock({ className }: { className?: string }) {
return () => window.clearInterval(id)
}, [])
return <span className={['clock', className].filter(Boolean).join(' ')}>{now}</span>
const zone = zoneName()
return (
<span
className={['clock', className].filter(Boolean).join(' ')}
title={zone ? `Your device's clock — ${zone}. Every time in the panel is shown in this zone.` : undefined}
>
{now}
</span>
)
}
+162
View File
@@ -0,0 +1,162 @@
/* <ConfirmDialog> — the safety interlock plate.
*
* This replaces the browser's native confirm dialog, which a browser can mute for
* good ("prevent this page from creating additional dialogs"): after that it
* returns false with no dialog at all, so every delete button in the panel goes
* dead and silent with no way to recover short of a page reload. We draw the
* plate ourselves, so nothing can suppress it.
*
* Faceplate language: a small rack module lifted off the panel — corner screws
* (reused from Faceplate.css), an engraved label, a groove above the actions.
* Destructive intent is carried by the crit semantic, never by the orange accent:
* accent means "this control is active", crit means "this destroys something".
*/
/* The veil is a fixed dark wash in both themes — a light scrim over a light
* panel would not read as "the panel is out of reach". Follows the tokens.css
* pattern: light base, dark via media query, data-theme overrides win both ways. */
.cfm-scrim {
--cfm-veil: rgba(33, 29, 21, 0.52);
}
@media (prefers-color-scheme: dark) {
.cfm-scrim {
--cfm-veil: rgba(0, 0, 0, 0.66);
}
}
:root[data-theme='light'] .cfm-scrim {
--cfm-veil: rgba(33, 29, 21, 0.52);
}
:root[data-theme='dark'] .cfm-scrim {
--cfm-veil: rgba(0, 0, 0, 0.66);
}
.cfm-scrim {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
/* Short viewports: the plate scrolls with the veil instead of being clipped. */
overflow-y: auto;
padding: calc(var(--u, 8px) * 2);
background: var(--cfm-veil);
animation: cfm-veil-in 0.14s ease-out;
}
.cfm-card {
position: relative;
width: min(32rem, 100%);
max-height: calc(100dvh - var(--u, 8px) * 4);
overflow-y: auto;
padding: calc(var(--u, 8px) * 3.25);
border: 1px solid var(--groove);
border-radius: 12px;
/* same brushed plate as <Faceplate>, one step brighter so it reads as lifted */
background:
repeating-linear-gradient(
90deg,
transparent 0 2px,
color-mix(in srgb, var(--edge) 30%, transparent) 2px 3px
),
linear-gradient(180deg, var(--raised), color-mix(in srgb, var(--raised) 82%, var(--panel)));
box-shadow:
0 1px 0 var(--edge) inset,
0 30px 60px -22px var(--shadow),
0 4px 12px var(--shadow);
animation: cfm-card-in 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.cfm-card:focus {
outline: none;
}
/* `still` is set from usePrefersReducedMotion — the plate appears, it never
* travels. (The global reduced-motion rule in tokens.css also neutralises the
* duration; this keeps the intent explicit at the component.) */
.cfm-scrim.still,
.cfm-scrim.still .cfm-card {
animation: none;
}
@keyframes cfm-veil-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes cfm-card-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: none;
}
}
/* ---- header: engraved label + state LED ---- */
.cfm-hd {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: calc(var(--u, 8px) * 1.5);
}
.cfm-label {
flex: 1;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: var(--track-label-wide, 0.24em);
color: var(--dim);
text-transform: uppercase;
}
/* ---- copy ---- */
.cfm-title {
margin: 0;
font-family: var(--font-mono);
font-weight: 700;
font-size: 17px;
line-height: 1.35;
color: var(--ink);
/* names can be long and unbroken — wrap rather than push the plate wide */
overflow-wrap: anywhere;
}
.cfm-body {
margin: calc(var(--u, 8px) * 1.5) 0 0;
max-width: 52ch;
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.6;
color: var(--dim);
overflow-wrap: anywhere;
}
/* ---- action bar ---- */
.cfm-actions {
display: flex;
justify-content: flex-end;
gap: calc(var(--u, 8px));
margin-top: calc(var(--u, 8px) * 3);
padding-top: calc(var(--u, 8px) * 2);
border-top: 1px solid var(--groove);
}
@media (max-width: 420px) {
.cfm-card {
padding: calc(var(--u, 8px) * 2.5);
}
.cfm-actions {
flex-wrap: wrap;
}
.cfm-actions .btn {
flex: 1 1 auto;
text-align: center;
}
/* screws crowd a small plate — drop them rather than collide with the copy */
.cfm-card > .screw {
display: none;
}
}
+281
View File
@@ -0,0 +1,281 @@
import './ConfirmDialog.css'
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button } from './Button'
import { Led } from './Led'
import { usePrefersReducedMotion } from './usePrefersReducedMotion'
/**
* How the confirming button is painted.
*
* crit — the action destroys something. Semantic crit, never the accent.
* neutral — the action is a normal commit the operator should read first
* (a warning before saving); the accent's call-to-action is correct.
*/
export type ConfirmTone = 'crit' | 'neutral'
export interface ConfirmOptions {
/** Engraved eyebrow, e.g. "DELETE RULE". Names the operation, not the object. */
label?: string
/** The question. One line, ends in "?". */
title: string
/** The consequence — what changes on the router if this goes through. */
body?: ReactNode
/** Verb on the confirming button. Defaults to "Delete". */
confirmLabel?: string
/** Verb on the dismissing button. Defaults to "Cancel". */
cancelLabel?: string
/** Defaults to `crit` — the overwhelmingly common case is a delete. */
tone?: ConfirmTone
}
export interface ConfirmDialogProps extends ConfirmOptions {
open: boolean
/** Called exactly once per dialog, with the operator's answer. */
onResolve: (confirmed: boolean) => void
}
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* The modal plate itself. Normally reached through `useConfirm()`; exported so a
* page that wants to own the open state can render it directly.
*
* Keyboard contract:
* - focus moves to Cancel on open, so a reflex Enter dismisses, never deletes;
* - Tab / Shift+Tab cycle inside the plate and cannot reach the page behind it;
* - Esc answers "no";
* - on close, focus returns to whatever opened the dialog.
*/
export function ConfirmDialog({
open,
onResolve,
label,
title,
body,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
tone = 'crit',
}: ConfirmDialogProps) {
const titleId = useId()
const bodyId = useId()
const cardRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const openerRef = useRef<HTMLElement | null>(null)
const reduced = usePrefersReducedMotion()
// Take the page out of the tab order, park focus on Cancel, and hand focus
// back to the opener when the plate goes away.
useEffect(() => {
if (!open) return
const opener = document.activeElement
openerRef.current = opener instanceof HTMLElement ? opener : null
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
// Cancel is the resting place: an Enter or a Space meant for the page lands
// on "no". The destructive button is one Tab away, deliberately.
;(cancelRef.current ?? cardRef.current)?.focus()
return () => {
document.body.style.overflow = prevOverflow
const back = openerRef.current
openerRef.current = null
if (back && document.contains(back)) back.focus()
}
}, [open])
// Esc answers no; Tab is caged. Capture phase so a page-level key handler
// never sees keys aimed at the dialog.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onResolve(false)
return
}
if (e.key !== 'Tab') return
const card = cardRef.current
if (!card) return
const list = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE))
if (list.length === 0) {
e.preventDefault()
card.focus()
return
}
const first = list[0]
const last = list[list.length - 1]
const active = document.activeElement as HTMLElement | null
if (!active || !card.contains(active)) {
e.preventDefault()
;(e.shiftKey ? last : first).focus()
} else if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [open, onResolve])
if (!open) return null
return createPortal(
<div
className={['cfm-scrim', reduced ? 'still' : ''].filter(Boolean).join(' ')}
// A click on the field around the plate means "not now". Mousedown (not
// click) so a text selection dragged out of the plate can't dismiss it.
onMouseDown={(e) => {
if (e.target === e.currentTarget) onResolve(false)
}}
>
<div
className={`cfm-card tone-${tone}`}
ref={cardRef}
tabIndex={-1}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={body != null ? bodyId : undefined}
>
<i className="screw tl" aria-hidden="true" />
<i className="screw tr" aria-hidden="true" />
<i className="screw bl" aria-hidden="true" />
<i className="screw br" aria-hidden="true" />
{/* Lamp first, then the engraved label — the way a real panel reads, and
it keeps the LED off the corner screw. */}
<div className="cfm-hd">
<Led variant={tone === 'crit' ? 'crit' : 'amber'} />
<span className="cfm-label">{label ?? (tone === 'crit' ? 'Confirm delete' : 'Confirm')}</span>
</div>
<h2 className="cfm-title" id={titleId}>
{title}
</h2>
{body != null && (
<p className="cfm-body" id={bodyId}>
{body}
</p>
)}
<div className="cfm-actions">
<Button ref={cancelRef} onClick={() => onResolve(false)}>
{cancelLabel}
</Button>
<Button variant={tone === 'crit' ? 'crit' : 'primary'} onClick={() => onResolve(true)}>
{confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
)
}
// ---- provider + hook --------------------------------------------------------
interface Request extends ConfirmOptions {
id: number
resolve: (v: boolean) => void
}
const ConfirmCtx = createContext<((o: ConfirmOptions) => Promise<boolean>) | null>(null)
/**
* Mount once at the app root. Everything below can then ask a question and await
* the answer.
*/
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [req, setReq] = useState<Request | null>(null)
const pending = useRef<Request | null>(null)
const seq = useRef(0)
const confirm = useCallback(
(opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
// A second question while one is open answers the first with "no" rather
// than leaving its promise — and its caller — hanging forever.
pending.current?.resolve(false)
seq.current += 1
const next: Request = { ...opts, id: seq.current, resolve }
pending.current = next
setReq(next)
}),
[],
)
const settle = useCallback((confirmed: boolean) => {
const open = pending.current
pending.current = null
setReq(null)
open?.resolve(confirmed)
}, [])
// Teardown must not strand a caller mid-await.
useEffect(
() => () => {
pending.current?.resolve(false)
pending.current = null
},
[],
)
// A question belongs to the page that asked it. The provider outlives the
// hash router, so a navigation would otherwise leave a stale plate floating
// over a page it has nothing to do with — answer it "no" and clear it.
useEffect(() => {
const onNav = () => {
if (pending.current) settle(false)
}
window.addEventListener('hashchange', onNav)
return () => window.removeEventListener('hashchange', onNav)
}, [settle])
return (
<ConfirmCtx.Provider value={confirm}>
{children}
{req !== null && <ConfirmDialog key={req.id} open onResolve={settle} {...req} />}
</ConfirmCtx.Provider>
)
}
/**
* Ask the operator, get a definite answer:
*
* const confirm = useConfirm()
* if (!(await confirm({ title: 'Delete rule "x"?', body: '…' }))) return
*
* The returned function is stable, so it is safe in a useCallback dep list. It
* always settles — cancel, Esc, click-outside and teardown all resolve `false`;
* only the confirming button resolves `true`.
*
* Name it `confirm` at the call site on purpose: the local binding shadows the
* global one inside that component, so an accidental bare `confirm(...)` cannot
* reach the suppressible native dialog.
*/
export function useConfirm(): (o: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmCtx)
if (!ctx) {
// Loud on purpose. A fallback that quietly resolved false would rebuild the
// exact bug this component exists to kill.
throw new Error('useConfirm() needs <ConfirmProvider> above it (mounted in main.tsx)')
}
return ctx
}
+108
View File
@@ -0,0 +1,108 @@
import type { DetourCatalog } from '../detour'
/**
* The detour picker: `Direct` plus every group / chain / interface-egress / node
* the Model currently defines.
*
* One <select>, shared by every page that pins traffic to a route — a resolver's
* DNS path, an alert's delivery, a subscription's fetch, the list-fetch route.
* It was three byte-identical copies (DNS.tsx, Nodes.tsx, Alerts.tsx) before the
* fourth consumer arrived; the option labels are the words the operator learns
* the vocabulary from, so they have to be the SAME words on every page.
*
* `className` and `directLabel` are the only things a page varies. `directLabel`
* matters: under a proxy-fetch `Direct` is not "no preference", it is the plain
* WAN with the router's real address, and the page that means that says so.
*
* A stored value the catalog no longer knows is kept as a trailing
* `<option>… (missing)</option>` rather than silently reselecting the first
* entry — a picker that quietly rewrites a stale setting to `direct` would turn
* a visible misconfiguration into an invisible leak.
*/
export interface DetourSelectProps {
/** Canonical value — run the stored string through `canonDetour` first. */
value: string
catalog: DetourCatalog
/** `detourValues(catalog)` — what counts as still-resolvable. */
valid: Set<string>
disabled?: boolean
/** A save/apply is in flight; folded into `disabled`. */
busy?: boolean
ariaLabel: string
onChange: (v: string) => void
className?: string
directLabel?: string
/**
* Turns the picker into an OVERRIDE picker: adds a leading `<option value="">`
* with this label, meaning "not set here — inherit". Only for fields where
* empty and `direct` are different inputs (a profile override: `''` inherits
* whatever globals says, `direct` forces the plain WAN over a globals setting
* that tunnels). Leave it off and `''` is not a selectable state.
*/
inheritLabel?: string
}
export function DetourSelect({
value,
catalog,
valid,
disabled = false,
busy = false,
ariaLabel,
onChange,
className = 'fp-input',
directLabel = 'Direct (no proxy)',
inheritLabel,
}: DetourSelectProps) {
const missing = value !== '' && value !== 'direct' && !valid.has(value)
return (
<select
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy || disabled}
aria-label={ariaLabel}
>
{inheritLabel !== undefined && <option value="">{inheritLabel}</option>}
<option value="direct">{directLabel}</option>
{catalog.groups.length > 0 && (
<optgroup label="Groups">
{catalog.groups.map((g) => (
<option key={g} value={`group:${g}`}>
Group {g} (balancer)
</option>
))}
</optgroup>
)}
{catalog.chains.length > 0 && (
<optgroup label="Chains">
{catalog.chains.map((c) => (
<option key={c} value={`chain:${c}`}>
Chain {c}
</option>
))}
</optgroup>
)}
{catalog.egresses.length > 0 && (
<optgroup label="Interfaces / egresses">
{catalog.egresses.map((e) => (
<option key={e.name} value={`egress:${e.name}`}>
Interface/egress {e.name}
{e.type ? ` (${e.type})` : ''}
</option>
))}
</optgroup>
)}
{catalog.nodes.length > 0 && (
<optgroup label="Nodes">
{catalog.nodes.map((n) => (
<option key={n} value={`node:${n}`}>
Node {n}
</option>
))}
</optgroup>
)}
{missing && <option value={value}>{value} (missing)</option>}
</select>
)
}
+168
View File
@@ -0,0 +1,168 @@
/* ListPicker — the additions on top of SrcPicker.css.
*
* Everything structural (the machined slot, the flush-docked popover, the shelf
* strips, the sunken rows, the chip shell and its ×) is SrcPicker's and is reused
* verbatim; only what this picker says differently lives here:
*
* .lstp-lane / .lstp-step the two ranked lanes inside one slot, carrying the
* engine step number that ties the control back to the
* order rail on the device card;
* .lstp-dot / .lstp-load the load reading — the one colour on a list chip. It
* is driven by data-load, never by "is it attached":
* on = matching, warn = loaded but empty, crit = not
* loaded / no such list, unknown = the engine has not
* said, which is DIM and never green.
*/
/* --- ranked lanes --------------------------------------------------------- */
.lstp-lane {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
padding: 1px 4px 1px 1px;
border-radius: 6px;
background: color-mix(in srgb, var(--panel) 55%, transparent);
}
/* The step number. A machined stamp, not a bullet: it is the same number printed
* on the card's order rail, so the two read as one instrument. */
.lstp-step {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 15px;
height: 15px;
padding: 0 3px;
border: 1px solid var(--groove);
border-radius: 3px;
background: var(--sink);
box-shadow: 0 1px 1px var(--shadow) inset;
color: var(--dim);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9.5px;
font-weight: 700;
line-height: 1;
}
/* --- chips ---------------------------------------------------------------- */
/* A typed entry is hand-made: amber dot, same as SrcPicker's custom value. It
* carries NO kind tag — the marker it was typed with is already the first word of
* the label, and "full:discord.com FULL" says the same thing twice. */
/* An attached list carries the engine's verdict, and nothing else. */
.lstp-dot {
background: var(--faint);
}
[data-load='on'] > .lstp-dot,
.lstp-dot[data-load='on'] {
background: var(--led-on);
box-shadow: 0 0 4px var(--led-on);
}
[data-load='warn'] > .lstp-dot,
.lstp-dot[data-load='warn'] {
background: var(--amber);
box-shadow: 0 0 4px var(--amber);
}
[data-load='crit'] > .lstp-dot,
.lstp-dot[data-load='crit'] {
background: var(--crit);
box-shadow: 0 0 4px var(--crit);
}
[data-load='unknown'] > .lstp-dot,
.lstp-dot[data-load='unknown'] {
background: var(--faint);
box-shadow: none;
}
/* The verdict, in full. It is never truncated: "not loaded" clipped to
* "NOT LOAD…" is the one word on this card a parent has to be able to read. */
.lstp-load {
flex: 0 0 auto;
white-space: nowrap;
color: var(--faint);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
[data-load='on'] > .lstp-load,
.lstp-load[data-load='on'] {
color: var(--led-on);
}
[data-load='warn'] > .lstp-load,
.lstp-load[data-load='warn'] {
color: var(--amber);
}
[data-load='crit'] > .lstp-load,
.lstp-load[data-load='crit'] {
color: var(--crit);
}
.lstp-chip-list[data-load='crit'] {
border-color: color-mix(in srgb, var(--crit) 45%, var(--groove));
}
.lstp-chip-list[data-load='warn'] {
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
}
/* --- popover rows --------------------------------------------------------- */
.lstp-list {
max-height: 168px;
}
.lstp-row {
gap: 6px;
}
.lstp-row-detail {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--faint);
font-size: 10px;
}
.lstp-row-tags {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
}
.lstp-tag {
padding: 0 4px;
border: 1px solid var(--groove);
border-radius: 3px;
color: var(--dim);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9px;
letter-spacing: var(--track-label, 0.08em);
text-transform: uppercase;
}
/* --- notes ---------------------------------------------------------------- */
.lstp-hint,
.lstp-foot {
margin: 0;
padding: 6px 9px;
border-top: 1px solid var(--groove);
background: var(--panel);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 11px;
line-height: 1.5;
color: var(--dim);
}
.lstp-hint .mono {
color: var(--ink);
font-size: 10.5px;
}
.lstp-foot {
color: var(--faint);
}
@media (max-width: 560px) {
.lstp-row-detail {
display: none;
}
}
+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { describeDomainEntry, parseDomainEntry } from '../deviceLists'
import type { ListLoad } from '../deviceLists'
import './SrcPicker.css'
import './ListPicker.css'
/**
* ListPicker — the Faceplate chooser for ONE direction of a device's domain
* policy: the named lists attached to it plus the domains typed on it, in a
* single slot.
*
* It borrows SrcPicker's LANGUAGE wholesale (a machined slot of chips, a popover
* docked flush under it with hairline shelves, a Custom row at the bottom, Esc and
* click-outside, a dot encoding the chip's kind) and its stylesheet — but not its
* code. SrcPicker is welded to `useSrcOptions()`, to IP/CIDR validation, and to an
* empty state that reads "everyone · all LAN clients"; on a BLOCK list that
* sentence would mean the exact opposite of the truth. Generalising it would have
* produced a component with two of everything and a props list nobody reads.
*
* ## What the slot has to make legible
*
* The two chip kinds are not equal, and they are not adjacent in the engine's
* order either. A device is evaluated:
*
* 1 allow typed · 2 block typed · 3 allow attached · 4 block attached · 5 network
*
* so the typed chips and the attached chips inside ONE control sit two steps
* apart. The slot therefore renders them as two labelled lanes carrying their own
* step number, which is what ties this control back to the order rail above it on
* the card. Sorting them the other way round would draw a precedence that does not
* exist.
*
* ## What a chip is not allowed to say
*
* A list chip reports what the ENGINE says about that list (via
* /api/ruleset/status, through `listLoad`), never the fact that someone attached
* it. A list whose URL is unreachable, whose file is missing, or whose geosite
* category resolved to nothing is a parental control that does not work; it is
* drawn `not loaded`, in crit, and a list the engine has not mentioned at all is
* drawn `load unknown` rather than green.
*/
export type ListPickerKind = 'block' | 'allow'
/** One attachable named list, as the page knows it. */
export interface ListOption {
/** The `config blocklist` / `config allowlist` section name — the stored value. */
name: string
/** Where its content comes from: "url · big.oisd.nl", "3 domains", "geosite · telegram". */
detail: string
/** Blocklist/Allowlist.Enabled — whether it is in the NETWORK-wide filter.
* It does NOT gate this device: attaching the list is the switch here. */
networkEnabled: boolean
/** Blocklist.Response — the reply a blocked name gets. Block lists only. */
response?: string
/** What the running engine reports about it. */
load: ListLoad
}
export interface ListPickerProps {
kind: ListPickerKind
/** Attached list names — Device.Blocklists / Device.Allowlists. */
lists: string[]
/** Hand-typed entries — Device.Block / Device.Allow. */
domains: string[]
/** Every list that could be attached, in config order. */
options: ListOption[]
/** The engine step the TYPED lane is (1 for allow, 2 for block). */
typedStep: number
/** The engine step the ATTACHED lane is (3 for allow, 4 for block). */
listStep: number
disabled?: boolean
ariaLabel: string
onListsChange: (v: string[]) => void
onDomainsChange: (v: string[]) => void
}
const WORD: Record<ListPickerKind, { noun: string; verb: string; shelf: string }> = {
block: { noun: 'block', verb: 'Blocked', shelf: 'Blocklists' },
allow: { noun: 'allow', verb: 'Allowed', shelf: 'Allowlists' },
}
const EMPTY_TEXT: Record<ListPickerKind, string> = {
block: 'nothing blocked for this device',
allow: 'nothing forced through for this device',
}
const CUSTOM_PLACEHOLDER: Record<ListPickerKind, string> = {
block: 'example.com or keyword:tiktok',
allow: 'school.example.edu',
}
/** Said at the moment of choosing, because both facts change what the operator is
* about to do — and neither is visible from the chip afterwards. */
const FOOTNOTE: Record<ListPickerKind, string> = {
block:
'Attaching a list runs it for this device even when the list is off for the network. The reply a blocked name gets comes from the list, not from the device.',
allow:
'An attached allow list is terminal: everything it covers is also lifted out of the network blocklists for this device. A big list here removes a lot of filtering.',
}
/** The reading for a name the config no longer has. Built once — it never varies. */
const MISSING_LOAD: ListLoad = {
tone: 'crit',
tag: 'no such list',
detail: 'This device points at a list that is not in the config — it filters nothing.',
ruleCount: 0,
}
export function ListPicker({
kind,
lists,
domains,
options,
typedStep,
listStep,
disabled,
ariaLabel,
onListsChange,
onDomainsChange,
}: ListPickerProps) {
const [open, setOpen] = useState(false)
const [custom, setCustom] = useState('')
const [customErr, setCustomErr] = useState<string | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const fieldRef = useRef<HTMLDivElement>(null)
const customRef = useRef<HTMLInputElement>(null)
const byName = useMemo(() => new Map(options.map((o) => [o.name, o])), [options])
const attached = useMemo(() => new Set(lists), [lists])
const free = useMemo(() => options.filter((o) => !attached.has(o.name)), [options, attached])
// A stored name with no matching option is a device pointing at a deleted list.
// It keeps its chip and says so, rather than vanishing from the card.
const listChips = useMemo(
() =>
lists.map((name) => {
const opt = byName.get(name)
return { name, opt, load: opt ? opt.load : MISSING_LOAD }
}),
[lists, byName],
)
const domainChips = useMemo(
() => domains.map((d) => ({ value: d, ...describeDomainEntry(d) })),
[domains],
)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false)
fieldRef.current?.focus()
}
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const w = WORD[kind]
const attachList = (name: string) => {
if (attached.has(name)) return
onListsChange([...lists, name])
}
const detachList = (name: string) => onListsChange(lists.filter((x) => x !== name))
const removeDomain = (v: string) => onDomainsChange(domains.filter((x) => x !== v))
const addCustom = () => {
const parsed = parseDomainEntry(custom)
if (!parsed.ok) {
setCustomErr(parsed.reason)
return
}
if (domains.some((d) => d.toLowerCase() === parsed.value)) {
setCustomErr(`${parsed.value} is already on this list.`)
return
}
onDomainsChange([...domains, parsed.value])
setCustom('')
setCustomErr(null)
customRef.current?.focus()
}
const toggleOpen = () => {
if (disabled) return
setOpen((o) => !o)
}
const onFieldKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
}
}
const empty = listChips.length === 0 && domainChips.length === 0
return (
<div className="srcp lstp" ref={rootRef}>
<div
ref={fieldRef}
className={disabled ? 'srcp-field disabled' : 'srcp-field'}
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
onClick={toggleOpen}
onKeyDown={onFieldKeyDown}
>
{empty ? (
<span className="srcp-empty">{EMPTY_TEXT[kind]}</span>
) : (
<>
{domainChips.length > 0 && (
<span
className="lstp-lane"
role="group"
aria-label={`Step ${typedStep} — domains typed here`}
>
<span className="lstp-step" aria-hidden="true">
{typedStep}
</span>
{domainChips.map((c) => (
<span key={c.value} className="srcp-chip lstp-chip-typed" title={c.detail}>
<span className="srcp-dot srcp-dot-custom" aria-hidden="true" />
<span className="srcp-chip-name mono">{c.label}</span>
<button
type="button"
className="srcp-chip-x"
aria-label={`Remove ${c.value} from the ${w.noun} list`}
onClick={(e) => {
e.stopPropagation()
removeDomain(c.value)
}}
disabled={disabled}
>
×
</button>
</span>
))}
</span>
)}
{listChips.length > 0 && (
<span
className="lstp-lane"
role="group"
aria-label={`Step ${listStep} — attached ${w.shelf.toLowerCase()}`}
>
<span className="lstp-step" aria-hidden="true">
{listStep}
</span>
{listChips.map((c) => (
<span
key={c.name}
className="srcp-chip lstp-chip-list"
data-load={c.load.tone}
title={`${c.name} — ${c.load.detail}`}
>
<span className="srcp-dot lstp-dot" aria-hidden="true" />
<span className="srcp-chip-name mono">{c.name}</span>
<span className="lstp-load">{c.load.tag}</span>
<button
type="button"
className="srcp-chip-x"
aria-label={`Detach list ${c.name} — ${c.load.detail}`}
onClick={(e) => {
e.stopPropagation()
detachList(c.name)
}}
disabled={disabled}
>
×
</button>
</span>
))}
</span>
)}
</>
)}
<span className="srcp-caret" aria-hidden="true">
▾
</span>
</div>
{open && !disabled && (
<div className="srcp-pop" role="dialog" aria-label={`${ariaLabel} — choose lists and domains`}>
{/* Named lists ---------------------------------------------------- */}
<div className="srcp-shelf" aria-hidden="true">
<span>
{w.shelf} · step {listStep}
</span>
<span className="srcp-shelf-n">{free.length || '—'}</span>
</div>
{free.length > 0 ? (
<ul className="srcp-list lstp-list">
{free.map((o) => (
<li key={o.name}>
<button
type="button"
className="srcp-row lstp-row"
onClick={() => attachList(o.name)}
title={o.load.detail}
>
<span className="srcp-dot lstp-dot" data-load={o.load.tone} aria-hidden="true" />
<span className="srcp-row-name">{o.name}</span>
<span className="lstp-row-detail mono">{o.detail}</span>
<span className="lstp-row-tags">
{o.response === 'zero' && <span className="lstp-tag">0.0.0.0</span>}
{!o.networkEnabled && (
<span className="lstp-tag" title="Off for the network — attaching it still runs it here">
network off
</span>
)}
<span className="lstp-load" data-load={o.load.tone}>
{o.load.tag}
</span>
</span>
</button>
</li>
))}
</ul>
) : (
<p className="srcp-none">
{options.length
? `every ${w.shelf.toLowerCase().replace(/s$/, '')} is already attached`
: `no ${w.shelf.toLowerCase()} configured — add one on the DNS page`}
</p>
)}
{/* Typed domains -------------------------------------------------- */}
<div className="srcp-shelf" aria-hidden="true">
<span>
Type a domain · step {typedStep}
</span>
</div>
<div className="srcp-custom">
<input
ref={customRef}
className="srcp-custom-input mono"
type="text"
value={custom}
onChange={(e) => {
setCustom(e.target.value)
if (customErr) setCustomErr(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCustom()
}
}}
placeholder={CUSTOM_PLACEHOLDER[kind]}
aria-label={`Domain to ${w.noun} for this device`}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
className="srcp-custom-add"
onClick={addCustom}
disabled={!custom.trim()}
>
{kind === 'block' ? 'Block' : 'Allow'}
</button>
</div>
{customErr && (
<p className="srcp-note" role="alert">
{customErr}
</p>
)}
<p className="lstp-hint">
A bare entry covers the domain and its subdomains.{' '}
<span className="mono">full:</span> one exact name,{' '}
<span className="mono">keyword:</span> any host containing it.
</p>
<p className="lstp-foot">{FOOTNOTE[kind]}</p>
</div>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More