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
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
// 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',
|
||
)
|
||
})
|