main
3155
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8c0ea55054 |
feat(dns,fetch): resolvers and list fetches follow the uplink; the node cache survives the reboot it exists for
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>
apk-latest-aarch64_cortex-a53
apk-latest-x86_64
apk-v0.2.24-aarch64_cortex-a53
apk-v0.2.24-x86_64
v0.2.24
|
||
|
|
625942834b |
fix(gate): [5/7] hid the runner's exit code exactly when it explained everything
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_01BHw89tdWddzhjUc4bAH4tSapk-v0.2.23-aarch64_cortex-a53 apk-v0.2.23-x86_64 v0.2.23 |
||
|
|
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
|
||
|
|
be1cdbfc63 |
feat(netplane): an explicitly named private subnet is routed, not silently swallowed
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
|
||
|
|
bbb493ea91 |
fix(ci): the package-count assertion lives in two scripts and only one was updated
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_01BHw89tdWddzhjUc4bAH4tSapk-v0.2.22-aarch64_cortex-a53 apk-v0.2.22-x86_64 v0.2.22 |
||
|
|
4869d62e02 |
feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it is REVERSED there, with the reason, because the reason is the whole point. D13 adopted an external desync process on an observation: the engine's own `tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not get through, so the method was judged too weak for anything past "just fragment the ClientHello". The method was never tried. `common/tlsfragment` dropped a number of labels equal to the number of DOTS in the name, and a name always has one more label than it has dots — so the cut always landed inside the FIRST label. `www.youtube.com` was split inside `www` and `youtube` went to the wire in one piece, which is the word the DPI matches on. Of six blocked names exactly one got through: `youtube.com`, the one whose first label IS the blocked word. That defect is fixed ( |
||
|
|
efb2177f43 |
fix(tlsfragment): one cut, in the label a blocklist keys on — and a budget for it
Follow-up to
|
||
|
|
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
|
||
|
|
0a34e64c2c |
fix(panel): byedpi off the status poll, and disabled stops speaking for two situations
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_01BHw89tdWddzhjUc4bAH4tSapk-v0.2.21-aarch64_cortex-a53 apk-v0.2.21-x86_64 v0.2.21 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
a4ea5dba44 |
fix(tests): the last two packages that wrote to the router's own /etc/shater
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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,
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |