Files
shater/panel/src/planeState.test.ts
T
omarandClaude Opus 5 1746d4d0ef
release / apk aarch64_cortex-a53 (push) Successful in 9m13s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 7s
fix: stop the panel and the shipped binary from lying about what works
Four defects, all found by the owner on the live router, all of the same
family: something declared itself working while it was not.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 20:08:58 +03:00

151 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// protectionState — the one sentence the whole panel shows about "am I protected".
//
// Run with `npm test` (node's built-in test runner + native TypeScript stripping;
// no test dependency is added to the SPA, which ships inside the daemon binary).
//
// The case this file was written for is "plane full, traffic direct": the exact
// state of a live router — one enabled rule, `default → direct`, no groups, no
// rule-sets — where every part of the data plane was installed and the readout
// therefore said "Protected — traffic from your network is going through the
// tunnel", under a green LED, while the whole LAN went out the plain WAN.
//
// planeState.ts has no runtime imports (both of its imports are `import type`),
// so this runs against the real module with nothing stubbed.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { protectionState } from './planeState.ts'
import type { Status, Traffic } from './api.ts'
/** A healthy, fully-installed router; `traffic` is what each case varies. */
function status(over: Partial<Status> = {}): Status {
return {
running: true,
enabled: true,
active: true,
table: true,
hash: 'abc',
version: '1.11.0-shater',
kill_switch: 'closed',
engine_running: true,
plane: 'full',
warnings: [],
...over,
}
}
function withTraffic(traffic: Traffic | undefined): Status {
return status({ traffic })
}
// --- the field case ---------------------------------------------------------
test('plane full + default direct is NOT reported as protected', () => {
const s = protectionState(withTraffic({ verdict: 'direct', default: 'direct', tunnel_rules: 0 }))
assert.notEqual(s.headline, 'Protected')
assert.equal(s.variant, 'crit')
assert.equal(s.alarm, true)
// The claim that was false must not survive anywhere in the copy.
assert.doesNotMatch(s.detail, /going through the tunnel/)
// ...and the honest consequence must be stated, not implied.
assert.match(s.detail, /real address/)
})
// --- the other verdicts under a full plane ----------------------------------
test('plane full + default into a tunnel is protected', () => {
const s = protectionState(withTraffic({ verdict: 'tunnel', default: 'auto', tunnel_rules: 1 }))
assert.equal(s.variant, 'on')
assert.equal(s.headline, 'Protected')
assert.equal(s.alarm, false)
})
test('plane full + direct default with tunnelling rules is split, not protected', () => {
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 3 }))
assert.equal(s.variant, 'amber')
assert.notEqual(s.headline, 'Protected')
// Says how much is protected, and that the default is not.
assert.match(s.detail, /3 rules/)
assert.match(s.detail, /normal internet connection/)
// A working selective setup must not raise a banner on every other page.
assert.equal(s.alarm, false)
})
test('split names a single rule in the singular', () => {
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 1 }))
assert.match(s.detail, /^One rule sends traffic/)
})
test('plane full + blocked default with rules leaks nothing and is never crit', () => {
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 2 }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, false)
assert.match(s.detail, /nothing is leaving unprotected/)
})
test('plane full + blocked default with no rules says the network has no way out', () => {
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 0 }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
assert.doesNotMatch(s.detail, /going through the tunnel/)
})
test('plane full with no verdict claims nothing either way', () => {
for (const t of [undefined, { verdict: '' as const }]) {
const s = protectionState(withTraffic(t))
assert.notEqual(s.headline, 'Protected')
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, false)
}
})
// --- the branches that were already correct ---------------------------------
test('no status yet', () => {
const s = protectionState(null)
assert.equal(s.variant, 'off')
assert.equal(s.alarm, false)
})
test('service switched off is a deliberate state, not a fault', () => {
const s = protectionState(status({ enabled: false }))
assert.equal(s.variant, 'amber')
assert.equal(s.headline, 'Turned off')
assert.equal(s.alarm, false)
})
test('hold: the kill-switch caught it — protected, offline', () => {
const s = protectionState(status({ plane: 'hold', engine_running: false, active: false }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
assert.match(s.headline, /blocked/)
})
test('none + fail-closed is the leak, and it is crit', () => {
const s = protectionState(status({ plane: 'none', table: false, engine_running: false }))
assert.equal(s.variant, 'crit')
assert.equal(s.alarm, true)
})
test('none + fail-open is the operator’s documented choice, stated not alarmed at', () => {
const s = protectionState(
status({ plane: 'none', table: false, engine_running: false, kill_switch: 'open' }),
)
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
})
test('daemon too old to send `plane` keeps its own fallback', () => {
// Nothing here may depend on `traffic`: a daemon with no `plane` has no
// `traffic` either, and this branch reads what it can observe instead.
const { plane, ...noPlane } = status()
void plane
assert.equal(protectionState(noPlane as Status).headline, 'Protected')
assert.equal(protectionState({ ...noPlane, running: false } as Status).headline, 'Service stopped')
assert.equal(
protectionState({ ...noPlane, active: false } as Status).headline,
'Starting up',
)
})