Compare commits

...
Author SHA1 Message Date
omarandClaude Opus 5 bbb493ea91 fix(ci): the package-count assertion lives in two scripts and only one was updated
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 8m55s
release / apk x86_64 (push) Successful in 2m51s
release / release apk (push) Successful in 8s
D29 removed byedpi, so the feed carries three packages. sdk-build-apk.sh was
changed to >=3; build-feed-apk.sh still demanded >=4 and killed both arch lanes
of v0.2.22 with `expected >=4 .apk … found 3`. Nothing was published from that
run. The comment now says the count is duplicated, because reading one script
was what made this look done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:31:35 +03:00
omarandClaude Opus 5 4869d62e02 feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m39s
release / apk aarch64_cortex-a53 (push) Failing after 2m54s
release / apk x86_64 (push) Failing after 2m54s
release / release apk (push) Failing after 1m35s
The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness
endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it
is REVERSED there, with the reason, because the reason is the whole point.

D13 adopted an external desync process on an observation: the engine's own
`tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not
get through, so the method was judged too weak for anything past "just fragment
the ClientHello". The method was never tried. `common/tlsfragment` dropped a
number of labels equal to the number of DOTS in the name, and a name always has
one more label than it has dots — so the cut always landed inside the FIRST
label. `www.youtube.com` was split inside `www` and `youtube` went to the wire
in one piece, which is the word the DPI matches on. Of six blocked names exactly
one got through: `youtube.com`, the one whose first label IS the blocked word.
That defect is fixed (815011dfb, efb2177f4). With it fixed the built-in presets
do the job the external process was brought in to do, and the process is 100 KB
of binary, a second procd service, a second UCI file, a port that agreed with
our egress by hand-written comment only, a readiness prober, a five-state
service model and a panel plate — all to work around fifteen lines of ours.

So this is not "ByeDPI turned out to be bad". It is a good tool that turned out
not to be needed, and the reason we thought it was needed was ours.

A CONFIG THAT STILL SAYS `type 'byedpi'` IS THE PART THAT NEEDED WORK. Nothing
is migrated and nothing is rewritten: the kind stays unbuildable, therefore
fail-closed — no outbound, no mark, no `ip rule`, no routing table, so every
node, group and rule bound to it is blocked rather than released onto the plain
WAN. A migration to `direct` was considered and rejected: it is the only rewrite
that leaves the egress routing at all, and it would silently turn a blocked
egress into a live plain-WAN path with the router's real address — by an
upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore not
bumped either: no stored field changes meaning, and a bump would only make this
build's configs unreadable to an older daemon for no gain.

What changes is what the operator is TOLD. `model.RetiredEgressTypes` is a
closed, positive table read by BOTH `ValidateEgresses` and the generator (one
copy of the sentence, because two copies drift). It names the removal, denies
that it is a typo, says nothing is built and that the traffic is blocked rather
than leaked, names the replacement (`direct`/`interface` with `dpi 'record'`),
refuses to promise which preset defeats a given ISP, and says `apk del byedpi`.
The generic "unknown type" is still there and still says something different, on
purpose: "we took this kind away" and "you mistyped something" send an operator
to different places, and a value that was correct on the day it was written must
not be reported as a spelling mistake. The type list stays closed and positive —
`interface`, `direct`, the alias `tunnel` — and `EgressTypeKnown` does NOT admit
the retired kind: being told it was removed and having it work anyway is worse
than either alone.

`Egress.Port` goes with the kind: no surviving egress dials anything, so the
option is no longer parsed and drains out of /etc/config/shater on the next
render, the same way the deleted per-group probe_url/probe_interval did.

Tests, verified by mutation, each failing by name:
  - drop the retired branch in `ValidateEgresses` -> the retired kind is
    reported as "is not one of interface/direct" and
    TestRetiredEgressTypeIsReportedByTheValidator fails on both spellings;
  - drop it in the generator -> "unknown type \"byedpi\"" and
    TestRetiredEgressTypeIsReportedByTheGenerator fails;
  - the FAIL-OPEN mutation, which is the one that matters: let `byedpi` fall
    into the `direct` arm and be a known type -> four tests fail, including the
    two that check no outbound is emitted. A removal that quietly starts routing
    the traffic it used to block, under a reassuring message, is the failure with
    the worst consequence;
  - the panel half: empty RETIRED_EGRESS_TYPES -> two egressEdit tests fail.
Controls beside the claims: `interface`, `direct`, the `tunnel` alias and the
empty synonym must still resolve, warn about nothing and emit an outbound
(TestSupportedEgressTypesAreUntouched), and never-supported values — `proxy`,
`block`, `wireguard`, `byedpi2`, `bye dpi`, `sorcery` — must NOT draw the
removal sentence, which names a replacement for something that never existed.

CI and docs: the feed loses its fourth package everywhere the four were named —
`apk upgrade shaterd shater-core luci-app-shater`, in CLAUDE.md, both READMEs,
INSTALL.md, the release body and `shaterd`'s own diag bundle. The version
exception (byedpi carried upstream's version, ours come from the git tag) is
gone with it, so ci/version.sh and ci/sdk-build-apk.sh no longer have an
exception to remember and the "expected >=4 of OUR .apk" collect check is now 3.
INSTALL.md §5.3 gains the half a feed cannot do: dropping the package from the
feed does not take it off a router it is already on, so `apk del byedpi` is
written down, with what it removes and why it is safe.

Panel: 368 tests -> 339. Deleted with the mechanism they covered:
byedpiReady.test.ts, byedpiAge.test.ts, byedpiRefusal.test.ts (34 tests);
egressEdit.test.ts gains 5 for the retired-type sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:13:50 +03:00
omarandClaude Opus 5 efb2177f43 fix(tlsfragment): one cut, in the label a blocklist keys on — and a budget for it
Follow-up to 815011dfb, which fixed WHICH label is cut but left "a cut in every
candidate label" as an unconditional rule. Measured on this tree, loopback peer,
product default fallbackDelay, one ClientHello per row:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also, from review-by-seams:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The cache is not allowed to lie:

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

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

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

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

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

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

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

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

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

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

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

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

    _ = os.Remove(statsFilePath())

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things here are constructions, not checks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

LogEntry gains two fields:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Honesty, in three places it would otherwise have lied:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Along the way, three defects the work surfaced:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:40:24 +03:00
205 changed files with 36544 additions and 2752 deletions
+5 -7
View File
@@ -11,13 +11,12 @@
# the arch-matched artifact. (arch-specific .apk)
# - shater-core data glue, PKGARCH=all
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
# - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
#
# TARGET HARDWARE / ARCH MATRIX
# x86_64 -> the QEMU testbed VM (generic x86-64).
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 mini + BPI-R4,
# mediatek/filogic), both on 25.12 with apk-tools 3.
# Only shaterd + byedpi are arch-specific; shater-core + luci-app-shater are
# Only shaterd is arch-specific; shater-core + luci-app-shater are
# PKGARCH=all, so one build of each covers every device — but the RELEASES
# are still per-arch (see the release-apk job for why).
#
@@ -59,8 +58,8 @@
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
# built .apk really carry that version, so the failure can never be silent
# again. This is also why the build job checks out with fetch-depth: 0
# — `git describe` needs tags and ancestry. `byedpi` is excluded: it keeps
# upstream ByeDPI's own PKG_VERSION (see openwrt/byedpi/Makefile).
# — `git describe` needs tags and ancestry. Every package this repo ships is
# versioned from the tag; there is no longer an exception to remember.
# CACHING (T3 — fast CI)
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
@@ -453,7 +452,7 @@ jobs:
echo "[release-apk] arch=$arch built version=$want"
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
Packages: shaterd + byedpi (per-arch), shater-core + luci-app-shater (arch=all).
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
This build: \`$want\`.
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
@@ -462,7 +461,6 @@ jobs:
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
apk update
apk add luci-app-shater # pulls shater-core + shaterd too
apk add byedpi # optional: ByeDPI desync egress
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
assets, so the same repo line keeps serving the newest build. To pin a
version instead, point the repo line at
@@ -470,7 +468,7 @@ jobs:
file must be edited by hand for each upgrade.
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi
apk upgrade shaterd shater-core luci-app-shater
A bare \`apk upgrade\` reconciles EVERY installed package against every
configured repo and can downgrade unrelated system packages; naming them
upgrades only those (apk-tools 3: \"If list of packages is provided, only
+1 -1
View File
@@ -133,7 +133,7 @@
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
`apk upgrade shaterd shater-core luci-app-shater byedpi`.
`apk upgrade shaterd shater-core luci-app-shater`.
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
иначе.
+2 -2
View File
@@ -64,7 +64,7 @@ apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
once and `apk update && apk upgrade shaterd shater-core luci-app-shater byedpi`
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
@@ -109,7 +109,7 @@ sufficient: it does not see the kernel, procd or nftables seams.
|------|------|
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Product documentation |
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
+6 -7
View File
@@ -139,8 +139,8 @@ BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`
(+ опциональный `byedpi`). `shaterd` подтягивается автоматически как зависимость.
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
`shaterd` подтягивается автоматически как зависимость.
### Фид apk
@@ -158,7 +158,6 @@ echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/a
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # опционально: ByeDPI desync-egress
```
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
@@ -168,14 +167,14 @@ apk add byedpi # опционально: ByeDPI desync-egress
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi
apk upgrade shaterd shater-core luci-app-shater
# эквивалент, дополнительно закрепляющий пакеты в world:
# apk add -u shaterd shater-core luci-app-shater byedpi
# apk add -u shaterd shater-core luci-app-shater
```
Документация apk-tools 3 про `apk upgrade`: *«If list of packages is provided,
only those packages are upgraded along with needed dependencies»*. Проверить
установленные версии: `apk list -I shaterd shater-core luci-app-shater byedpi`.
установленные версии: `apk list -I shaterd shater-core luci-app-shater`.
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
@@ -282,7 +281,7 @@ bash scripts/run-tests.sh --no-race # без -race, для локального
|------|---------|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
+6 -1
View File
@@ -110,7 +110,12 @@ docker run --rm --volumes-from "$(hostname)" \
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
[ -s "$OUT/packages.adb" ] || { echo "[apk-feed] ERROR: $OUT/packages.adb missing/empty" >&2; exit 4; }
apks=$(find "$OUT" -maxdepth 1 -name '*.apk' | wc -l)
[ "$apks" -ge 4 ] || { echo "[apk-feed] ERROR: expected >=4 .apk in $OUT, found $apks" >&2; exit 5; }
# Three, since D29 removed byedpi: shaterd, shater-core, luci-app-shater. The
# count lives in TWO scripts — sdk-build-apk.sh asserts what it collected out of
# bin/, this one asserts what reached the feed dir. v0.2.22 shipped with only the
# first one updated and the aarch64 lane died here on `found 3`, so if the set of
# packages ever changes again, change it in both.
[ "$apks" -ge 3 ] || { echo "[apk-feed] ERROR: expected >=3 .apk in $OUT, found $apks" >&2; exit 5; }
if [ -n "${KEY_APK:-}" ] && [ ! -s "$OUT/shater-apk.pem" ]; then
echo "[apk-feed] ERROR: signed feed but shater-apk.pem missing from $OUT" >&2; exit 6
fi
+14 -13
View File
@@ -30,7 +30,7 @@ echo "[apk-sdk] sdk=$SDK_URL"
# Package version derived from the git tag by ci/version.sh (bug B4). Forwarded
# to the unprivileged build user on the `su` line at the bottom of this file;
# openwrt/{shaterd,shater-core,luci-app-shater}/Makefile pick it up from the
# environment. byedpi keeps upstream ByeDPI's own version (see its Makefile).
# environment. All three are versioned from the tag — there is no exception.
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
test -f "$REPO/openwrt/shaterd/Makefile" || {
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
@@ -135,7 +135,7 @@ if ! ./scripts/feeds update -a; then
./scripts/feeds update -a
fi
echo "[apk-sdk] feeds install (prefer shater feed)"
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
./scripts/feeds install -p shater shaterd shater-core luci-app-shater
# --- strip the SDK's generated per-package `default m` blocks ----------------
# Run 60 settled the question that runs 58 and 59 left open. Writing an explicit
@@ -232,7 +232,7 @@ if [ -s .config.sdk ]; then
.config.sdk | tee -a .config | sed 's/^/[apk-sdk] /' || true
fi
for p in shaterd shater-core byedpi luci-app-shater; do
for p in shaterd shater-core luci-app-shater; do
echo "CONFIG_PACKAGE_$p=m" >> .config
done
# Route source downloads through OpenWrt's fast CDN mirror FIRST. Some upstreams
@@ -336,15 +336,15 @@ fi
echo "[apk-sdk] cache settings after defconfig:"
grep -E '^CONFIG_(LOCALMIRROR|DOWNLOAD_FOLDER)=' .config | sed 's/^/[apk-sdk] /' || true
echo "[apk-sdk] our packages after defconfig:"
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|byedpi|luci-app-shater)=' .config \
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|luci-app-shater)=' .config \
| sed 's/^/[apk-sdk] /' || true
# Each of our 4 must have SURVIVED defconfig. If kconfig dropped one, it is
# Each of our 3 must have SURVIVED defconfig. If kconfig dropped one, it is
# because a symbol it `select`s (a DEPENDS entry) does not exist in the installed
# feeds — with the old append-everything .config that was masked by the SDK
# pre-selecting half the distro. `make package/<p>/compile` would then die with a
# cryptic "No rule to make target", far from the real cause.
for p in shaterd shater-core byedpi luci-app-shater; do
for p in shaterd shater-core luci-app-shater; do
grep -q "^CONFIG_PACKAGE_$p=m" .config || {
echo "[apk-sdk] ERROR: $p is NOT selected after defconfig."
echo " kconfig dropped it -> one of its DEPENDS is missing from the"
@@ -368,7 +368,7 @@ done
grep -m5 '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/ /' || true
exit 11; }
for p in shaterd shater-core byedpi luci-app-shater; do
for p in shaterd shater-core luci-app-shater; do
echo "[apk-sdk] === build $p ==="
make "package/$p/compile" V=s -j"$(nproc)"
done
@@ -384,24 +384,25 @@ anyapk=$(find bin -type f -name '*.apk' | wc -l)
[ "$anyapk" -gt 0 ] || {
echo "[apk-sdk] ERROR: no .apk produced under bin/ (wrong/older SDK? found $(find bin -type f -name '*.ipk' | wc -l) .ipk)";
find bin -maxdepth 4 -type d || true; exit 6; }
# Collect ONLY our 4 packages' .apk (apk filenames carry NO arch:
# Collect ONLY our 3 packages' .apk (apk filenames carry NO arch:
# `<name>-<ver>-r<rel>.apk`). NOT a blanket `*.apk` copy — the SDK bin/ can hold
# prebuilt base/kmod .apk that would bloat the index and be signed under our key.
found=0
for p in shaterd shater-core byedpi luci-app-shater; do
for p in shaterd shater-core luci-app-shater; do
for a in $(find bin -type f -name "${p}-*.apk"); do
cp -f "$a" "$OUT/"; found=$((found+1))
done
done
[ "$found" -ge 4 ] || { echo "[apk-sdk] ERROR: expected >=4 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
[ "$found" -ge 3 ] || { echo "[apk-sdk] ERROR: expected >=3 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
echo "[apk-sdk] collected $found of our .apk"
# --- assert the tag-derived version actually reached the packages -------------
# B4's failure mode is a wrong-but-plausible version shipping silently, so the
# env -> make hand-off is verified, not trusted: each of our three tag-versioned
# packages must be named `<name>-<ver>-r<rel>.apk`. byedpi is excluded on purpose
# (it carries upstream ByeDPI's own version). This runs BEFORE `apk mkndx`, so a
# stale version can never even reach the index.
# packages must be named `<name>-<ver>-r<rel>.apk`. Every package this repo ships
# is tag-versioned, so the check covers all of them with no exception to
# remember. This runs BEFORE `apk mkndx`, so a stale version can never even
# reach the index.
if [ -n "${SHATER_PKG_VERSION:-}" ] && [ -n "${SHATER_PKG_RELEASE:-}" ]; then
want="${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
for p in shaterd shater-core luci-app-shater; do
+3 -1
View File
@@ -38,7 +38,9 @@
# a dispatch build of the tagged commit itself identical to the release build of
# that same commit — which is the truth: same tree, same binary.
#
# `byedpi` is deliberately NOT versioned from our tag — see openwrt/byedpi/Makefile.
# Every package this repo ships is versioned from the tag. There used to be one
# exception (an external tool carrying its upstream's own version); it is gone
# with the package, and nothing here has to remember it any more.
#
# USAGE
# ci/version.sh # or --env: eval-able / $GITHUB_ENV-able lines
+227 -18
View File
@@ -6,6 +6,8 @@ import (
"encoding/binary"
"math/rand"
"net"
"net/netip"
"slices"
"strings"
"time"
@@ -47,6 +49,33 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}()
serverName := IndexTLSServerName(b)
if serverName != nil {
// The SNI extension carries a LIST of names; MyServerName.Length is
// the length of the FIRST entry while MyServerName.ServerName is
// everything left in the extension. Plan the cuts over the first
// entry only: a second entry would otherwise be handed to the
// public suffix list as if it were part of the name.
name := serverName.ServerName
if serverName.Length >= 0 && serverName.Length < len(name) {
name = name[:serverName.Length]
}
// Packet fragmentation pays half a second per cut, record
// fragmentation pays microseconds — see the budget constants.
budget := recordCutBudget
if c.splitPacket {
budget = packetCutBudget
}
splitIndexes := cutOffsets(name, budget, rand.Intn)
if len(splitIndexes) == 0 {
// Nothing inside this name can be cut — it is empty or a single
// byte, so there is no offset that leaves a non-empty piece on
// both sides. Write the ClientHello as it stands: the loop
// below reads b[:splitIndexes[0]] unconditionally and would
// panic on an empty plan.
return c.Conn.Write(b)
}
for i := range splitIndexes {
splitIndexes[i] += serverName.Index
}
if c.splitPacket {
if c.tcpConn != nil {
err = c.tcpConn.SetNoDelay(true)
@@ -55,24 +84,6 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}
}
}
splits := strings.Split(serverName.ServerName, ".")
currentIndex := serverName.Index
if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" {
splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
}
if len(splits) > 1 && splits[0] == "..." {
currentIndex += len(splits[0]) + 1
splits = splits[1:]
}
var splitIndexes []int
for i, split := range splits {
splitAt := rand.Intn(len(split))
splitIndexes = append(splitIndexes, currentIndex+splitAt)
currentIndex += len(split)
if i != len(splits)-1 {
currentIndex++
}
}
var buffer bytes.Buffer
for i := 0; i <= len(splitIndexes); i++ {
var payload []byte
@@ -133,6 +144,204 @@ func (c *Conn) Write(b []byte) (n int, err error) {
return c.Conn.Write(b)
}
// labelSpan is the half-open byte range [start, end) of one DNS label inside a
// server name, relative to the first byte of that name.
type labelSpan struct {
start int
end int
}
// How many cuts Write may spend on one ClientHello. The two numbers differ
// because the two modes cost completely different things per cut — MEASURED
// 2026-07-27 on this tree, loopback peer, product default fallbackDelay
// (500 ms), one ClientHello per row:
//
// cuts tls_fragment (*net.TCPConn) tls_fragment (proxy conn) tls_record_fragment
// 1 502 ms 500 ms <1 ms
// 2 1.004 s 1.001 s <1 ms
// 4 2.008 s 2.002 s <1 ms
// 8 4.015 s 4.003 s 539 µs
// 21 10.540 s 10.509 s 525 µs
//
// So a cut in the PACKET modes costs half a second of connection setup, and it
// costs that on BOTH branches: writeAndWaitAck sleeps the whole fallbackDelay
// anyway whenever the ACK comes back inside 20 ms (its "under transparent
// proxy" case), and N.UnwrapReader only reaches the *net.TCPConn when nothing
// in the chain transforms the stream — which a proxy protocol conn always does,
// so a proxied egress takes the flat-500 ms branch regardless of RTT. The
// number of labels is chosen by whoever picked the hostname, so "a cut in every
// label" made a 253-byte SNI worth ~42 s of one connection's setup.
//
// In tls_record_fragment nothing waits at all: the whole ClientHello leaves in
// ONE write, split into more TLS records. 21 cuts cost 525 µs and 105 bytes of
// record headers, and a real server (1.1.1.1) completed the handshake with the
// ClientHello in 22 records in the same 77 ms it took with 2. That mode is
// where "cut every label" was always affordable — and it is the mode the field
// measurement that started this was taken in.
//
// recordCutBudget is therefore not a cost limit but a shape limit: real names
// have one to three labels outside the public suffix, so 4 never binds on real
// traffic, while a hostile 253-byte name cannot turn one ClientHello into 85
// records that no ordinary client would ever emit.
const (
packetCutBudget = 1
recordCutBudget = 4
)
// cutOffsets plans where the ClientHello must be cut, in byte offsets relative
// to the FIRST BYTE OF THE SERVER NAME, spending at most budget cuts. randIntn
// is math/rand's Intn in production; a test hands in its own to make the plan
// deterministic.
//
// A cut is only a cut if it lands STRICTLY INSIDE a label. Offset 0 of a label
// is that label's own boundary: it leaves the label — the very string the DPI
// box matches on — whole in the following segment. That is not theory. The old
// code drew rand.Intn(len(label)), so a one-byte label could only ever produce
// offset 0, and on the measured provider (blocks by name in the handshake)
// m.youtube.com, tv.youtube.com and www.youtube.com were all blocked with the
// cut sitting uselessly at the start of "m"/"tv"/"www", while the name itself
// travelled intact in one segment. Hence a label shorter than two bytes carries
// no cut at all.
func cutOffsets(name string, budget int, randIntn func(n int) int) []int {
var offsets []int
for _, span := range cutCandidates(name) {
if span.end-span.start < 2 {
continue // no interior offset exists
}
offsets = append(offsets, cutInside(span, randIntn))
if len(offsets) >= budget {
break
}
}
if len(offsets) == 0 && len(name) >= 2 {
// No candidate label was long enough to cut on its own (a.b.co.uk).
// Cut the name somewhere rather than hand it over in one piece: a
// matcher looking for the whole FQDN still fails across the split, even
// though no single label was severed.
offsets = append(offsets, cutInside(labelSpan{start: 0, end: len(name)}, randIntn))
}
slices.Sort(offsets) // candidates are returned by priority, the wire wants order
return offsets
}
// cutInside draws an offset strictly inside span, from its MIDDLE THIRD.
//
// Every interior offset severs the label, but not equally well: a cut one byte
// in leaves "outube" of "youtube", and a matcher keyed on a suffix or on a
// six-byte substring still reads it. The middle leaves two short, unremarkable
// halves. The draw stays random inside that third — a fixed point (say, exactly
// the middle of the longest label) would be a constant a middlebox vendor can
// special-case in one line, and the whole family of fragmentation tricks lives
// on making reassembly the only counter.
func cutInside(span labelSpan, randIntn func(n int) int) int {
lo, hi := span.start+1, span.end-1 // the interior offsets, both inclusive
if margin := (span.end - span.start - 1) / 3; margin > 0 {
lo += margin
hi -= margin
}
return lo + randIntn(hi-lo+1)
}
// cutCandidates returns the labels of name that a cut may land in, MOST WORTH
// CUTTING FIRST — which matters because the budget above is small.
//
// First is the registrable label: the one immediately left of the public
// suffix. That is the label a name-based blocklist keys on ("youtube" of
// youtube.com, www.youtube.com and studio.youtube.com alike, "ytimg" of
// i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also breaks
// any match on the whole FQDN, so one cut covers both matchers. It is chosen by
// STRUCTURE, from the public suffix list — not by length, which is the trap the
// old code fell into from the other side: in cdn-static-assets.youtube.com the
// longest label is not the blocked one.
//
// The rest follow longest-first: among labels we have no structural reason to
// rank, a long one is likelier to be a distinctive token than "www", "m" or
// "tv". They are only reached when the budget allows more than one cut, or when
// the registrable label is too short to cut.
//
// The public suffix itself is dropped because it is shared by everything under
// it and carries none of the blocked word. WIDENING this set needs no proof,
// NARROWING it does, so an input the public suffix list has no opinion about (a
// trailing dot, an unmanaged TLD, a name that IS a suffix) keeps every label.
// No branch here ends up with nothing to cut except the empty name, which has
// nothing to cut by construction.
func cutCandidates(name string) []labelSpan {
spans := labelSpans(name)
suffix := publicsuffix.List.PublicSuffix(name)
switch {
case len(spans) == 0:
// name == "". Nothing to cut; Write sends the ClientHello unchanged.
case isIPLiteral(name):
// An IP literal is not a name (RFC 6066 forbids it in SNI) and its dots
// do not separate labels, so the public suffix list has nothing to say
// about it — it returns the literal itself. Treat the whole literal as
// one token: there is no name for a DPI box to read here, but the
// caller asked for a fragmented handshake and gets one.
return []labelSpan{{start: 0, end: len(name)}}
case suffix != "" && len(suffix) < len(name) && strings.HasSuffix(name, "."+suffix):
// The ordinary case, and the one the old arithmetic got wrong: it
// subtracted the number of dots in the WHOLE NAME, which — labels being
// always one more than dots — left exactly one label, the FIRST, for
// every name in existence. Subtract the number of labels in the SUFFIX
// instead: "com" is one ("www.youtube.com" keeps www + youtube),
// "co.uk" is two ("a.b.co.uk" keeps a + b).
if keep := len(spans) - strings.Count(suffix, ".") - 1; keep > 0 {
spans = spans[:keep]
}
// Everything else — suffix == "" (a trailing dot, which the list
// declines to parse), suffix == name (the name IS a public suffix:
// "com", "co.uk", "localhost"), or a suffix that is somehow not a tail
// of the name — keeps every label. Cutting inside a suffix costs a
// segment and hides nothing that was not already hidden; NOT cutting is
// the expensive mistake.
}
return byCutPriority(spans)
}
// byCutPriority puts the registrable label first and orders the rest
// longest-first. It never drops a span, so the budget — not this — decides how
// many labels are actually cut.
func byCutPriority(spans []labelSpan) []labelSpan {
if len(spans) < 2 {
return spans
}
out := make([]labelSpan, 0, len(spans))
out = append(out, spans[len(spans)-1])
rest := make([]labelSpan, len(spans)-1)
copy(rest, spans[:len(spans)-1])
slices.SortStableFunc(rest, func(a, b labelSpan) int {
return (b.end - b.start) - (a.end - a.start)
})
return append(out, rest...)
}
// labelSpans splits name on '.' and returns the byte range of each label.
// Empty labels (a leading, trailing or doubled dot) come back as zero-width
// spans and are dropped by cutOffsets, which is what keeps a name like
// ".youtube.com" away from rand.Intn(0) — that combination panicked.
func labelSpans(name string) []labelSpan {
if name == "" {
return nil
}
var spans []labelSpan
start := 0
for i := 0; i <= len(name); i++ {
if i == len(name) || name[i] == '.' {
spans = append(spans, labelSpan{start: start, end: i})
start = i + 1
}
}
return spans
}
func isIPLiteral(name string) bool {
_, err := netip.ParseAddr(name)
return err == nil
}
func (c *Conn) ReaderReplaceable() bool {
return true
}
+742
View File
@@ -0,0 +1,742 @@
package tf
// Cut planning: which label of the SNI gets a cut, where inside it, and how
// many cuts one ClientHello is allowed to cost.
//
// WHY THIS FILE EXISTS (2026-07-27)
// Conn.Write used to compute the labels to cut as
//
// splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
//
// which is identically splits[:1] for EVERY name, labels being always one
// more than dots. Exactly one label was ever cut — the LEFTMOST — so on a
// provider that blocks by the name in the handshake, youtube.com passed (its
// first label IS the blocked word) while m./tv./www./music./studio.youtube.com
// were all blocked, the cut sitting inside "m"/"tv"/"www" while "youtube"
// travelled whole in the next segment. Measured on the router.
//
// Two more halves of the same defect:
// - the offset came from rand.Intn(len(label)), whose 0 is the label's own
// boundary and severs nothing. For a one-byte label that is the ONLY
// value it can take;
// - an EMPTY label reached rand.Intn(0) and panicked the process. Reachable
// from the LAN: route/conn.go wraps the outbound with this and the
// ClientHello it fragments is the client's. See
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN.
//
// Every test below fails on the old expressions — see the mutation log.
import (
"crypto/tls"
"encoding/binary"
"io"
"math/rand"
"net"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- deterministic draws -----------------------------------------------------
// minRand takes the lowest offset a label allows, maxRand the highest. Between
// them they pin BOTH ends of the range, which is where the interesting failures
// live: it is the ends that decide whether the label is severed or only touched.
func minRand(int) int { return 0 }
func maxRand(n int) int { return n - 1 }
func fixedRand(v int) func(int) int {
return func(n int) int {
if v >= n {
return n - 1
}
return v
}
}
// severedLabel names the label that a cut at offset o splits in two, or says
// why it splits none. This is the assertion vocabulary of the whole file: the
// question is never "which number came out" but "which word did we break".
func severedLabel(name string, o int) string {
switch {
case o <= 0 || o >= len(name):
return "!outside the name"
case name[o] == '.' || name[o-1] == '.':
return "!a label boundary, nothing severed"
}
start := strings.LastIndexByte(name[:o], '.') + 1
end := len(name)
if i := strings.IndexByte(name[o:], '.'); i >= 0 {
end = o + i
}
return name[start:end]
}
func severedLabels(name string, offsets []int) []string {
var out []string
for _, o := range offsets {
out = append(out, severedLabel(name, o))
}
return out
}
// --- which label is cut ------------------------------------------------------
func TestCutOffsetsCutTheRegistrableLabel(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
packet []string // labels severed with the packet budget (1 cut)
record []string // ... and with the record budget (4 cuts), in wire order
why string
}{
{name: "youtube.com", packet: []string{"youtube"}, record: []string{"youtube"}},
{name: "www.youtube.com", packet: []string{"youtube"}, record: []string{"www", "youtube"},
why: "THE regression: the old code cut www and shipped youtube whole"},
{name: "m.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "a one-byte label has no interior offset and carries no cut"},
{name: "tv.youtube.com", packet: []string{"youtube"}, record: []string{"tv", "youtube"}},
{name: "music.youtube.com", packet: []string{"youtube"}, record: []string{"music", "youtube"}},
{name: "studio.youtube.com", packet: []string{"youtube"}, record: []string{"studio", "youtube"}},
{name: "cdn-static-assets.youtube.com", packet: []string{"youtube"}, record: []string{"cdn-static-assets", "youtube"},
why: "the LONGEST label is not the blocked one — structure decides, not length"},
{name: "foo.bar.baz.youtube.com", packet: []string{"youtube"}, record: []string{"foo", "bar", "baz", "youtube"},
why: "four candidates, four cuts, and the budget stops there"},
{name: "a.b.c.d.e.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "five one-byte labels: the budget is never even reached"},
{name: "i9.ytimg.com", packet: []string{"ytimg"}, record: []string{"i9", "ytimg"}},
{name: "example.co.uk", packet: []string{"example"}, record: []string{"example"},
why: "co.uk is TWO labels of public suffix"},
{name: "a.b.example.co.uk", packet: []string{"example"}, record: []string{"example"}},
{name: "example.com.br", packet: []string{"example"}, record: []string{"example"}},
{name: "site.pp.ru", packet: []string{"site"}, record: []string{"site"},
why: "pp.ru is a private two-label suffix"},
{name: "localhost", packet: []string{"localhost"}, record: []string{"localhost"},
why: "unmanaged TLD: the list returns the whole name, so cut it"},
{name: "com", packet: []string{"com"}, record: []string{"com"}},
{name: "co.uk", packet: []string{"uk"}, record: []string{"co", "uk"},
why: "the name IS the suffix: keep every label rather than cut nothing"},
{name: ".youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
why: "leading dot: the empty label is skipped, NOT fed to rand.Intn(0)"},
{name: "youtube.com.", packet: []string{"youtube"}, record: []string{"youtube", "com"},
why: "trailing dot: the list declines to parse it, so every label stays a candidate"},
{name: "WWW.YouTube.COM", packet: []string{"YouTube"}, record: []string{"WWW", "YouTube"}},
{name: "ab", packet: []string{"ab"}, record: []string{"ab"}},
} {
t.Run(tc.name, func(t *testing.T) {
for _, draw := range []struct {
label string
fn func(int) int
}{{"lowest", minRand}, {"highest", maxRand}} {
got := severedLabels(tc.name, cutOffsets(tc.name, packetCutBudget, draw.fn))
require.Equal(t, tc.packet, got, "%s draw, packet budget: %s", draw.label, tc.why)
got = severedLabels(tc.name, cutOffsets(tc.name, recordCutBudget, draw.fn))
require.Equal(t, tc.record, got, "%s draw, record budget: %s", draw.label, tc.why)
}
})
}
}
// TestCutOffsetsSeverTheBlockedLabel is the field measurement turned into an
// instrument. On the measured provider these names differ only in the label in
// front of "youtube", and five of the six were blocked. What has to hold — for
// every draw and both budgets, not for most of them — is that the byte range of
// the blocked word straddles a cut.
func TestCutOffsetsSeverTheBlockedLabel(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, blocked string }{
{"youtube.com", "youtube"},
{"m.youtube.com", "youtube"},
{"tv.youtube.com", "youtube"},
{"www.youtube.com", "youtube"},
{"music.youtube.com", "youtube"},
{"studio.youtube.com", "youtube"},
{"cdn-static-assets.youtube.com", "youtube"},
{"i9.ytimg.com", "ytimg"},
{"a.b.example.co.uk", "example"},
} {
t.Run(tc.name, func(t *testing.T) {
start := strings.Index(tc.name, tc.blocked)
require.GreaterOrEqual(t, start, 0)
end := start + len(tc.blocked)
// Every draw the label can take, not a sample: the range is small
// enough to enumerate, so there is no "it passed 1000 times" here.
for draw := 0; draw < len(tc.name); draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(tc.name, budget, fixedRand(draw))
severed := false
for _, o := range offsets {
if o > start && o < end {
severed = true
}
}
require.True(t, severed,
"draw %d, budget %d: %q got cuts at %v (%v), none inside %q [%d,%d)",
draw, budget, tc.name, offsets, severedLabels(tc.name, offsets), tc.blocked, start, end)
}
}
})
}
}
// TestCutOffsetsStayInTheMiddleThird: every interior offset severs the label,
// but not equally well — one byte in leaves "outube" of "youtube", which a
// matcher keyed on a substring still reads. Both halves must keep at least
// (width-1)/3 + 1 bytes.
func TestCutOffsetsStayInTheMiddleThird(t *testing.T) {
t.Parallel()
for _, name := range []string{
"youtube.com", "www.youtube.com", "cdn-static-assets.youtube.com",
"music.youtube.com", "example.co.uk", "ab.example.com", "localhost",
} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < 64; draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
for _, o := range cutOffsets(name, budget, fixedRand(draw)) {
label := severedLabel(name, o)
require.NotContains(t, label, "!", "draw %d: cut at %d in %q severed nothing", draw, o, name)
start := strings.Index(name, label)
width := len(label)
margin := (width-1)/3 + 1
require.GreaterOrEqual(t, o-start, margin,
"draw %d: cut at %d leaves only %d byte(s) of %q on the left", draw, o, o-start, label)
require.GreaterOrEqual(t, start+width-o, margin,
"draw %d: cut at %d leaves only %d byte(s) of %q on the right", draw, o, start+width-o, label)
}
}
}
})
}
}
// TestCutOffsetsRespectTheBudget: the budget is what bounds a hostile name's
// cost — a measured 500 ms of connection setup per cut in the packet modes.
func TestCutOffsetsRespectTheBudget(t *testing.T) {
t.Parallel()
var long strings.Builder
for i := 0; i < 40; i++ {
long.WriteString("lb.")
}
long.WriteString("example.com") // 40 cuttable labels plus the registrable one
for _, budget := range []int{1, 2, 3, 4} {
require.Len(t, cutOffsets(long.String(), budget, rand.Intn), budget, "budget %d", budget)
}
require.Len(t, cutOffsets(long.String(), packetCutBudget, rand.Intn), 1,
"a 253-byte SNI must not be able to buy more than one 500 ms wait")
require.Len(t, cutOffsets(long.String(), recordCutBudget, rand.Intn), 4,
"nor more than five records")
}
// TestCutOffsetsFallBackWhenNoLabelCanBeCut covers the names where NO candidate
// label has an interior offset. Severing a label is impossible there, so what
// is checked is that a cut still happens and still lands inside the buffer: a
// matcher keyed on the whole FQDN fails across it.
func TestCutOffsetsFallBackWhenNoLabelCanBeCut(t *testing.T) {
t.Parallel()
for _, name := range []string{"a.b.co.uk", "x.pp.ru", "a.b.c.d", "1.2.3.4", "::1", "x.com"} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < len(name)+4; draw++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(name, budget, fixedRand(draw))
require.NotEmpty(t, offsets, "%q went out in one piece", name)
require.Greater(t, offsets[0], 0)
require.Less(t, offsets[len(offsets)-1], len(name))
}
}
})
}
}
// TestCutOffsetsAreOrderedAndDistinct: the write loop slices b between
// consecutive offsets, so anything out of order or repeated is an empty or
// negative segment on the wire. Candidates come back in PRIORITY order, which
// is not wire order — this is the test that the sort is not forgotten.
func TestCutOffsetsAreOrderedAndDistinct(t *testing.T) {
t.Parallel()
for _, name := range []string{
"www.youtube.com", "foo.bar.baz.youtube.com", "cdn-static-assets.youtube.com",
"a.bb.ccc.dddd.example.com", "youtube.com.", ".youtube.com", "co.uk",
} {
t.Run(name, func(t *testing.T) {
for i := 0; i < 200; i++ {
offsets := cutOffsets(name, recordCutBudget, rand.Intn)
prev := 0
for _, o := range offsets {
require.Greater(t, o, prev, "%q: offsets %v are not strictly increasing", name, offsets)
prev = o
}
require.Less(t, prev, len(name))
}
})
}
}
// --- the arithmetic must not panic on anything ------------------------------
// adversarialNames is the closed list of shapes that reach the arithmetic from
// outside: empty and one-byte names, every position a dot can take, names that
// are nothing but dots, names at the 253-byte limit, and bytes that are not
// ASCII at all. The unit test, the fuzz seed corpus and the end-to-end test all
// draw from it, so all three see the same inputs.
func adversarialNames() []string {
return []string{
"", "a", ".", "..", "...", "....",
".com", "com.", ".com.", ".youtube.com", "youtube.com.", ".youtube.com.",
"a..b.example.com", "..youtube..com..", "-.-.-.-", "-", "--",
"xn--p1ai", "test.xn--p1ai", "xn--", ".xn--p1ai.",
"\xff\xfe.example.com", "\x00\x00.com", "пример.рф", "\xff",
strings.Repeat("a", 253),
strings.Repeat("ab.", 84) + "a", // 253 bytes, 85 labels
strings.Repeat(".", 253),
strings.Repeat("a.", 126) + "a",
"1.2.3.4", "::1", "::ffff:1.2.3.4", "fe80::1%eth0", "0.0.0.0",
}
}
func TestCutOffsetsSurviveEveryAdversarialName(t *testing.T) {
t.Parallel()
for _, name := range adversarialNames() {
t.Run(strings.ToValidUTF8(name, "?"), func(t *testing.T) {
for i := 0; i < 100; i++ {
for _, budget := range []int{packetCutBudget, recordCutBudget} {
offsets := cutOffsets(name, budget, rand.Intn) // must not panic
require.LessOrEqual(t, len(offsets), budget)
if len(name) >= 2 {
require.NotEmpty(t, offsets, "%q is long enough to cut and was not cut", name)
} else {
require.Empty(t, offsets, "%q has no offset that leaves bytes on both sides", name)
}
prev := 0
for _, o := range offsets {
require.Greater(t, o, prev)
require.Less(t, o, len(name))
prev = o
}
}
}
})
}
}
// FuzzCutOffsets is the open half of the audit above: the closed list says what
// we thought of, this says whether anything else reaches rand.Intn with a
// non-positive argument or produces an offset the write loop cannot slice at.
// Under plain `go test` it runs the seed corpus, which is that closed list.
func FuzzCutOffsets(f *testing.F) {
for _, name := range adversarialNames() {
f.Add(name, packetCutBudget)
f.Add(name, recordCutBudget)
}
for _, name := range []string{"www.youtube.com", "a.b.example.co.uk", "localhost"} {
f.Add(name, 1)
f.Add(name, 4)
}
f.Fuzz(func(t *testing.T, name string, budget int) {
if budget < 0 {
budget = -budget
}
budget = budget%recordCutBudget + 1 // 1..4, never zero or negative
offsets := cutOffsets(name, budget, rand.Intn)
if len(offsets) > budget {
t.Fatalf("%q: %d offsets for a budget of %d", name, len(offsets), budget)
}
if len(name) >= 2 && len(offsets) == 0 {
t.Fatalf("%q (%d bytes) was handed over in one piece", name, len(name))
}
prev := 0
for _, o := range offsets {
if o <= prev || o >= len(name) {
t.Fatalf("%q: offsets %v are not strictly increasing inside [1,%d)", name, offsets, len(name))
}
prev = o
}
})
}
// --- end to end: what actually goes out on the wire -------------------------
// fakeConn records every Write. It is deliberately NOT a *net.TCPConn, which is
// also the common production case (the outbound is usually a proxy stream whose
// reader transforms the bytes, so N.UnwrapReader stops there), so Conn.Write
// takes the sleep-instead-of-ACK path — hence the 1ns fallback delay the tests
// below pass to NewConn.
type fakeConn struct {
writes [][]byte
}
func (c *fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
func (c *fakeConn) SetReadDeadline(time.Time) error { return nil }
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
func (c *fakeConn) Write(b []byte) (int, error) {
c.writes = append(c.writes, append([]byte(nil), b...))
return len(b), nil
}
// clientHelloFor produces a real ClientHello for serverName by letting
// crypto/tls build one and capturing the first write.
func clientHelloFor(t *testing.T, serverName string) []byte {
t.Helper()
rec := &fakeConn{}
_ = tls.Client(rec, &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}).Handshake()
require.NotEmpty(t, rec.writes, "crypto/tls wrote no ClientHello for %q", serverName)
hello := rec.writes[0]
// Control on the instrument: the parser this package ships must find the
// name we asked for, otherwise the assertions below prove nothing.
sni := IndexTLSServerName(hello)
require.NotNil(t, sni, "IndexTLSServerName found no SNI in the generated ClientHello")
require.Equal(t, serverName, sni.ServerName)
return hello
}
// buildClientHello assembles a ClientHello by hand around a server_name_list of
// the given entries. crypto/tls will not emit a name with a leading or trailing
// dot, an IP literal, an empty name or a second list entry — hostnameInSNI
// rewrites or refuses all of them — and those are exactly the shapes a
// forwarded ClientHello from a LAN client can carry.
func buildClientHello(t *testing.T, entries ...string) []byte {
t.Helper()
var list []byte
for _, e := range entries {
list = append(list, sniNameDNSHostnameType)
list = binary.BigEndian.AppendUint16(list, uint16(len(e)))
list = append(list, e...)
}
extBody := binary.BigEndian.AppendUint16(nil, uint16(len(list)))
extBody = append(extBody, list...)
ext := binary.BigEndian.AppendUint16(nil, sniExtensionType)
ext = binary.BigEndian.AppendUint16(ext, uint16(len(extBody)))
ext = append(ext, extBody...)
extensions := binary.BigEndian.AppendUint16(nil, uint16(len(ext)))
extensions = append(extensions, ext...)
body := []byte{0x03, 0x03} // client_version TLS 1.2
body = append(body, make([]byte, 32)...) // random
body = append(body, 0x00) // session_id length
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites
body = append(body, 0x01, 0x00) // compression_methods
body = append(body, extensions...)
handshake := []byte{handshakeType, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
handshake = append(handshake, body...)
record := []byte{contentType, 0x03, 0x01}
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
record = append(record, handshake...)
// Control on the instrument: this hand-built record must parse the way a
// real one does, or the tests below are testing a straw man.
sni := IndexTLSServerName(record)
require.NotNil(t, sni, "hand-built ClientHello did not parse")
require.Equal(t, len(entries[0]), sni.Length, "Length must be the FIRST entry")
require.Equal(t, entries[0], string(record[sni.Index:sni.Index+sni.Length]))
return record
}
// patchSNI rewrites the server name inside a ClientHello in place. from and to
// must be the same length, so every length field in the record stays valid.
func patchSNI(t *testing.T, hello []byte, from, to string) []byte {
t.Helper()
require.Equal(t, len(from), len(to), "patchSNI cannot change the length")
at := IndexTLSServerName(hello)
require.NotNil(t, at)
require.Equal(t, from, at.ServerName)
out := append([]byte(nil), hello...)
copy(out[at.Index:], to)
sni := IndexTLSServerName(out)
require.NotNil(t, sni)
require.Equal(t, to, sni.ServerName)
return out
}
// segments returns, for one recorded run, the payload of every segment written
// and the absolute offsets in hello at which the cuts fell.
func segments(t *testing.T, hello []byte, writes [][]byte, recordFragment bool) ([][]byte, []int) {
t.Helper()
var payloads [][]byte
for _, w := range writes {
if !recordFragment {
payloads = append(payloads, w)
continue
}
// A record-fragmented write is one or more TLS records: 3 bytes of the
// original header, a 2-byte length, then the payload.
for len(w) > 0 {
require.GreaterOrEqual(t, len(w), recordLayerHeaderLen, "truncated record header")
require.Equal(t, hello[:3], w[:3], "record header is not the ClientHello's own")
n := int(binary.BigEndian.Uint16(w[3:5]))
require.LessOrEqual(t, recordLayerHeaderLen+n, len(w), "record length runs past the write")
payloads = append(payloads, w[recordLayerHeaderLen:recordLayerHeaderLen+n])
w = w[recordLayerHeaderLen+n:]
}
}
require.NotEmpty(t, payloads, "Write returned without putting anything on the wire")
// Cut offsets are the cumulative payload lengths, shifted past the record
// header that the first fragment drops.
offset := 0
if recordFragment {
offset = recordLayerHeaderLen
}
var cuts []int
for _, p := range payloads[:len(payloads)-1] {
offset += len(p)
cuts = append(cuts, offset)
}
return payloads, cuts
}
type writeMode struct {
name string
splitPacket bool
splitRecord bool
recordFraming bool
segmentPerCall bool // one Write call per segment
budget int
}
var writeModes = []writeMode{
{name: "tls_fragment", splitPacket: true, segmentPerCall: true, budget: packetCutBudget},
{name: "tls_record_fragment", splitRecord: true, recordFraming: true, budget: recordCutBudget},
{name: "both", splitPacket: true, splitRecord: true, recordFraming: true, segmentPerCall: true, budget: packetCutBudget},
}
// TestWriteSeversTheBlockedLabelOnTheWire is the end-to-end control: not "the
// planner returned nice numbers" but "the bytes that left the socket have the
// blocked label straddling a segment boundary", for every mode the presets
// expose, over many real random draws.
func TestWriteSeversTheBlockedLabelOnTheWire(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, tc := range []struct{ name, blocked string }{
{"youtube.com", "youtube"}, // the ONE name the old code got right
{"www.youtube.com", "youtube"}, // the regression
{"m.youtube.com", "youtube"}, // one-byte label in front
{"music.youtube.com", "youtube"},
{"cdn-static-assets.youtube.com", "youtube"},
{"a.b.example.co.uk", "example"}, // two-label public suffix
} {
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, tc.name)
sniAt := IndexTLSServerName(hello).Index
start := sniAt + strings.Index(tc.name, tc.blocked)
end := start + len(tc.blocked)
for i := 0; i < 100; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n, "Write must report the length of the buffer it was given")
_, cuts := segments(t, hello, out.writes, mode.recordFraming)
require.NotEmpty(t, cuts, "the ClientHello went out in one piece")
require.LessOrEqual(t, len(cuts), mode.budget, "more cuts than this mode's budget")
severed := false
for _, c := range cuts {
if c > start && c < end {
severed = true
}
}
require.True(t, severed,
"run %d: %q left with cuts at %v, none inside %q [%d,%d)",
i, tc.name, cuts, tc.blocked, start, end)
}
})
}
}
}
// TestWriteReassemblesToTheOriginalClientHello: cutting may change how the
// bytes are packaged and nothing else. Byte-for-byte, plus the length Write
// reports, plus the segment count implied by the plan.
func TestWriteReassemblesToTheOriginalClientHello(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, serverName := range []string{
"www.youtube.com", "youtube.com", "a.b.example.co.uk", "localhost", "a",
"foo.bar.baz.youtube.com",
} {
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, serverName)
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p, "empty segment: a cut of zero length went out on the wire")
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
// The record header is re-emitted per fragment, so what
// must survive is the handshake body.
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "run %d: the reassembled ClientHello differs from the original", i)
if mode.segmentPerCall {
require.Len(t, out.writes, len(cuts)+1, "one Write call per segment")
} else {
require.Len(t, out.writes, 1, "record fragmentation without packet fragmentation is a single write")
}
}
})
}
}
}
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN is the regression for a
// PROCESS DEATH that shipped in v0.2.21.
//
// route/conn.go wraps the outbound connection with this Conn and fragments the
// ClientHello the LAN client sent, so the server name is chosen by the client,
// not by us. An empty label made cutOffsets call rand.Intn(0) — "panic: invalid
// argument to Intn" — and a Go panic in a connection goroutine takes the whole
// daemon with it. Two ordinary ways to produce one:
//
// "youtube.com." a fully qualified name with the root dot, which curl and
// every browser will happily send, and for which the public
// suffix list returns "" so the trailing empty label survived;
// ".youtube.com" a leading dot, which nothing legitimate sends but nothing
// stops a client from writing into its own ClientHello.
//
// With the kill switch armed the daemon's death is not a slow connection, it is
// a dark LAN until procd restarts it — into the same request.
func TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ from, to, why string }{
{from: "youtube.comx", to: "youtube.com.", why: "the FQDN root dot — a legitimate name"},
{from: "xyoutube.com", to: ".youtube.com", why: "leading dot"},
{from: "xyoutube.comx", to: ".youtube.com.", why: "both"},
{from: "ax.example.com", to: "a..example.com", why: "a doubled dot mid-name"},
{from: "xxxxxxxxxxxx", to: "............", why: "nothing but dots"},
{from: "1x2x3x4", to: "1.2.3.4", why: "an IP literal, which RFC 6066 forbids in SNI"},
{from: "xxx", to: "::1", why: "an IPv6 literal"},
{from: "a", to: "a", why: "one byte: no cut exists, and the empty plan must not be indexed"},
{from: "ab", to: "ab", why: "two bytes: exactly one interior offset"},
{from: "\xff\xfe.example.com", to: "\xff\xfe.example.com", why: "bytes that are not ASCII"},
} {
t.Run(strings.ToValidUTF8(tc.to, "?"), func(t *testing.T) {
t.Parallel()
hello := patchSNI(t, clientHelloFor(t, tc.from), tc.from, tc.to)
for _, mode := range writeModes {
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err, "%s: %s", mode.name, tc.why)
require.Equal(t, len(hello), n, "%s: %s", mode.name, tc.why)
payloads, _ := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p)
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "%s run %d: %s", mode.name, i, tc.why)
}
}
})
}
}
// TestWriteHandlesServerNameListsCryptoTLSWillNotEmit reaches the shapes that
// need a hand-built record: a zero-length name, a name at the 253-byte limit,
// and a list carrying a SECOND entry — which MyServerName.ServerName includes
// and MyServerName.Length does not, so cut planning must run on the first entry
// alone or it feeds the public suffix list bytes that belong to no name.
func TestWriteHandlesServerNameListsCryptoTLSWillNotEmit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
title string
entries []string
cut bool // must the ClientHello leave in more than one piece?
}{
{title: "empty name", entries: []string{""}, cut: false},
{title: "one byte", entries: []string{"a"}, cut: false},
{title: "253 bytes, 85 labels", entries: []string{strings.Repeat("ab.", 84) + "a"}, cut: true},
{title: "253 bytes, one label", entries: []string{strings.Repeat("a", 253)}, cut: true},
{title: "two entries", entries: []string{"www.youtube.com", "evil.example.com"}, cut: true},
{title: "two entries, first empty", entries: []string{"", "www.youtube.com"}, cut: false},
{title: "trailing dot", entries: []string{"youtube.com."}, cut: true},
} {
t.Run(tc.title, func(t *testing.T) {
t.Parallel()
hello := buildClientHello(t, tc.entries...)
for _, mode := range writeModes {
for i := 0; i < 20; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err, mode.name)
require.Equal(t, len(hello), n, mode.name)
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
require.Equal(t, tc.cut, len(cuts) > 0, "%s: expected cut=%v, got %d cut(s)", mode.name, tc.cut, len(cuts))
require.LessOrEqual(t, len(cuts), mode.budget, mode.name)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p)
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "%s run %d", mode.name, i)
}
}
})
}
}
// TestWriteCutsTheFirstEntryOfTheServerNameList: with two entries the cut must
// land inside "youtube" of the FIRST one. Planning over the whole remainder of
// the extension would hand the public suffix list a string that is not a name
// and put the cut somewhere else entirely.
func TestWriteCutsTheFirstEntryOfTheServerNameList(t *testing.T) {
t.Parallel()
hello := buildClientHello(t, "www.youtube.com", "cdn-static-assets.example.com")
sni := IndexTLSServerName(hello)
start := sni.Index + strings.Index("www.youtube.com", "youtube")
end := start + len("youtube")
for i := 0; i < 200; i++ {
out := &fakeConn{}
_, err := NewConn(out, t.Context(), true, false, time.Nanosecond).Write(hello)
require.NoError(t, err)
_, cuts := segments(t, hello, out.writes, false)
require.Len(t, cuts, 1)
require.Greater(t, cuts[0], start, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
require.Less(t, cuts[0], end, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
}
}
// TestWriteWithoutSNIIsUntouched: the fast path must stay a straight pass, and
// the second and later writes must never be re-planned.
func TestWriteWithoutSNIIsUntouched(t *testing.T) {
t.Parallel()
payload := []byte("not a tls record at all")
out := &fakeConn{}
conn := NewConn(out, t.Context(), true, true, time.Nanosecond)
n, err := conn.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.Len(t, out.writes, 1)
require.Equal(t, payload, out.writes[0])
hello := clientHelloFor(t, "www.youtube.com")
n, err = conn.Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
require.Len(t, out.writes, 2, "a ClientHello after the first write must not be fragmented")
require.Equal(t, hello, out.writes[1])
}
+72
View File
@@ -142,6 +142,12 @@ with a modest one-time decompress-into-RAM cost at start. Ship compressed; keep
uncompressed artifact for debugging.
## D13 — External DPI-bypass tool = **ByeDPI** (a SOCKS egress), NOT zapret
> **PARTLY REVERSED by [D29](#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken) (2026-07-27).** The
> comparison below still stands and zapret is still rejected. What did not stand
> is the premise that the native presets were too weak to carry this: they were
> not weak, they were defective. ByeDPI, the `byedpi` egress kind and the
> `openwrt/byedpi` package are gone. Read D29 before acting on anything here.
Decided 2026-07-14. We evaluated exactly two external desync tools — **zapret**
(nfqws/tpws, NFQUEUE packet plane) vs **ByeDPI/ciadpi** (a local SOCKS5 desync
proxy) — and picked **one**: ByeDPI. DPI-bypass stays a **per-ruleset egress
@@ -1418,3 +1424,69 @@ all of them end up above `main` — but it is luck, not design. Moving to explic
`pref` values touches every existing rule and needs a migration for rules already
installed on implicit numbers; that is its own piece of work, not a rider on this
one.
## D29 — ByeDPI is removed: the presets it replaced were not weak, they were broken
Decided 2026-07-27. **This reverses the ByeDPI half of [D13](#d13--external-dpi-bypass-tool--byedpi-a-socks-egress-not-zapret).** The
`byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness
endpoint `GET /api/byedpi` and the panel's readiness plate are all deleted. The
zapret half of D13 is untouched: zapret was rejected for reasons that have
nothing to do with this and stays rejected.
**Why it was added.** D13 read: the native route-action presets
(`tls_fragment` / `tls_record_fragment` / `tls_spoof`) "cover the light 'just
fragment the ClientHello' case", and ByeDPI "is what we add for the stronger
methods the engine lacks". That sentence was written from observed behaviour —
the native presets were tried against a real ISP and did not get through — and
the conclusion drawn from it was that the METHOD was too weak.
**Why it is removed.** The method was never tried. `common/tlsfragment/conn.go`
chose the split point by dropping a number of labels equal to the number of DOTS
in the whole name — and a name always has one more label than it has dots. So
the cut always landed inside the **first** label. For `www.youtube.com` it split
`www` and handed `youtube` to the wire in one intact piece, which is precisely
the word the DPI matches on. Measured against the live ISP: of six blocked
names, exactly one got through — `youtube.com`, the one whose first label IS the
blocked word. That is not a weak desync, it is a desync aimed at the wrong three
characters, and every conclusion drawn from its failure rate was a conclusion
about our own defect.
The fix is one expression. With it, the built-in presets do the job that the
external process was brought in to do, and the external process is 100 KB of
binary, a second procd service, a second UCI file, a port that agreed with our
egress by hand-written comment only, a readiness prober, a five-state service
model, a per-egress port cross-check and a panel plate — all to work around a
bug in fifteen lines of our own code.
**So this is not "ByeDPI turned out to be bad".** It is a good tool. It turned
out not to be needed, and the reason we thought it was needed was ours.
**What happens to a config that still says `type 'byedpi'`.** Nothing is
migrated and nothing is rewritten. The kind stays unbuildable, which means it
stays **fail-closed**: no outbound, no mark, no `ip rule`, no routing table, so
everything bound to that egress is blocked rather than released onto the plain
WAN. The one thing that changes is what the operator is TOLD. `byedpi` is
recorded in `model.RetiredEgressTypes` — a closed, positive table read by both
`ValidateEgresses` and the generator — and draws a sentence naming the removal,
the replacement (`direct`/`interface` with `dpi 'record'`), the honest caveat
that which preset defeats a given ISP is not something we can promise, and how
to take the dead package off the router.
A migration rewriting `byedpi` → `direct` was considered and rejected. It is the
only rewrite that leaves the egress routing at all, and it would silently
convert a blocked egress into a live plain-WAN path with the router's real
address — the exact leak class this codebase refuses everywhere else, performed
by an upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore
**not** bumped either: no stored field changes meaning, nothing is migrated, and
a bump would only make configs written by this build unreadable to an older
daemon (Migrate refuses a newer schema) for no gain.
**The package is not uninstalled by this change.** Dropping it from the feed does
not remove it from a router it is already on. `apk del byedpi` does, it is safe,
and it is written down in `INSTALL.md` beside the update command — which loses
its fourth name: `apk upgrade shaterd shater-core luci-app-shater`.
**When ByeDPI would win again.** If a fixed `tls_fragment`/`tls_record_fragment`
still fails against an ISP that fake/disorder/oob/autottl defeats, the argument
in D13 for choosing ByeDPI over zapret is still the right argument and this
decision is the one to revisit — with a measurement of the FIXED presets first,
which is the step that was skipped last time.
+111 -14
View File
@@ -83,14 +83,13 @@ probe case in `shater/generate/shipped_tags_linux_test.go`.
## 2. Packages
Four OpenWrt packages live under `openwrt/`:
Three OpenWrt packages live under `openwrt/`:
| Package | Arch | What it ships |
|--------------------|-----------|---------------|
| `shaterd` | per-arch | **Prebuilt** static `shaterd` binary → `/usr/bin/shaterd` (this is the ship artifact from step 1). |
| `shater-core` | all | procd init (supervises `shaterd run`), the boot armor (§4), cron, hotplug, sysctl, inert default UCI. `DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle`. |
| `luci-app-shater` | all | Thin LuCI launcher: mini dashboard + token-handoff "Open panel" button. `DEPENDS:=+shater-core +rpcd`. |
| `byedpi` | per-arch | *Optional* ByeDPI (`ciadpi`) local desync SOCKS proxy for a `type='byedpi'` egress. |
### Why `shaterd` is a prebuilt-binary package
@@ -138,11 +137,9 @@ builds. `ci/sdk-build-apk.sh` then **asserts** the produced `.apk` really carrie
it, so a lost variable fails the build instead of shipping a stale version. The
release job asserts the same version again on the published rolling repo (§5.1).
`byedpi` is deliberately excluded — `PKG_VERSION:=0.17.3` is *upstream ByeDPI's*
version, which is what `PKG_HASH` pins and what tells you which ByeDPI is
installed. Stamping our tag on it would also be a downgrade: every comparator
reads `0.2.7 < 0.17.3` (component-wise, `2 < 17`). Bump its `PKG_RELEASE` by hand
when our packaging of it changes.
All three are versioned this way. There used to be a fourth package carrying its
upstream's own version and therefore exempt from the assertion above; it is gone
(D29), and with it the exception nobody could be expected to remember.
## 3. Install on a router
@@ -160,7 +157,6 @@ apk filenames carry no architecture, so make sure you copied the `.apk` built fo
apk add --allow-untrusted ./shaterd-<ver>.apk
apk add --allow-untrusted ./shater-core-<ver>.apk
apk add --allow-untrusted ./luci-app-shater-<ver>.apk
apk add --allow-untrusted ./byedpi-0.17.3-r1.apk # optional: ByeDPI egress
```
From the repo instead (§5 sets it up once), deps pull the rest in:
@@ -346,7 +342,6 @@ echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/a
# 3) refresh + install (shaterd pulled in as a dependency).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # optional: ByeDPI desync egress
```
### 5.3 Updating
@@ -358,7 +353,7 @@ unrelated system packages. Always name ours:
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi
apk upgrade shaterd shater-core luci-app-shater
```
apk-tools 3 documents exactly this behaviour for `apk upgrade`: *"When no
@@ -368,16 +363,118 @@ dependencies."* The equivalent form, which additionally re-pins the packages in
`world`, is:
```sh
apk add -u shaterd shater-core luci-app-shater byedpi # -u = --upgrade
apk add -u shaterd shater-core luci-app-shater # -u = --upgrade
```
Drop `byedpi` from either list if you never installed it. Check what you are on
with `apk list -I shaterd shater-core luci-app-shater byedpi` — the version reads
Check what you are on
with `apk list -I shaterd shater-core luci-app-shater` — the version reads
`0.2.7-r1` (§2.1: `PKG_VERSION-rPKG_RELEASE`, derived from the git tag by CI, so
every build really is a new version; before that fix v0.2.2…v0.2.6 all published
as `0.2.0-r3` and `apk update` offered nothing). Rolling vs pinned repo URL —
§5.1.
#### If this router still has `byedpi` installed
Older releases shipped a fourth, optional package — `byedpi` (the `ciadpi` local
desync proxy) — behind an egress of `type='byedpi'`. Both are **removed from the
product** ([D29](DECISIONS.md#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken)):
the desync it provided is done by the engine's own `dpi` presets, which were
failing for a defect of ours that is fixed.
Dropping it from the feed does **not** take it off a router it is already on —
nothing here uninstalls anything. Remove it by hand:
```sh
apk del byedpi
```
That is safe. No shater package depends on it, nothing in `shaterd` looks for the
`ciadpi` binary any more, and it takes `/etc/init.d/byedpi`, `/usr/bin/ciadpi`
and (unless you edited it) `/etc/config/byedpi` with it. Leaving it installed is
also harmless — it is then simply a service nothing routes to.
If an egress in `/etc/config/shater` still says `option type 'byedpi'`, it is
**blocked, not leaking**: the daemon builds no outbound for the kind, so every
node, group and rule bound to that egress stops rather than going out over the
plain WAN. The panel and the apply warnings name it and say what to change it to
— `direct` (or `interface`) with `dpi 'record'`. Nothing is migrated for you on
purpose: the only automatic rewrite that would keep the egress routing is the one
that would silently put that traffic on the plain WAN.
### 5.4 Downgrading — going back to an older build
Per-version releases live forever (`apk-vX.Y.Z-<arch>`, §5.1), so the way back is
always open. It is a different command from updating, because **`apk upgrade`
never downgrades** — that is not a policy of ours, it is what the solver does.
**Read this first: an older build refuses to write a newer config.** The UCI
schema version is stored in `globals.schema_version`, and a build that finds a
schema NEWER than it understands refuses every config write — the panel, the
6-hourly subscription refresh and the profile watcher all stop persisting, with a
message naming both versions. That refusal is the recoverable outcome: your
`/etc/config/shater` is untouched, and installing the newer package again brings
everything back. The alternative would have been an older build quietly rewriting
the file in its own, poorer form. The file as it stood before the first change of
any build is also kept at `/etc/shater/config.pre-v<schema>.bak`.
So: downgrade across a schema bump only as a temporary measure, and expect the
box to hold the config it has rather than accept edits.
```sh
# 0) note where you are, and keep it.
apk list -I shaterd shater-core luci-app-shater
# 1) repoint the repo file at the PINNED per-version release you want.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-v0.2.9-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 2) refresh, and read the exact version strings that feed offers.
apk update
apk list shaterd shater-core luci-app-shater
# 3) install them BY NAME with an explicit version. `=` is what downgrades.
apk add shaterd=0.2.9-r1 shater-core=0.2.9-r1 luci-app-shater=0.2.9-r1
```
Going back up afterwards is two commands, not one — the `=` form leaves a
**pinned constraint** in `/etc/apk/world` (`shaterd=0.2.9-r1`), and a pin outranks
an upgrade:
```sh
# repoint /etc/apk/repositories.d/shater.list back (rolling, or the newer tag)
apk update
apk add shaterd shater-core luci-app-shater # drops the =version pin
apk upgrade shaterd shater-core luci-app-shater # moves the packages
```
**Measured, not inferred** — on the testbed VM (ImmortalWrt 25.12.1
`r37978-cd0a06bfd3fd`, apk-tools 3.0.5, x86_64), against the real
`apk-v0.2.9-x86_64` and `apk-v0.2.10-x86_64` feeds, in an isolated `--root`
sandbox so nothing on the box moved:
| Command, with only v0.2.9 in the shater feed | What apk actually did |
|---|---|
| `apk upgrade shaterd` | nothing — stayed on `0.2.10-r1` |
| `apk add shaterd=0.2.9-r1` | `Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1)`, and `world` became `shaterd=0.2.9-r1` |
| `apk add shaterd` (after the pin) | pin cleared; the installed version did **not** move |
| `apk upgrade shaterd` (pin cleared, feed back at v0.2.10) | `Upgrading shaterd (0.2.9-r1 -> 0.2.10-r1)` |
| `apk add shaterd=0.2.9-r1` while the feed carries only 0.2.10 | `ERROR: unable to select packages: shaterd-0.2.10-r1: breaks: world[shaterd=0.2.9-r1]` — nothing installed. Repoint the repo FIRST. |
**`apk upgrade -a` is not the way to do this**, even though it does downgrade.
`--available` reconciles against the repositories rather than against the
installed versions, and naming our packages does **not** keep it to them: the same
run on the testbed reported
```
(22/27) Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1)
(23/27) Downgrading shater-core (0.2.10-r1 -> 0.2.9-r1)
(24/27) Downgrading luci-app-shater (0.2.10-r1 -> 0.2.9-r1)
```
together with `luci-app-attendedsysupgrade`, `luci-i18n-firewall-zh-cn` and two
more unrelated packages rolled back to whatever the distfeed snapshot holds. Use
the `=version` form, which touched exactly the three packages named.
### BananaWRT `25.12-mtk-vendor` compatibility
The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
@@ -385,7 +482,7 @@ The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
**`aarch64_cortex-a53`**, and its images even point their distfeeds at vanilla
`downloads.immortalwrt.org/releases/25.12-SNAPSHOT` — so packages built with the
vanilla ImmortalWrt 25.12 filogic SDK install cleanly; no SuperKali-special SDK
is needed. We ship **no kmods** (shaterd is a static Go binary, byedpi plain C),
is needed. We ship **no kmods** (shaterd is a static Go binary),
so the vendor 6.6 kernel is irrelevant to our packages.
What was actually checked in the BananaWRT mtk-vendor `config.buildinfo` is
+78 -23
View File
@@ -28,6 +28,14 @@ openwrt/
luci-app-shater/ # thin LuCI launcher [Phase 3]
```
> That block is the Phase-2 **plan**, kept because the wave assignment below reads
> from it. The tree that shipped is flatter — `shater/` holds `alert apply
> buildtags cmd devices engine generate logsink model netplane panel parse
> registry stats subscribe` — with rulesets, schedules, profiles, backup and
> migration living inside `model/` and `generate/` rather than as packages of
> their own, and with **no `preset/`**: v0.2 has no preset subsystem at all (see
> the `config preset` note in the schema section).
Build order / waves (parallel agents must own DISJOINT dirs, build only their own
package, and never edit `go.mod`):
- **Wave 1 (contract):** `model/` (+ uci reader + migrate). Everything imports it.
@@ -76,8 +84,7 @@ flock. Commit-confirm/rollback and read verbs can follow, but Teardown must be h
`procd_set_param file` watch); `reload_service`→start/stop; `service_triggers`
reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-cron`
(sub/ruleset/schedule due + reconcile). `uci-defaults/30_shater-core` (seed rt_tables
8192, enable inits, seed disabled presets, `model.Migrate`, sysctl from
`netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
8192, enable inits, `model.Migrate`, sysctl from `netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
`sysctl.d/99-shater.conf` (from `netplane`). Default `/etc/config/shater` conffile.
---
@@ -136,7 +143,7 @@ reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-
- **conns.go** — `ConnsJSON`, `parseConntrack` (live flows, proxied/direct).
- **flock_unix.go** — real blocking cross-process flock; `lockPath=/var/lock/xrayctl.lock`.
- **geodata.go** — `geoAssetPresent`, `geoStrip`, `GeodataStatus/Download/Remove` (strip geo matchers when dat absent).
- **migrate.go** — `Migrate`, `CurrentSchemaVersion=1`, `uciRunner` (UCI schema migration; refuses newer).
- **migrate.go** — `Migrate`, `uciRunner`, and on the v0.1 branch `CurrentSchemaVersion = 1` with a single step `{0 -> 1}`. **That 1 is v0.1's number and nothing else's.** v0.2's `shater/model/migrate.go` is at `CurrentSchemaVersion = 2` with `{0 -> 1, 1 -> 2}` — `migrate1to2` is the one that removed `dst_domain`/`dst_ip` from `config rule` (see the schema subsection below, which is the live document). Both versions refuse a config NEWER than the build; in v0.2 that refusal also covers every config WRITE (`model.ErrSchemaTooNew`), so a downgraded package cannot quietly rewrite a newer config into the older form.
- **nodeops.go** — `NodeSetEnabled/NodeDelete/NodeAssignGroup/NodeQR`.
- **observatory.go** — read xray live state via gRPC API inbound (127.0.0.1:10853). (rewrite → lx command server)
- **preset.go** — `presetRules`, `presetDef`, `builtinPresets` (curated rule packs → synthetic Rules).
@@ -251,6 +258,20 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
> v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
### uci.go — `/etc/config/shater` schema
> This subsection alone describes the **CURRENT v0.2 schema**, not v0.1 — the
> shipped `/etc/config/shater` points its reader here by name, so it is kept in
> step with `shater/model/uci.go` (read) and `render.go` (write), which are the
> only two places a section type or option name exists. Everything else in PART A
> is the v0.1 survey the port was planned from and is deliberately frozen.
>
> An option not listed below is not "undocumented" — it is IGNORED: the parser's
> type switch drops an unknown section type whole, and an unknown option inside a
> known section is never read. That is deliberate (`TestUnknownSectionAndOptionIgnored`
> pins that such a config still parses — the daemon has to come up on whatever it
> finds), and it is also why a dead knob here is SILENT: setting one changes the
> file and nothing else, with no error anywhere to say so.
- `config globals` — the full option set, with the value used when the option is
ABSENT (the `model.DefaultGlobals` seed). Booleans are always written back as
`'1'`/`'0'` by `render.go`, so an explicit value never decays into the seed:
@@ -273,7 +294,7 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
| `block_doh` | `0` | NXDOMAIN the known public DoH hostnames + the Firefox canary and reject `:443` to their IPs, so clients fall back to `:53` (which the engine catches) |
| `group_health` | `1` | OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group |
| `untunnelable` | `block` | policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): `block` \| `icmp` (echo out, rest dropped) \| `direct` (all out, bypassing the tunnel) |
| `l3_tunnel` | `0` | **opt-in**, UCI-only (the panel does not expose it). Opens the synthetic `l3-in` TUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LAN `icmp`/`ipv6-icmp` with `fwmark_base+0x80` and a scoped `ip rule` sends it to table `table_base+8`. Absent option ⇒ OFF; only an explicit `1` opens it. See D25 and `ARCHITECTURE.md` §3a |
| `l3_tunnel` | **`1`** | Opens the synthetic `l3-in` TUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LAN `icmp`/`ipv6-icmp` with `fwmark_base+0x80` and a scoped `ip rule` sends it to table `table_base+8`. ON by default since the flip (`model/l3tunnel_default_test.go`): with it off a LAN ping is decided by `untunnelable` alone, whose every rung either drops the echo or lets it out of the WAN with the client's real address. An ABSENT option therefore comes back ON; only an explicit `0` closes it, and that opt-out survives the write→read round-trip. Set through UCI — no panel control writes it (the Networks page reads it to explain what `untunnelable` still decides). See D25 and `ARCHITECTURE.md` §3a |
| `untunnelable_egress` | unset | **opt-in**, UCI-only. Names a `config egress`; everything the L3 block did not claim (ESP/AH, GRE, IGMP, SCTP, and ICMP when `l3_tunnel=0`) is stamped with that egress's OWN mark and routed out its device by the kernel — no new mark, no new table, engine not in the path. Empty ⇒ `untunnelable` above stays in sole charge (D26) |
| `geo_provider` | unset = auto | `sagernet` \| `loyalsoldier` \| `metacubex` \| `custom`; auto = country codes from SagerNet, everything else from Loyalsoldier |
| `geosite_url` / `geoip_url` | unset | `{category}` templates, honoured only when `geo_provider=custom` |
@@ -293,29 +314,60 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
its own UCI round-trip. Re-adding it would be a regression, not a restored feature —
the hijack-dns rule matches the SNIFFED `dns` protocol, so a per-inbound toggle is a
DNS-leak switch wearing a performance label (`model.go`, `Inbound`).
- `config subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
- `config chain`: name, list hop. `config egress`: name, type, interface, target.
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url|geosite|geoip), url, path, format, update_interval, list category, list entry.
- `config subscription`: name, enabled, url, update_interval, fetch_via(`direct`|`proxy`), fetch_detour, ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days — plus the persisted `Subscription-Userinfo` state the daemon writes back itself: user_upload, user_download, user_total, user_expire, userinfo_at (absent = `0` = "not reported", on both the read and the write side).
`fetch_detour` is consulted only when `fetch_via=proxy`; it names the outbound the fetch dials through — `group:X` \| `node:X` \| `egress:X` \| `chain:X` \| `direct`, empty = direct (`apply.HTTPClient`/`resolveVia`).
- `config node`: name, enabled, uri, mux, mux_concurrency, sockopt_mark, tcp_fast_open, tcp_keepalive_idle, egress — the last binds THIS node's own upstream to a `config egress` (multi-WAN), so its exit connection leaves over the chosen device.
**No `xudp_concurrency`/`xudp_udp443`**: xudp was an xray packet-encoding knob with no sing-box counterpart, and the fields went with the generator rewrite.
`from_sub` / `fingerprint` / `stale` are READ but never written. Subscription nodes live in per-subscription cache files (`model/subcache.go`); the options survive only so an old config's cached nodes are imported once on first read, after which they drain out of UCI.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, egress — the last binds EVERY member's dialer to that egress (a node's own `egress` is more specific and wins).
**No `probe_url`/`probe_interval`**: the per-group overrides were deleted; probing is configured once, in `globals` (D20). Old configs carrying them still parse — the options are ignored and drain out on the next render.
- `config chain`: name, list hop (`group:<n>` \| `node:<n>`, L1..Ln, Ln = exit).
- `config egress`: name, type, interface, dpi.
type is `interface` \| `direct`; `tunnel` is an accepted ALIAS of `interface` and an empty value means `direct` — both folded to the canonical spelling once, at the config boundary (`Model.NormalizeEgressTypes`, called by `ReadUCI`), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by `ValidateEgresses`, every binding to it fail-closed). A type this product REMOVED is a third case with the same fail-closed behaviour and a different sentence — `model.RetiredEgressTypes` is the closed table both `ValidateEgresses` and the generator read it from, so an operator whose config was correct for an older build is told what happened rather than that their value is a typo (D29).
`interface` names the device and is meaningful for the `interface` type only; `dpi` is the native DPI-bypass preset — `off`|`fragment`|`record`|`spoof` (D13).
**No `port`**: no surviving egress kind dials anything, so the option is not parsed and drains out on the next render. It belonged to the removed SOCKS-hop kind (D29).
**No `target`**: v0.1's `proxy`/`block` egress kinds are gone — where traffic goes is a rule's `target`, what device it leaves by is an egress.
- `config ruleset`: name, type(`domain`|`ipcidr`, default `domain`), source(`inline`|`file`|`url`|`geosite`|`geoip`, default `inline`), url, path, format, update_interval, list category, list entry.
`format` names the ENGINE rule-set format and has exactly two real values, `binary` (a compiled `.srs`) and `source` (a sing-box rule-set `.json`); empty — and the v0.1 leftover `plain`, and `auto` — mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip.
`list category` is the canonical spelling (one chip per geosite category or geoip country code, each materialised as its own remote `.srs`); a legacy single `option category` is still accepted on read and re-emitted as a list.
- `config rule`: name, enabled, order, list src, list dst_ruleset, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end, sched_utc_offset.
v0.1 carried `dst_domain`/`dst_ip` on the rule itself; **schema v2 removed both** — a
destination is a `config ruleset` and nothing else. `shaterd migrate` folds each legacy
list into a generated `rule-<name>` (and `rule-<name>-ip`) inline ruleset; see
`DECISIONS.md` D21 for the entry-by-entry conversion table.
- `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
- `config blocklist`: name, enabled, source(inline|file|url|geosite), url, path, list category, list entry, response(nxdomain), update_interval.
- `config allowlist`: the same minus `response` (an allowlist has no verdict to render); it overrides every blocklist.
- `config device`: name, mac, ip, enabled, list block, list allow.
- `config alert`: name, enabled, type(telegram), token, chat_id, url, list event, via, fallback.
- **`config preset` is NOT a section type.** `ReadUCI`'s type switch has no `preset`
branch, so such a section is parsed by nothing and reaches no part of the model.
It survives only because `30_shater-core` still seeds three of them
(`block_ads`, `ru_bypass`, `private`) with the comment "so the LuCI Rules page
renders their toggles" — and v0.2's LuCI app is a thin launcher with no Rules
page. Those `uci set` calls should be dropped from the uci-defaults script; until
they are, three inert sections appear in every fresh `/etc/config/shater`.
- `config profile`: name, enabled, priority, list match_iface, list enable_rule, list disable_rule, endpoint_resolver.
The condition is `match_iface` and nothing else: the active default-route device must be in that set (`cmd/shaterd/profilewatch.go`). The overrides are the two rule lists plus an optional per-profile `endpoint_resolver`, which overrides `globals.endpoint_resolver` while the profile is active (`generate/dns.go`; the point is keying the bootstrap resolver to the WAN in use).
**No `probe_url`/`probe_mode`**, and they were deleted rather than documented: they promised "activate while a probe succeeds/fails", nothing ever probed, AND the selector treated a profile carrying a `probe_url` as having an unsatisfiable condition and skipped it — so adding a probe to a working profile silently switched that profile off.
**No `sched_*`, no `default_target`/`default_egress`** either — a profile enables and disables named rules; it does not carry a routing default of its own.
- `config resolver`: name, type, address, detour, pool.
type is `doh` (synonym `https`) \| `dot` (synonym `tls`) \| `plain` (synonyms `udp` and the empty default) \| `tcp` \| `local` \| `fakeip`. Anything else is NOT built — the resolver simply does not exist, and `generate` says so by name.
Each type consumes a different subset, and the rest is thrown away (`generate.warnIgnoredResolverFields` warns per field rather than dropping it silently): `doh`/`dot`/`plain`/`tcp` use address + detour, ignore pool; `local` uses detour only (it reads the router's `/etc/resolv.conf`); `fakeip` uses pool only (default `198.18.0.0/15`) — it mints answers locally, so it has nothing to dial and ignores both address and detour.
`detour` is per-SERVER, not global: every sing-box DNS server carries its own.
- `config dns_rule`: order, list match_domain, list match_src, resolver. It has no `name` — a DNS rule is identified by its order and selectors.
- `config blocklist`: name, enabled, source(`inline`|`file`|`url`|`geosite`), url, path, list category, list entry, response, update_interval.
`response` has exactly two values: `nxdomain` (default — Rcode 3, empty answer) and `zero` (NOERROR + `A 0.0.0.0`, and `AAAA ::` when `globals.ipv6`). There is no third; neither is sing-box's `action:reject`, which answers REFUSED.
- `config allowlist`: the same minus `response` (an allowlist has no verdict to render); it overrides every blocklist, being emitted at higher priority.
- `config device`: name, mac, ip, enabled, list block, list allow — where `block`/`allow` are DOMAINS, not targets: a device entry is per-device DNS filtering (block regardless of the global `dns_filter` switch, allow overriding every blocklist). Per-device ROUTING is not a device concern — it is an ordinary `config rule` whose `src` names the device.
- `config alert`: name, enabled, type(`telegram`|`webhook`), token + chat_id (telegram), url (webhook), list event, via, fallback.
- **`config preset` is NOT a section type, and there is no preset subsystem in
v0.2.** `ParseUCIExport`'s type switch has no `preset` branch, so such a section
is parsed by nothing, reaches no part of the model, and setting `enabled=1` on
one changes the file and nothing about the router.
It used to appear anyway: `30_shater-core` seeded three (`block_ads`,
`ru_bypass`, `private`) "so the LuCI Rules page renders their toggles", and
v0.2's LuCI app is a launcher with no Rules page. Worse than inert — the panel's
first save wiped them, because `writeUCIWith` replaces the whole package
(`uci delete shater` + `uci import`), so the placeholder deleted itself and read
as a breakage. **The seeding is gone, and the script now DELETES any `preset`
section an older release left behind** (safe by construction: the type is read
by no consumer, so there is no setting to lose).
This is not a feature waiting to be re-enabled. v0.1's packs were xray
`geosite:`/`geoip:` matcher lists materialised into synthetic rules
(`xrayctl/preset.go` on the v0.1 branch); under schema v2 a rule's destination
IS a `config ruleset`, so the same pack is an ordinary ruleset + rule — which
the panel's Routing page builds today, geosite/geoip sources included. Anything
richer needs a section type the parser knows, and that has to land in
`shater/model` first.
### Subscriptions & HAPP fetch
Schemes: `vless:// vmess:// trojan:// ss:// wireguard:// wg://`. Body formats (`DetectSubFormat`): clash-YAML, xray-JSON, singbox-JSON, base64/plain link list. All converge to URIs re-parsed by `ParseShareLink`. HAPP fetch: UA default `Happ/3.13.0`; headers `x-hwid` (auto UUIDv4/sub), `x-device-os`, `x-ver-os`, `x-device-model`, custom. `fetch_via=proxy` dials local socks. Quota/expiry from `Subscription-Userinfo` (`upload;download;total;expire`). Reconcile by `Fingerprint` (sha256 of proto|addr|port|id|net|sec|sni|path) → new/keep/stale (drop after 3 stale refreshes).
@@ -327,7 +379,10 @@ Pure scripts+config, `PKGARCH:=all`. v0.1 DEPENDS: `+xrayctl +xray-core +dnsmasq
- **init.d/shater** (procd, START=99/STOP=10): v0.1 supervised `xray run -c /etc/xray/run.json`; → v0.2 supervises `shaterd`. `respawn 3600 5 0` (infinite). **No `procd_set_param file` watch** (would bounce tunnel on commit). Inert unless `globals.enabled=1`. `ACTIVE_FLAG=/var/run/shater.active` gates hotplug/cron. `stop` clears flag + tears down nft table + reserved routing tables. `reload_service`→start/stop. trigger `procd_add_reload_trigger "shater"`.
- **init.d/shater-cron** (START=96): supervised `loop`; per-item due-check, runs sub/ruleset update + reconcile + schedule due; watchdog: engine dead 5 ticks ⇒ kill_switch=open stops stack (fail-open), closed logs crit.
- **init.d/shater-armor** (START=21/STOP=89, v0.2-only — no v0.1 counterpart): the fail-closed plane BEFORE the daemon exists. `/etc/init.d/shater` is START=99, so from netifd's `ifup` until the daemon's first apply the router forwarded LAN→WAN in the clear. The daemon persists its holding plane to `/etc/shater/boot.nft` on every apply; this loads it after fw4 (19) and netifd (20), `nft -c`-validated. Four state checks refuse to arm (no/empty/invalid file, missing `shaterd`, no `S??shater` rc-link, readable UCI saying `enabled≠1`) — asked ON THE WAY UP, deliberately not recorded on the way down. Hooks `forward` only, so SSH/LuCI/panel stay reachable. `stop()` is a NO-OP. Operator-facing writeup: `INSTALL.md` §4.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), `mkdir /etc/shater`, seed the `shater_l3` fw4 zone + `lan→shater_l3` forwarding (named sections, `list device 'shater-l3*'`) and migrate a legacy exact-name entry to the wildcard, run `shaterd migrate`, apply sysctl, then a DETACHED bring-up (enable+restart `shater`/`shater-cron`, enable `shater-armor`, conditional `firewall reload`) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock. Also still seeds three `config preset` sections, which nothing parses (see the schema note above); those calls should go.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), `mkdir /etc/shater`, DELETE any leftover `config preset` section (a type nothing parses — see the schema note above; the seeding of three of them is gone), seed the `shater_l3` fw4 zone + a `<zone>→shater_l3` forwarding for every zone (named sections, `list device 'shater-l3*'`) and migrate a legacy exact-name entry to the wildcard, run `shaterd migrate` (whose result is **classified and reported**, not discarded — see below), apply sysctl, then a DETACHED bring-up (enable+restart `shater`/`shater-cron`, enable `shater-armor`, conditional `firewall reload`) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock.
- **`shaterd migrate` reporting** (both call sites: `uci-defaults/30_shater-core` and `init.d/shater`'s `start_service`). The verb exits 1 for every failure, so the shell classifies the outcome itself, with a CLOSED positive list — `ok` / `downgrade` / `unreadable` / `failed` (`shater_migrate_class`, duplicated in the two scripts because the package installs no shell library they could share; `TestMigrateClassifiersAgree` fails if they ever diverge). `downgrade` is recognised by the substring `newer than this build`, which both `model.migrateWith`'s refusal and `model.ErrSchemaTooNew` contain — a contract pinned by `TestMigrateDowngradeSignatureIsAContract`. An unrecognised failure lands on `failed`, which says so and quotes the binary verbatim, rather than being reported as one of the causes we can name.
Failures reach the operator on **two channels that are not syslog**, because `globals.log_syslog=0` is a deliberate setting about the syslog stream and not a request to be left uninformed: the script's own **stderr** (the operator's terminal on a hand-typed `restart`; the package manager's output inside `apk add`/`opkg install`), and **`/etc/shater/migrate-failed`** on flash, written on failure and REMOVED on the first success — its absence is the all-clear. syslog gets the same line too when `log_syslog` allows it. A migration that SUCCEEDED is routine and stays on the syslog channel only.
`30_shater-core` still **exits 0** after a failed migration, deliberately: a uci-defaults script that exits non-zero is kept and re-run at every boot, and this one re-runs a detached enable+restart of `shater`/`shater-cron` plus a firewall reload — so one recoverable failure would become permanent boot-time churn, to carry a status nothing reads. The retry that matters already exists: `init.d/shater` runs the migration on every start.
- **hotplug.d/iface/99-shater**: ifup/ifdown → debounced (2s) `reconcile` (netifd wipes ip rules on reload). Guarded by enabled + ACTIVE_FLAG.
- **sysctl.d/99-shater.conf**: `ip_forward=1`, `rp_filter=0` (all+default), `lo.route_localnet=1`, `lo.accept_local=1`, `all.src_valid_mark=1`, `ipv6.all.forwarding=1`.
+15 -19
View File
@@ -49,25 +49,21 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
the gate: fail-closed forward drop (4f618140), engine apply-swap close-first
fallback (9b6b9406), DNS hijack-dns per D14 (86194ce6).
## Phase 2b — DPI-bypass egress = ByeDPI (D13) ✅ DONE
- The one external desync tool is **ByeDPI (ciadpi)** — chosen over zapret because
it *is* a SOCKS egress (fits shater's "routing picks the egress" model with zero
packet-plane conflict); zapret is explicitly rejected (see D13).
- Add egress `dpi` value `byedpi`: a supervised local `ciadpi` SOCKS5 instance +
a `socks` outbound pointed at it. New `openwrt/` procd package + musl-static
cross-build of ciadpi (~100 KB); `model` reserves the egress kind, `generate`
wires the `socks` outbound.
- QUIC gap closed by routing (drop `udp/443` for desync-domains → TCP+TLS
fallback), not by adopting a packet plane.
- The **`byedpi` package** (`openwrt/byedpi/`, SEPARATE & optional) provides the
`ciadpi` process behind a `type='byedpi'` egress: it cross-compiles ciadpi via
the SDK toolchain and ships a procd init that supervises one `ciadpi` SOCKS5
desync instance per enabled `config instance` in `/etc/config/byedpi`
(127.0.0.1:`<port>`). Install it only when you want a byedpi egress; a
`type='byedpi'` egress with no matching `ciadpi` listener simply has nothing to
dial. `shater-core` does NOT depend on it (opt-in).
- **Gate:** a DPI-blocked domain (that plain `fragment` can't crack) loads via the
`byedpi` egress on the VM, direct (no tunnel), kill-switch still honest.
## Phase 2b — DPI-bypass egress ✅ DONE, then REVERSED (D29, 2026-07-27)
- Shipped as **ByeDPI (ciadpi)**: a supervised local SOCKS5 desync process in its
own optional `openwrt/byedpi/` package, reached through a `type='byedpi'`
egress. Chosen over zapret because it *is* an egress and needed no second
packet plane (D13); zapret stays rejected.
- **Removed in full on 2026-07-27** — package, egress kind, readiness endpoint and
panel plate. It was adopted because the engine's own `tls_fragment` /
`tls_record_fragment` presets did not get through; the cause was a defect in
our fragmentation (the split always landed inside the first label of the name),
not a limit of the method. With that fixed the built-in presets carry this, and
the external process is weight without a job. Full argument: **D29**.
- What survives from this phase: the native `dpi` presets `fragment` / `record` /
`spoof` on a `direct` or `interface` egress, which is what the feature is now.
- A config still naming the removed kind is fail-closed and told so by name — see
`model.RetiredEgressTypes`.
## Phase 3 — Admin panel MVP + thin LuCI launcher ✅ DONE (2026-07-15)
- `panel/`: embedded web server on its own port + session store; token-mint ubus
-99
View File
@@ -1,99 +0,0 @@
#
# byedpi — ByeDPI (ciadpi), a tiny portable-C SOCKS5/HTTP desync proxy.
#
# This is the process behind a Shater egress of `type='byedpi'`: shaterd's
# `generate` emits a SOCKS5 outbound `egress-<name>` -> 127.0.0.1:<port>, and a
# `ciadpi` instance supervised by this package listens on that port, applies
# TCP/TLS desync to the connections passing through it, and goes DIRECT to the
# target (no tunnel). Kept as a SEPARATE, optional package: a byedpi egress is
# opt-in — install this only when you want the external desync engine.
#
# Compiled C (musl, per target) => NOT PKGARCH:=all. The OpenWrt SDK toolchain
# cross-compiles ciadpi via its own plain Makefile.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=byedpi
# DELIBERATELY NOT auto-versioned from our git tag (unlike shaterd/shater-core/
# luci-app-shater, which take SHATER_PKG_VERSION/SHATER_PKG_RELEASE from
# ci/version.sh). PKG_VERSION here is THIRD-PARTY UPSTREAM's version — it is what
# PKG_SOURCE_URL/PKG_HASH pin, and what tells an operator which ByeDPI is
# actually installed. Stamping our tag on it would be both a lie and a
# regression: our tags are 0.2.x, and the version comparator (apk-tools 3,
# verified) reads 0.2.7 < 0.17.3 — component-wise numerically, 2 < 17
# — so the "new" package would be a DOWNGRADE and routers would refuse it.
# Bump PKG_RELEASE BY HAND when *our packaging* of it changes (init script, uci
# defaults, build flags); bump PKG_VERSION+PKG_HASH when upstream releases.
PKG_VERSION:=0.17.3
PKG_RELEASE:=1
# Pinned upstream release tag v0.17.3 (commit
# 7efde1b1296eaaa187b70e951894dde17527489c). codeload emits a stable tarball
# per tag; PKG_HASH is the sha256 of that tarball (build fails on mismatch).
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hufrea/byedpi/tar.gz/refs/tags/v$(PKG_VERSION)?
PKG_HASH:=0a9cb8585554c68c3e2be88c33c9bf6f99f8e8c7f54b362285adab99e262566c
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/byedpi
SECTION:=net
CATEGORY:=Network
TITLE:=ByeDPI (ciadpi) local SOCKS5/HTTP desync proxy
URL:=https://github.com/hufrea/byedpi
# Pure C against musl; every target has a C toolchain, so no arch-depends.
# No runtime library deps beyond libc (static-ish tiny binary).
DEPENDS:=
endef
define Package/byedpi/description
ByeDPI is a small local SOCKS5/HTTP proxy that applies TCP/TLS desynchronization
(split, disorder, fake packets, TLS-record splitting) to the connections passing
through it and then connects DIRECTLY to the destination — no upstream tunnel.
Its binary is `ciadpi`. In the Shater stack it is the process behind an egress of
`type='byedpi'`: shaterd routes selected traffic to a local SOCKS5 outbound
pointed at ciadpi's 127.0.0.1:<port>. Multi-instance, driven by /etc/config/byedpi.
endef
# ciadpi's upstream Makefile appends its own required flags with `CFLAGS +=`.
# A CFLAGS set on the make command line CLOBBERS that `+=` (GNU make: a
# command-line assignment overrides the makefile's append), so we must re-supply
# ciadpi's own needed flags (-I. -std=c99 and its warning set) alongside
# $(TARGET_CFLAGS). CPPFLAGS (-D_DEFAULT_SOURCE) is left untouched by not
# overriding it. The default target `all` builds the `ciadpi` binary; its link
# rule is `$(CC) -o ciadpi $(OBJ) $(LDFLAGS)`, so $(TARGET_LDFLAGS) reaches the
# link. Kernel headers (linux/netfilter_ipv4.h) come from the SDK sysroot.
define Build/Compile
+$(MAKE) -C $(PKG_BUILD_DIR) \
CC="$(TARGET_CC)" \
CFLAGS="$(TARGET_CFLAGS) -I. -std=c99 -Wall -Wno-unused -Wextra -Wno-unused-parameter" \
LDFLAGS="$(TARGET_LDFLAGS)" \
all
endef
define Package/byedpi/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/ciadpi $(1)/usr/bin/ciadpi
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/byedpi $(1)/etc/init.d/byedpi
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/byedpi $(1)/etc/config/byedpi
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/etc/uci-defaults/40_byedpi $(1)/etc/uci-defaults/40_byedpi
endef
# /etc/config/byedpi is user-editable desired state -> preserve on upgrade.
define Package/byedpi/conffiles
/etc/config/byedpi
endef
$(eval $(call BuildPackage,byedpi))
-33
View File
@@ -1,33 +0,0 @@
#
# ByeDPI (ciadpi) desync SOCKS proxies (/etc/config/byedpi).
#
# Each `config instance` is one supervised `ciadpi` process bound to
# 127.0.0.1:<port>. Point a Shater egress at it:
#
# config egress 'bd'
# option name 'bd'
# option type 'byedpi'
# option port '1080' # must match an enabled instance's `port`
#
# then a rule with `option target 'egress:bd'` routes selected traffic through
# ciadpi, which desyncs it and connects DIRECTLY to the target (no tunnel).
#
# This shipped default is INERT (enabled='0'): installing the package opens no
# listener. Set enabled='1' and apply to bring the proxy up.
#
# This file is a conffile — your edits survive package upgrades.
#
config instance 'default'
option enabled '0'
option port '1080'
# Desync preset (documented ByeDPI example — general RKN/YouTube-busting):
# --disorder 1 : split the first segment at offset 1 and send the two
# parts in REVERSE order (TCP desync; defeats naive
# stream reassembly in the DPI).
# --auto=torst : auto mode — if the connection is reset (TCP RST), retry
# with the desync params instead of failing.
# --tlsrec 1+s : re-frame the TLS record boundary at the SNI offset +1,
# so the ClientHello SNI is split across TLS records and
# SNI-based DPI can't match the hostname.
option args '--disorder 1 --auto=torst --tlsrec 1+s'
-79
View File
@@ -1,79 +0,0 @@
#!/bin/sh /etc/rc.common
# /etc/init.d/byedpi — procd supervisor for ByeDPI (ciadpi) desync SOCKS proxies.
#
# One supervised `ciadpi` process per ENABLED `config instance` in
# /etc/config/byedpi. Each instance is a local SOCKS5 desync proxy bound to
# 127.0.0.1:<port>; a Shater egress of type='byedpi' with the matching `port`
# routes traffic to it (shaterd emits a SOCKS5 outbound to that port). ciadpi
# desyncs the connection and goes DIRECT to the target — no tunnel.
#
# Design:
# * INERT by default: the shipped instance has enabled='0', and config_foreach
# starts nothing unless an instance is explicitly enabled. Installing this
# package can never, by itself, open a listener or affect connectivity.
# * FOREGROUND: ciadpi stays in the foreground unless -D/--daemon is given (we
# never pass it), so procd supervises the real process. respawn on crash.
# * Instances are named after their UCI section, so `reload` diffs per-section
# and restarts only the instances whose config actually changed.
# * busybox ash only — no bashisms.
USE_PROCD=1
START=90 # before shater (START=99): the SOCKS egress should be up
STOP=11 # after shater (STOP=10) — higher STOP runs LATER on shutdown,
# so the desync proxy outlives the data plane it serves.
PROG=/usr/bin/ciadpi
CONF=byedpi
# Validate one `instance` section. Datatypes per openwrt-uci:
# enabled : bool (default 0 — inert)
# port : port (default 1080 — matches shater's byedpi egress default)
# args : free-form desync flag string (passed verbatim to ciadpi)
validate_instance_section() {
uci_load_validate "$CONF" instance "$1" "$2" \
'enabled:bool:0' \
'port:port:1080' \
'args:string:'
}
start_instance() {
# $1 = section name, $2 = validation return code
local cfg="$1"
[ "$2" = 0 ] || { echo "byedpi: validation failed for '$cfg'"; return 1; }
[ "$enabled" -eq 1 ] || return 0
# Never claim to run without the binary (half-removed/failed upgrade must
# degrade to "off", not to a phantom respawn loop).
[ -x "$PROG" ] || { echo "byedpi: $PROG missing, skipping '$cfg'"; return 1; }
procd_open_instance "$cfg"
# Bind loopback only: this proxy is reachable solely by the local engine.
procd_set_param command "$PROG" -i 127.0.0.1 -p "$port"
# Desync flag list. Unquoted on purpose: word-split $args into separate argv
# tokens (e.g. "--disorder 1 --auto=torst --tlsrec 1+s" -> 5 arguments).
# shellcheck disable=SC2086
[ -n "$args" ] && procd_append_param command $args
procd_set_param respawn
# Restart this instance when its config changes (checksum-watched).
procd_set_param file /etc/config/$CONF
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
start_service() {
config_load "$CONF"
config_foreach validate_instance_section instance start_instance
}
reload_service() {
start_service
}
service_triggers() {
# Reload (not full restart) when /etc/config/byedpi changes via a
# config.change event (LuCI Save&Apply / `reload_config`).
procd_add_reload_trigger "$CONF"
procd_add_validation validate_instance_section
}
@@ -1,12 +0,0 @@
#!/bin/sh
# /etc/uci-defaults/40_byedpi
#
# Idempotent first-boot setup for the byedpi package. Runs once on first boot
# (and via the default postinst on a live opkg/apk install); must exit 0 so it
# is cleared and not retried. Enabling the init is safe on a fresh box: the init
# and the shipped config are INERT (the 'default' instance has enabled='0'), so
# nothing listens until an instance is explicitly enabled.
[ -x /etc/init.d/byedpi ] && /etc/init.d/byedpi enable
exit 0
@@ -6,11 +6,24 @@
'require ui';
// The admin panel (shaterd's own web server) listens on its own port. shaterd
// reports the configured port in status.panel_port (globals.panel_port, default
// reports the CONFIGURED port in status.panel_port (globals.panel_port, default
// 8088); the launcher builds the redirect from that, falling back to the default
// when status is unavailable. `panelPort` tracks the latest reported value and is
// refreshed from each status poll. If the panel is ever fronted by TLS, the http
// scheme below must follow.
//
// CONFIGURED IS NOT BOUND, and nothing on this page can turn one into the other:
//
// - shaterd starts the panel server in a goroutine and treats a bind error as
// log-and-continue ("panel server unavailable (daemon continues)",
// cmd/shaterd/main.go). After a restart that races the old listener, the
// daemon is healthy, `panel_port` still names the port, and nothing is
// listening on it.
// - SHATER_PANEL_ADDR can disable the panel outright (panelAddr()), while
// apply.Status still reports effectivePanelPort() — 8088 when unset.
//
// There is no "panel is listening" field to read, so this page must not imply
// one. The hint and the tooltip say the port is configured, not checked.
var DEFAULT_PANEL_PORT = 8088;
var panelPort = DEFAULT_PANEL_PORT;
@@ -72,19 +85,41 @@ function row(state, label, value) {
// statusReadout() maps a shaterd status object to a list of
// { state, label, value } descriptors. It is deliberately free of E()/DOM so it
// can be run against recorded fixtures offline; tests/status-readout.test.js
// does exactly that for the three cases this page has to tell apart.
// does exactly that for the four cases this page has to tell apart — engine up,
// engine down with the daemon answering, no daemon at all, and a daemon that
// cannot read the configuration — plus the degenerate and old-daemon answers.
// The gate runs it as step [7/7].
// ---------------------------------------------------------------------------
// PLANES is the closed set of values a LIVE Applier.Status() can put in `plane`
// (shater/apply/apply.go: "full" | "hold" | "none"). It is also how this page
// tells a live daemon from a dead one — see daemonState().
// (shater/apply/apply.go: "full" | "hold" | "none"). It is the FALLBACK proof of
// daemon liveness for a shaterd that predates daemon_answered — see daemonState().
var PLANES = { full: true, hold: true, none: true };
// daemonState — is the shaterd PROCESS answering?
//
// 'up' — a live Applier produced this status.
// 'down' — proven not: `shaterd status` printed its OFFLINE STUB.
// 'unknown' — no usable answer, or an answer from a daemon older than `plane`.
// 'unknown' — no usable answer, or an answer too old to say either way.
//
// THE FIELD, THEN THE FALLBACK.
//
// `daemon_answered` (cmd/shaterd/main.go, statusDaemonAnsweredKey) is the
// CONTRACT: true means a running daemon answered over the control socket and
// every other field is that daemon's own Applier.Status(); false means the object
// is the offline stub — the apply.Status zero value plus a UCI and kernel read —
// and is not a status report at all. It is a positive, closed, two-valued
// statement about where the object came from, which is exactly what this page
// needs and what it never had.
//
// The `plane` test below is what this page used BEFORE that field existed, and it
// is kept only for the non-atomic-update window: `apk upgrade` can leave a new
// luci-app-shater beside an old shaterd, and that shaterd emits no
// daemon_answered. It works because the stub leaves `plane` at "" while a live
// Status() always assigns one of the three words — a side effect, not a promise,
// which is precisely why it is now second and not first. If the two ever
// disagree, the explicit field wins: a stub that somehow carried a plane word
// must still read as "no daemon answered".
//
// `running` MUST NOT be used for this. It changed meaning on 2026-07-26
// (a8970b8ac): it used to be a hardcoded true, and is now the ENGINE's liveness
@@ -95,24 +130,26 @@ var PLANES = { full: true, hold: true, none: true };
// keeps management reachable on purpose (shater/netplane/nft.go); LuCI was the
// only thing taking that guarantee away.
//
// Nor is "the ubus call returned" sufficient, which is the trap here: the rpcd
// plugin shells out to `shaterd status`, and that command EXITS 0 WITH A
// FABRICATED STATUS when the daemon is unreachable (cmd/shaterd/main.go,
// cmdStatus offline stub). The stub is the apply.Status zero value plus a UCI
// read, so it carries enabled/table/kill_switch/panel_port but leaves `plane` at
// "" — a value no live daemon ever emits, because Status() always assigns one of
// the three words. So a known plane word is the one positive proof on the wire
// that a daemon answered, and an explicit empty one is positive proof that none
// did.
// Nor is "the ubus call returned" sufficient: the rpcd plugin shells out to
// `shaterd status`, which prints a parseable object on BOTH branches (the exit
// code is what differs, and command substitution in the plugin drops it).
//
// Everything else is unknown and is painted as unknown: {} from a failed ubus
// call, {"error":...} from the plugin (which is ALSO what a live-but-wedged
// daemon produces — cmdStatus prints nothing and exits 1 on a control-socket
// timeout, so "wedged" must not be reported as "dead"), and a status from a
// daemon predating the `plane` field.
// daemon predating both fields.
function daemonState(st) {
if (!st || typeof st !== 'object')
return 'unknown';
// Closed positive list on the contract field. Anything that is not exactly
// `true` or exactly `false` is not a verdict — it falls through rather than
// being coerced, because a truthy string is not an answer.
if (st.daemon_answered === true)
return 'up';
if (st.daemon_answered === false)
return 'down';
// Compatibility fallback: an old shaterd under a new LuCI.
if (typeof st.plane === 'string' && PLANES[st.plane] === true)
return 'up';
if (st.plane === '')
@@ -120,6 +157,69 @@ function daemonState(st) {
return 'unknown';
}
// configState — could the daemon READ the router's configuration when it took
// this status?
//
// 'ok' — config_readable=true: enabled, kill_switch and panel_port below
// are readings.
// 'failed' — config_readable=false: those three are ZERO VALUES AND MEAN
// NOTHING (apply.go Status.ConfigReadable). Rendering `enabled`
// false as "switched off" here is the documented defect: the read
// fails when /overlay is full or a `uci commit` was interrupted,
// which is exactly when the fail-closed plane has the LAN cut off —
// and telling the owner they switched it off themselves sends them
// to a settings page backed by the same unreadable file.
// 'unknown' — nobody said. Two ways to get here, and neither may be read as
// 'failed':
// * a daemon predating the field (non-atomic package update);
// * NO LIVE DAEMON AT ALL. The offline stub in cmdStatus reads
// UCI directly and never sets ConfigReadable, so it emits
// config_readable=false while its enabled/kill_switch/
// panel_port ARE genuine reads. Taking that at face value would
// put "the configuration could not be read" on screen for a
// perfectly readable configuration, and would throw away the
// only facts a dead-daemon status does carry. So the field is
// only consulted when a daemon actually answered.
function configState(st) {
if (daemonState(st) !== 'up')
return 'unknown';
if (st.config_readable === true)
return 'ok';
if (st.config_readable === false)
return 'failed';
return 'unknown';
}
// killSwitchSetting — the CONFIGURED kill-switch policy, or null when it cannot
// be known. It is one of the three config-sourced fields, so an unreadable
// configuration leaves it "" — and "" must never normalise to "closed", which is
// how a router nobody could read printed a green "fail-closed" row (the panel's
// killSwitchReadout carries the same note).
function killSwitchSetting(st) {
if (configState(st) === 'failed')
return null;
if (st.kill_switch === 'closed')
return 'closed';
if (st.kill_switch === 'open')
return 'open';
return null;
}
// panelTarget — where the launcher will point, and whether the port came from the
// router or from this file's built-in default. It never claims the panel answers
// there; see the DEFAULT_PANEL_PORT comment at the top.
function panelTarget(st) {
st = (st && typeof st === 'object') ? st : {};
// The config gate is belt-and-braces: an unreadable configuration already
// leaves panel_port at 0, which the > 0 test rejects. It is spelled out so the
// rule ("only meaningful with config_readable=true") is visible at the point
// of use rather than inferred from a zero value.
if (configState(st) !== 'failed' &&
typeof st.panel_port === 'number' && st.panel_port > 0)
return { port: st.panel_port, known: true };
return { port: DEFAULT_PANEL_PORT, known: false };
}
// engineState — is a sing-box instance actually started?
//
// The engine lives INSIDE the shaterd process, so a dead daemon is a dead engine
@@ -149,7 +249,21 @@ function statusReadout(st) {
var dstate = daemonState(st);
var estate = engineState(st);
var traffic = (st.traffic && typeof st.traffic === 'object') ? st.traffic : {};
var cstate = configState(st);
var kswitch = killSwitchSetting(st);
// WHICH FIELDS SURVIVE A DEAD DAEMON. The offline stub's contract
// (cmd/shaterd/main.go statusDaemonAnsweredKey) names them: enabled, active,
// table, kill_switch and panel_port are read on the spot from UCI and the
// kernel and are real; running, engine_running, plane, traffic, hash, warnings
// and uptime "are placeholders, not measurements". Today the stub happens to
// leave all of them at their zero values, so reading them would look harmless
// — but "it happens to be zero" is the same side-effect reasoning that
// daemon_answered was added to replace. They are dropped on the stated
// contract instead, so a stub that ever grew a value cannot paint this page
// green.
var live = (dstate !== 'down');
var planeWord = live ? st.plane : '';
var traffic = (live && st.traffic && typeof st.traffic === 'object') ? st.traffic : {};
var rows = [];
// --- The shaterd process itself. ------------------------------------------
@@ -175,8 +289,31 @@ function statusReadout(st) {
else
rows.push(mk('unknown', _('Engine (sing-box)'), _('not reported')));
// --- Could the configuration be read at all? ------------------------------
// Placed ABOVE the three rows it qualifies, because it decides what they mean.
if (cstate === 'ok')
rows.push(mk('good', _('Configuration'), _('readable')));
else if (cstate === 'failed')
rows.push(mk('bad', _('Configuration'),
_('COULD NOT BE READ — the service/kill-switch/panel-port rows below say ' +
'"not known" because the daemon has no reading, NOT because anything is ' +
'switched off. If traffic is blocked that is the fail-closed plane; do not ' +
'turn anything off to fix it.') +
(typeof st.config_error === 'string' && st.config_error !== ''
? ' [' + st.config_error + ']' : '')));
else
rows.push(mk('unknown', _('Configuration'),
dstate === 'up'
? _('not reported by this daemon')
: _('not reported — no daemon answered')));
// --- Desired state: globals.enabled in UCI. -------------------------------
if (st.enabled === true)
// Read through cstate first: `enabled` is sourced from the configuration, so
// with config_readable=false its `false` is a zero value, not a choice.
if (cstate === 'failed')
rows.push(mk('unknown', _('Service enabled'),
_('not known — the configuration could not be read')));
else if (st.enabled === true)
rows.push(mk('good', _('Service enabled'), _('enabled')));
else if (st.enabled === false)
rows.push(mk('warn', _('Service enabled'), _('inert (disabled)')));
@@ -193,8 +330,19 @@ function statusReadout(st) {
// back. This page used to render it as "Interception: active", in green, over
// a dead engine and a blocked LAN. The lamp now reports only whether the
// latch AGREES with globals.enabled.
//
// The latch itself is a filesystem fact and stays readable when the
// configuration does not; what an unreadable configuration takes away is the
// COMPARISON, since the lamp only reports whether the latch agrees with
// globals.enabled. So the words stay and the lamp goes out.
if (typeof st.active !== 'boolean')
rows.push(mk('unknown', _('Service latch'), _('not reported')));
else if (cstate === 'failed')
rows.push(mk('unknown', _('Service latch'), st.active
? _('raised — the service is meant to be running; whether that matches the ' +
'setting is not known, the configuration could not be read')
: _('cleared — the service is torn down; whether that matches the setting is ' +
'not known, the configuration could not be read')));
else if (st.active)
rows.push(mk(st.enabled === true ? 'good' : 'warn', _('Service latch'),
_('raised — the service is meant to be running')));
@@ -206,7 +354,7 @@ function statusReadout(st) {
// The row this page was missing. `plane` distinguishes the working ruleset
// from the FAIL-CLOSED HOLDING PLANE, which `table` cannot: `table` is a bare
// existence check, so a held LAN and a working one look identical through it.
switch (st.plane) {
switch (planeWord) {
case 'full':
// Deliberately mechanical wording. "full" means the table, the policy
// routing and the engine are all in place — it does NOT mean traffic is
@@ -219,8 +367,11 @@ function statusReadout(st) {
_('hold — the engine is down and LAN→WAN forwarding is BLOCKED')));
break;
case 'none':
rows.push(mk(st.kill_switch === 'closed' ? 'bad' : 'warn', _('Traffic plane'),
st.kill_switch === 'closed'
// The alarming wording is earned by a KNOWN fail-closed setting. With an
// unreadable configuration kswitch is null, and the row states the fact it
// has (nothing is installed) without the claim it does not.
rows.push(mk(kswitch === 'closed' ? 'bad' : 'warn', _('Traffic plane'),
kswitch === 'closed'
? _('none — nothing is installed; traffic reaches the WAN unprotected')
: _('none — no data plane is installed')));
break;
@@ -260,37 +411,68 @@ function statusReadout(st) {
// Kept because the offline stub still reads it straight from the kernel, so
// it is the one plane fact available when no daemon answers. It says nothing
// about WHICH ruleset is loaded — that is the Traffic plane row.
//
// "Not loaded" is only the calm amber when the service is KNOWN to be switched
// off. With an unreadable configuration that is not known, and an absent
// firewall table with no explanation is the alarming side, not the calm one.
if (st.table === true)
rows.push(mk('good', _('nft table'), _('inet shater is loaded')));
else if (st.table === false)
rows.push(mk(st.enabled === false ? 'warn' : 'bad', _('nft table'), _('not loaded')));
rows.push(mk(cstate !== 'failed' && st.enabled === false ? 'warn' : 'bad',
_('nft table'), _('not loaded')));
else
rows.push(mk('unknown', _('nft table'), _('not reported')));
// --- Kill-switch: the configured policy, and whether it is in force. ------
// "closed" with no plane installed is a setting that is not in effect, which
// is worse news than "open" and must not share its amber lamp.
if (st.kill_switch === 'closed' && st.plane === 'none')
if (cstate === 'failed')
rows.push(mk('unknown', _('Kill-switch'),
_('not known — the configuration could not be read')));
else if (kswitch === 'closed' && planeWord === 'none')
rows.push(mk('bad', _('Kill-switch'),
_('closed, but NOT in effect — no data plane is installed')));
else if (st.kill_switch === 'closed' && dstate === 'up')
else if (kswitch === 'closed' && dstate === 'up')
rows.push(mk('good', _('Kill-switch'), _('closed (fail-closed)')));
else if (st.kill_switch === 'closed')
else if (kswitch === 'closed')
rows.push(mk('warn', _('Kill-switch'),
_('configured closed; whether it is installed is not known')));
else if (st.kill_switch === 'open')
else if (kswitch === 'open')
rows.push(mk('warn', _('Kill-switch'), _('open (leaky)')));
else
rows.push(mk('unknown', _('Kill-switch'), _('not reported')));
// --- Running engine config hash ("" when the engine is not started). ------
if (typeof st.hash === 'string' && st.hash !== '')
if (live && typeof st.hash === 'string' && st.hash !== '')
rows.push(mk('good', _('Config hash'), st.hash));
else if (estate === 'down')
rows.push(mk('unknown', _('Config hash'), _('none — the engine is not started')));
else
rows.push(mk('unknown', _('Config hash'), _('not reported')));
// --- Where the "Open panel" button will point. ----------------------------
// The lamp is UNLIT even on a perfectly healthy router, and that is the point:
// the lamps on this page report a condition, and the condition an operator
// cares about here — "will the panel answer on that port" — is one nothing in
// this status measures. shaterd's panel server is started in a goroutine whose
// bind error is only logged, so a taken port leaves a healthy daemon reporting
// a port nothing is listening on; SHATER_PANEL_ADDR can switch the server off
// entirely and the port is still reported. A green lamp here would be a
// promise made out of a configuration value.
var target = panelTarget(st);
if (target.known)
rows.push(mk('unknown', _('Panel port'),
_('%PORT% — configured; nothing here reports whether the panel is listening on it')
.replace('%PORT%', String(target.port))));
else if (cstate === 'failed')
rows.push(mk('unknown', _('Panel port'),
_('not known — the configuration could not be read; the button will try the built-in default %PORT%')
.replace('%PORT%', String(target.port))));
else
rows.push(mk('unknown', _('Panel port'),
_('not reported — the button will try the built-in default %PORT%')
.replace('%PORT%', String(target.port))));
return rows;
}
@@ -301,14 +483,33 @@ function statusRows(st) {
});
}
// panelHint describes the button's target and what is known about it. It never
// panelTitle describes the button's target and what is known about it. It never
// promises the panel is up — only where the launcher will point.
function panelTitle(dstate) {
function panelTitle(st) {
var dstate = daemonState(st);
var target = panelTarget(st);
// Second sentence, on every branch: the port is a configuration value that
// nothing on the wire confirms. A page that says "opens the panel" and lands
// on a connection refused has made a claim it had no field to support.
var port = target.known
? _('The port is the configured one; a status cannot say whether the panel is listening on it, so a connection error here means the port, not your token.')
: _('No panel port was reported, so this falls back to the built-in default and may well be the wrong port.');
if (dstate === 'up')
return _('Mint a session token and open the admin panel');
return _('Mint a session token and open the admin panel.') + ' ' + port;
if (dstate === 'down')
return _('shaterd is not answering, so this will probably fail — but the panel is served by the daemon, not by the engine, so it is worth trying: any failure is reported here.');
return _('The daemon state is not known. Try it — a failure is reported here rather than hidden.');
return _('shaterd is not answering, so this will probably fail — but the panel is served by the daemon, not by the engine, so it is worth trying: any failure is reported here.') + ' ' + port;
return _('The daemon state is not known. Try it — a failure is reported here rather than hidden.') + ' ' + port;
}
// panelHint is the grey line beside the button. Same rule as the tooltip: it
// states what the button DOES, and marks the port as configured rather than
// checked.
function panelHint(hostname, target) {
return (target.known
? _('opens http://%HOST%:%PORT%/ with a single-use session token — %PORT% is the configured port, not a checked one')
: _('opens http://%HOST%:%PORT%/ with a single-use session token — no port was reported, so %PORT% is this page\'s built-in default'))
.replace('%HOST%', hostname)
.replace(/%PORT%/g, String(target.port));
}
// handleOpenPanel mints a single-use token and hands it to the panel via the
@@ -361,6 +562,10 @@ return view.extend({
statusReadout: statusReadout,
daemonState: daemonState,
engineState: engineState,
configState: configState,
panelTarget: panelTarget,
panelTitle: panelTitle,
panelHint: panelHint,
load: function() {
return L.resolveDefault(callStatus(), {});
@@ -374,14 +579,9 @@ return view.extend({
'click': ui.createHandlerFn(this, handleOpenPanel)
}, [ _('Open panel') ]);
function hintText() {
return _('opens http://%s:%d/ with a single-use session token')
.format(window.location.hostname, panelPort);
}
var hint = E('span', {
'style': 'margin-left:1em;color:#888;font-size:90%'
}, hintText());
}, panelHint(window.location.hostname, panelTarget(st)));
// Track the panel port reported in status so the launcher redirect and the
// hint follow globals.panel_port.
@@ -396,9 +596,13 @@ return view.extend({
// direction for an unknown state.
function reflect(state) {
state = (state && typeof state === 'object') ? state : {};
panelPort = state.panel_port || DEFAULT_PANEL_PORT;
hint.textContent = hintText();
openBtn.title = panelTitle(daemonState(state));
// One source for the port: the same panelTarget() the "Panel port" row
// renders, so the row, the hint, the tooltip and the redirect cannot
// disagree about where the button goes.
var target = panelTarget(state);
panelPort = target.port;
hint.textContent = panelHint(window.location.hostname, target);
openBtn.title = panelTitle(state);
}
reflect(st);
@@ -8,22 +8,36 @@
# mint_token -> passthrough of `shaterd mint-token` ({"token":"..."} | {"error":"..."})
#
# The status object is whatever apply.Status marshals (shater/apply/apply.go is the
# only definition; this script never parses or reshapes it). As of 2026-07-26 that is:
# only definition; this script never parses or reshapes it), plus the one field
# `shaterd status` splices in itself. As of 2026-07-27 that is:
#
# running, engine_running, enabled, active, table, plane, traffic, hash,
# kill_switch, panel_port, can_rollback, warnings, started_unix, uptime_seconds
# daemon_answered, running, engine_running, enabled, active, table, plane,
# traffic, hash, kill_switch, panel_port, config_readable, config_error,
# can_rollback, warnings, started_unix, uptime_seconds
#
# Two of those are load-bearing for the caller and easy to misread:
# Three of those are load-bearing for the caller and easy to misread:
#
# running / engine_running — the ENGINE's liveness, not this daemon's. `running`
# daemon_answered — WHERE THE OBJECT CAME FROM, and the only field that says so by
# contract (cmd/shaterd/main.go, statusDaemonAnsweredKey). true: a running
# daemon answered over the control socket. false: this is the OFFLINE STUB —
# the apply.Status zero value plus a UCI and kernel read — and the fields only
# a live daemon can know (running/engine_running/plane/traffic/hash/warnings/
# uptime) are placeholders. dashboard.js keys "daemon: down" off this.
# running / engine_running — the ENGINE's liveness, not the daemon's. `running`
# was a hardcoded true until a8970b8ac (2026-07-26) and is now `engineUp`, so
# a healthy daemon with a dead engine reports running=false. The daemon's own
# liveness is not a field at all.
# plane — "full" | "hold" | "none" from a LIVE daemon. `shaterd status` also has an
# OFFLINE STUB path: when the daemon is unreachable it still exits 0 and prints
# a status built from the apply.Status zero value plus a UCI read, which leaves
# plane at "". So this method returning an object is NOT evidence that a daemon
# answered; a known plane word is. dashboard.js relies on exactly that.
# liveness is not a field of apply.Status at all.
# config_readable — whether the daemon could READ the configuration. When false,
# enabled/kill_switch/panel_port are zero values and mean NOTHING. Note the
# stub above never sets it, so its false is not a failed read either — a
# consumer must check daemon_answered first. dashboard.js does.
#
# EXIT CODE: `shaterd status` now exits 1 when no daemon answered, and this script
# deliberately ignores that — command substitution below keeps only stdout. The stub
# is still worth relaying (it carries the real UCI and nft-table readings, and it
# says what it is), and dropping it would blank the LuCI page instead of degrading
# it. The wedged case is the one where the exit code matters, and it reports itself
# by printing NOTHING: the case below then emits the error object.
#
# Why shell out to shaterd instead of talking to /var/run/shaterd.ctl directly:
# a reliable AF_UNIX client is NOT guaranteed on stock OpenWrt (busybox `nc` is
@@ -3,33 +3,44 @@
* Offline harness for the dashboard's state derivation.
*
* Run: node openwrt/luci-app-shater/tests/status-readout.test.js
* (the gate runs it as step [7/7]; see scripts/run-tests.sh NONGO_TEST_RUNNERS)
*
* Why this exists: the LuCI page is the ONE screen an operator reaches when the
* engine is down and the fail-closed holding plane is blocking the LAN. What it
* says there is a claim about the router's behaviour, and until now nothing
* checked those claims. There is no browser and no router in this loop — the view
* exposes statusReadout/daemonState/engineState as plain functions, and this file
* feeds them recorded status objects.
* exposes statusReadout/daemonState/engineState/configState/panelTarget/
* panelTitle/panelHint as plain functions, and this file feeds them recorded
* status objects.
*
* The fixtures are not invented. Each is what the wire actually carries:
*
* ENGINE_UP — apply.Status() from a live daemon with a started engine.
* ENGINE_DOWN — apply.Status() from a live daemon whose engine died; the
* holding plane is installed and the LAN is blocked.
* ENGINE_UP — apply.Status() from a live daemon with a started engine,
* marked daemon_answered:true by the CLI.
* ENGINE_DOWN — the same daemon with a dead engine; the holding plane is
* installed and the LAN is blocked.
* DAEMON_DOWN — the OFFLINE STUB `shaterd status` prints when the daemon is
* unreachable (cmd/shaterd/main.go cmdStatus): apply.Status zero
* value + a UCI read, marshalled by Status.JSON(), so every field
* is present and `plane` is "".
* value + a UCI and kernel read, marked daemon_answered:false.
* CONFIG_BAD — a LIVE daemon that could not read the configuration
* (config_readable:false): enabled/kill_switch/panel_port are
* zero values that mean NOTHING.
* NO_ANSWER — {} , what L.resolveDefault hands render() when the ubus call
* fails outright.
* PLUGIN_ERROR — {"error":...} from the rpcd plugin, which is ALSO what a
* live-but-wedged daemon produces.
* LEGACY — a daemon predating plane/engine_running (packages do not update
* atomically).
* OLD_* — a shaterd predating daemon_answered/config_readable, under a
* new luci-app-shater. Packages do not update atomically, so
* this combination WILL exist in the field.
* LEGACY — a daemon predating plane/engine_running as well.
*
* Mutation check: revert dashboard.js to reading `st.running` for daemon
* liveness and this file fails on ENGINE_DOWN with the exact text the operator
* would have been shown.
* Mutation check (each has been run; the named assertions in brackets fail):
* - delete the daemon_answered branches from daemonState() [H/*, C/*]
* - delete the `plane` fallback from daemonState() [OLD/*]
* - read config_readable without the daemon gate [C/config-*]
* - paint config_readable:false rows from their zero values [K/*]
* Reproduce by copying dashboard.js, breaking the copy, and pointing
* DASHBOARD_JS at it.
*/
'use strict';
@@ -71,34 +82,81 @@ var page = loadView();
// --- Fixtures ---------------------------------------------------------------
var ENGINE_UP = {
daemon_answered: true,
running: true, engine_running: true, enabled: true, active: true, table: true,
config_readable: true, config_error: '',
plane: 'full', traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 },
hash: 'a1b2c3d4', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
var ENGINE_DOWN = {
daemon_answered: true,
running: false, engine_running: false, enabled: true, active: true, table: true,
config_readable: true, config_error: '',
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
// Exactly what Status.JSON() emits for the cmdStatus offline stub.
// Exactly what Status.JSON() + markStatusOrigin(false) emit for the cmdStatus
// offline stub. NOTE config_readable:false: the stub reads UCI directly (so
// enabled/kill_switch/panel_port below ARE real readings) but never sets the
// field, so it ships the zero value. Anything keying on config_readable without
// first checking that a daemon answered will call this configuration unreadable.
var DAEMON_DOWN = {
daemon_answered: false,
running: false, engine_running: false, enabled: true, active: true, table: true,
config_readable: false, config_error: '',
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
warnings: null, started_unix: 0, uptime_seconds: 0
};
// A LIVE daemon whose readConfig() failed: /overlay full, or a `uci commit`
// interrupted. apply.go then leaves enabled=false, kill_switch="" and
// panel_port=0 as PLACEHOLDERS and raises config_readable=false + config_error.
// The engine cannot be generated, so the fail-closed holding plane is what is
// installed — which is the whole trap: the LAN is cut off and the three
// placeholders spell out a calm "the owner switched it off".
var CONFIG_BAD = {
daemon_answered: true,
running: false, engine_running: false, enabled: false, active: true, table: true,
config_readable: false,
config_error: 'uci: read /etc/config/shater: no space left on device',
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: '', panel_port: 0, can_rollback: false,
warnings: [], started_unix: 1753500000, uptime_seconds: 90
};
var NO_ANSWER = {};
var PLUGIN_ERROR = { error: 'shaterd unavailable' };
// Non-atomic package update: new luci-app-shater, old shaterd. No
// daemon_answered, no config_readable — the `plane` fallback is all there is.
var OLD_DAEMON_UP = {
running: false, engine_running: false, enabled: true, active: true, table: true,
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
};
var OLD_DAEMON_DOWN = {
running: false, engine_running: false, enabled: true, active: true, table: true,
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
warnings: null, started_unix: 0, uptime_seconds: 0
};
var NO_ANSWER = {};
var PLUGIN_ERROR = { error: 'shaterd unavailable' };
// Older still: no plane, no engine_running either.
var LEGACY = {
running: true, enabled: true, active: true, table: true, hash: 'deadbeef',
kill_switch: 'closed', panel_port: 8088
};
// A configured panel port that is NOT the built-in default, so "reported" and
// "fell back" are distinguishable in the readout and in the button hint.
var CUSTOM_PORT = Object.assign({}, ENGINE_UP, { panel_port: 9090 });
// --- Assertions -------------------------------------------------------------
var failures = [];
@@ -129,7 +187,8 @@ function readout(st) {
function show(title, st) {
process.stdout.write('\n=== ' + title + ' ===\n');
process.stdout.write(' daemon=' + page.daemonState(st) +
' engine=' + page.engineState(st) + '\n');
' engine=' + page.engineState(st) +
' config=' + page.configState(st) + '\n');
page.statusReadout(st).forEach(function(r) {
process.stdout.write(' [' + r.state.padEnd(7) + '] ' +
r.label.padEnd(18) + ' ' + r.value + '\n');
@@ -144,10 +203,28 @@ function lamps(st) {
show('A. daemon alive, engine running', ENGINE_UP);
check('A/daemon', page.daemonState(ENGINE_UP) === 'up');
check('A/engine', page.engineState(ENGINE_UP) === 'up');
check('A/config', page.configState(ENGINE_UP) === 'ok');
check('A/no-red', lamps(ENGINE_UP).indexOf('bad') === -1,
'a fully healthy router must show no red lamp');
check('A/no-unknown', lamps(ENGINE_UP).indexOf('unknown') === -1,
'every field is present, so nothing may read as unknown');
// Closed positive list of the rows that a fully-reporting healthy router MUST
// have a verdict for. It replaces a blanket "no unknown anywhere", which stopped
// being the right assertion when the Panel port row was added: that row is unlit
// even here, on purpose, because nothing in a status measures whether the panel
// is listening. Naming the rows says which readings are owed, and a row that
// silently disappears fails here rather than passing as "no unknowns".
var MUST_BE_KNOWN = ['Daemon (shaterd)', 'Engine (sing-box)', 'Configuration',
'Service enabled', 'Service latch', 'Traffic plane', 'Traffic verdict',
'nft table', 'Kill-switch', 'Config hash'];
var a = readout(ENGINE_UP);
MUST_BE_KNOWN.forEach(function(label) {
check('A/known:' + label, a[label].state !== 'unknown' && !a[label].missing,
'every field is present, so this row may not read as unknown (got ' +
a[label].state + ')');
});
check('A/panel-port-unlit', a['Panel port'].state === 'unknown',
'the panel port is a CONFIGURED value; a lit lamp would promise a listener ' +
'that nothing in this status measures');
// 2. THE DEFECT. Live daemon, dead engine, LAN held.
show('B. daemon alive, engine DOWN, holding plane', ENGINE_DOWN);
@@ -182,10 +259,10 @@ check('B/button-never-disabled',
!/openBtn\s*\.\s*disabled/.test(fs.readFileSync(SRC, 'utf8')),
'dashboard.js assigns openBtn.disabled — the launcher must never be locked');
// 3. Daemon not answering at all — the offline stub.
// 3. Daemon not answering at all — the offline stub, marked daemon_answered:false.
show('C. daemon NOT running (offline stub)', DAEMON_DOWN);
check('C/daemon-down', page.daemonState(DAEMON_DOWN) === 'down',
'plane:"" is the stub signature; got ' + page.daemonState(DAEMON_DOWN));
'daemon_answered:false is the contract; got ' + page.daemonState(DAEMON_DOWN));
check('C/engine-down', page.engineState(DAEMON_DOWN) === 'down');
var c = readout(DAEMON_DOWN);
check('C/daemon-row-red', c['Daemon (shaterd)'].state === 'bad');
@@ -197,7 +274,23 @@ check('C/distinct-from-B',
c['Daemon (shaterd)'].value !== b['Daemon (shaterd)'].value,
'engine-down and daemon-down must not render identically');
// 4/5/6. Degenerate answers must degrade to unknown, never to healthy.
// The stub ships config_readable:false without ever having tried a config read
// (cmd/shaterd/main.go never sets it), while its enabled/kill_switch/panel_port
// ARE genuine UCI reads. Believing the field here would put a red "COULD NOT BE
// READ" on screen for a perfectly readable file and throw away the only facts a
// dead-daemon status carries.
check('C/config-not-claimed-unreadable', page.configState(DAEMON_DOWN) === 'unknown',
'config_readable from the offline stub is a zero value, not a reading; got ' +
page.configState(DAEMON_DOWN));
check('C/config-row-unknown', c['Configuration'].state === 'unknown',
'got ' + c['Configuration'].state + ' / ' + c['Configuration'].value);
check('C/config-row-blames-the-daemon', /no daemon answered/.test(c['Configuration'].value));
check('C/enabled-still-read', c['Service enabled'].state === 'good',
'the stub read globals.enabled from UCI; that reading must survive');
check('C/panel-port-still-read', page.panelTarget(DAEMON_DOWN).known === true,
'the stub read globals.panel_port from UCI; the button must use it');
// 4/5/6/7. Degenerate answers must degrade to unknown, never to healthy.
[['D. ubus call failed ({})', NO_ANSWER],
['E. rpcd plugin error / wedged daemon', PLUGIN_ERROR],
['F. legacy daemon (no plane, no engine_running)', LEGACY]].forEach(function(p) {
@@ -208,6 +301,7 @@ check('C/distinct-from-B',
var r = readout(st);
check(p[0] + '/plane-unknown', r['Traffic plane'].state === 'unknown');
check(p[0] + '/daemon-row-unknown', r['Daemon (shaterd)'].state === 'unknown');
check(p[0] + '/config-unknown', r['Configuration'].state === 'unknown');
check(p[0] + '/no-false-green-plane', r['Traffic plane'].state !== 'good');
});
@@ -218,12 +312,203 @@ check('F/enabled-still-read', f['Service enabled'].state === 'good');
check('F/hash-still-read', f['Config hash'].value === 'deadbeef');
check('F/table-still-read', f['nft table'].state === 'good');
// A plane word this build does not know about must land in unknown, not in the
// last-listed branch. (Closed positive list, recoverable default.)
var FUTURE = Object.assign({}, ENGINE_UP, { plane: 'partial' });
check('G/unknown-plane-word', page.daemonState(FUTURE) === 'unknown',
'an unrecognised plane value must not be read as a live daemon');
check('G/unknown-plane-row', readout(FUTURE)['Traffic plane'].state === 'unknown');
// --- G. the compatibility fallback: an OLD shaterd under this LuCI ----------
// `apk upgrade shaterd shater-core luci-app-shater` is not atomic, so a new page
// will meet a daemon that emits no daemon_answered at all. `plane` must keep
// carrying the verdict on its own.
show('G1. OLD shaterd, alive (plane fallback)', OLD_DAEMON_UP);
check('G1/daemon-up', page.daemonState(OLD_DAEMON_UP) === 'up',
'plane:"hold" is a word only a live Applier.Status() writes; got ' +
page.daemonState(OLD_DAEMON_UP));
var g1 = readout(OLD_DAEMON_UP);
check('G1/daemon-row-green', g1['Daemon (shaterd)'].state === 'good');
check('G1/plane-red', g1['Traffic plane'].state === 'bad',
'the holding plane must still be reported as blocking');
check('G1/config-unknown', g1['Configuration'].state === 'unknown',
'an old daemon says nothing about config_readable; absence is not failure');
check('G1/enabled-still-read', g1['Service enabled'].state === 'good',
'a missing config_readable must NOT suppress an enabled that means what it says');
show('G2. OLD shaterd, dead (plane:"" fallback)', OLD_DAEMON_DOWN);
check('G2/daemon-down', page.daemonState(OLD_DAEMON_DOWN) === 'down',
'plane:"" is the pre-daemon_answered stub signature; got ' +
page.daemonState(OLD_DAEMON_DOWN));
check('G2/daemon-row-red', readout(OLD_DAEMON_DOWN)['Daemon (shaterd)'].state === 'bad');
// --- H. the field beats the side effect -------------------------------------
// If the two signals ever disagree, the explicit contract wins. A stub that
// somehow carried a plane word (a future change to cmdStatus, a merged object)
// must still read as "no daemon answered" — that is the entire reason the field
// was added, and keying off `plane` first would quietly restore the old defect.
var STUB_WITH_PLANE = Object.assign({}, DAEMON_DOWN, { plane: 'full' });
show('H. daemon_answered:false but plane:"full"', STUB_WITH_PLANE);
check('H/field-wins', page.daemonState(STUB_WITH_PLANE) === 'down',
'daemon_answered:false must outrank a plane word; got ' +
page.daemonState(STUB_WITH_PLANE));
var h = readout(STUB_WITH_PLANE);
check('H/daemon-row-red', h['Daemon (shaterd)'].state === 'bad');
// And the fields the stub CANNOT know are dropped rather than rendered. The
// stub's contract lists plane/traffic/hash among "placeholders, not
// measurements"; a green "full — ruleset, routing and engine are all installed"
// over a daemon that never answered is the original defect in a new costume.
check('H/plane-row-unknown', h['Traffic plane'].state === 'unknown',
'a plane word from an object marked daemon_answered:false is a placeholder; ' +
'got ' + h['Traffic plane'].state + ' / ' + h['Traffic plane'].value);
var STUB_WITH_EVERYTHING = Object.assign({}, DAEMON_DOWN, {
plane: 'full', hash: 'cafebabe',
traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 }
});
var he = readout(STUB_WITH_EVERYTHING);
check('H/verdict-row-unknown', he['Traffic verdict'].state === 'unknown',
'got ' + he['Traffic verdict'].value);
check('H/hash-row-not-shown', he['Config hash'].value.indexOf('cafebabe') === -1,
'a hash nobody measured must not be printed as the running config: ' +
he['Config hash'].value);
check('H/no-green-plane-anywhere', lamps(STUB_WITH_EVERYTHING).filter(function(s, i) {
return s === 'good' && ['Traffic plane', 'Traffic verdict', 'Config hash']
.indexOf(page.statusReadout(STUB_WITH_EVERYTHING)[i].label) !== -1;
}).length === 0,
'no engine-side row may be green while daemon_answered is false');
// Control for the three above: the SAME values from a daemon that did answer are
// rendered in full. Without this, "dropped" would also pass on a page that
// dropped them unconditionally.
var ANSWERED_WITH_EVERYTHING = Object.assign({}, STUB_WITH_EVERYTHING,
{ daemon_answered: true });
var ha = readout(ANSWERED_WITH_EVERYTHING);
check('H/control-plane-shown', ha['Traffic plane'].state === 'good');
check('H/control-verdict-shown', ha['Traffic verdict'].state === 'good');
check('H/control-hash-shown', ha['Config hash'].value === 'cafebabe');
// A non-boolean daemon_answered is not a verdict and must not be coerced: it
// falls through to the fallback, which here proves the daemon live by itself.
var WEIRD_FLAG = Object.assign({}, ENGINE_UP, { daemon_answered: 'true' });
check('H/non-boolean-not-coerced', page.daemonState(WEIRD_FLAG) === 'up',
'a string is not the field; the plane fallback must decide instead');
var WEIRD_FLAG_NO_PLANE = { daemon_answered: 'yes' };
check('H/non-boolean-alone-is-unknown',
page.daemonState(WEIRD_FLAG_NO_PLANE) === 'unknown',
'with nothing else to go on, a non-boolean flag is not an answer');
// --- I/J. unrecognised plane words ------------------------------------------
// Closed positive list, recoverable default — a word this build does not know
// lands in unknown, never in the last-listed branch.
var FUTURE_PLANE = Object.assign({}, ENGINE_UP, { plane: 'partial' });
check('I/daemon-still-up', page.daemonState(FUTURE_PLANE) === 'up',
'the daemon SAID it answered; an unknown plane word does not unsay it');
check('I/plane-row-unknown', readout(FUTURE_PLANE)['Traffic plane'].state === 'unknown');
var OLD_FUTURE_PLANE = Object.assign({}, OLD_DAEMON_UP, { plane: 'partial' });
check('J/fallback-list-is-closed', page.daemonState(OLD_FUTURE_PLANE) === 'unknown',
'with no contract field, an unrecognised plane value proves nothing');
check('J/plane-row-unknown', readout(OLD_FUTURE_PLANE)['Traffic plane'].state === 'unknown');
// --- K. THE CONFIGURATION CANNOT BE READ ------------------------------------
// A live daemon with config_readable:false. enabled/kill_switch/panel_port are
// zero values; rendering them as readings is the inverted lie the panel already
// fixed on its side (planeState.ts protectionState / killSwitchReadout).
show('K. daemon alive, CONFIGURATION UNREADABLE', CONFIG_BAD);
check('K/daemon-up', page.daemonState(CONFIG_BAD) === 'up');
check('K/config-failed', page.configState(CONFIG_BAD) === 'failed');
var k = readout(CONFIG_BAD);
check('K/config-row-red', k['Configuration'].state === 'bad',
'got ' + k['Configuration'].state);
check('K/config-row-carries-the-error',
k['Configuration'].value.indexOf('no space left on device') !== -1,
'the daemon said WHY; dropping it makes the operator guess');
check('K/config-row-says-dont-switch-off', /do not turn anything off/i.test(k['Configuration'].value),
'the instinct here is to switch things off, and that is the one action that ' +
'makes it worse');
check('K/enabled-not-called-disabled', k['Service enabled'].state === 'unknown',
'enabled=false with config_readable=false is a zero value, not the owner\'s ' +
'choice; got ' + k['Service enabled'].state + ' / ' + k['Service enabled'].value);
check('K/enabled-says-not-known', /not known/.test(k['Service enabled'].value));
check('K/kill-switch-not-green', k['Kill-switch'].state === 'unknown',
'kill_switch:"" must not normalise to a green "fail-closed"; got ' +
k['Kill-switch'].state + ' / ' + k['Kill-switch'].value);
check('K/kill-switch-says-not-known', /not known/.test(k['Kill-switch'].value));
check('K/latch-lamp-out', k['Service latch'].state === 'unknown',
'the latch is readable but the comparison against globals.enabled is not');
check('K/latch-still-states-the-latch', /raised/.test(k['Service latch'].value),
'the fact survives even though the verdict does not');
check('K/plane-red', k['Traffic plane'].state === 'bad',
'the holding plane is what is installed, and it is blocking');
check('K/panel-port-unknown', page.panelTarget(CONFIG_BAD).known === false,
'panel_port:0 is a placeholder, not a port');
check('K/panel-port-falls-back-to-default', page.panelTarget(CONFIG_BAD).port === 8088);
check('K/panel-port-row-says-so', /could not be read/.test(k['Panel port'].value),
'got ' + k['Panel port'].value);
// The same fixture WITHOUT a table loaded: "not loaded" may only be the calm
// amber when the service is KNOWN to be off. With no readable configuration it
// is not known, so the alarming lamp is the correct one.
var CONFIG_BAD_NO_TABLE = Object.assign({}, CONFIG_BAD, { table: false });
check('K/table-absent-is-red',
readout(CONFIG_BAD_NO_TABLE)['nft table'].state === 'bad',
'enabled=false is a placeholder here and must not soften an absent firewall ' +
'table to amber');
// Control for the line above: with a READABLE configuration that says disabled,
// the same absent table IS the calm amber. Without this, "red" proves nothing.
var DISABLED_NO_TABLE = Object.assign({}, ENGINE_UP,
{ enabled: false, table: false, plane: 'none', kill_switch: 'open' });
check('K/table-absent-is-amber-when-really-disabled',
readout(DISABLED_NO_TABLE)['nft table'].state === 'warn',
'a service the owner switched off has no table on purpose');
// A plane:"none" whose kill-switch setting is unknown must not carry the
// "traffic reaches the WAN unprotected" claim — that sentence is earned by a
// KNOWN fail-closed setting.
var CONFIG_BAD_NO_PLANE = Object.assign({}, CONFIG_BAD, { plane: 'none' });
check('K/none-plane-claims-nothing-about-protection',
!/unprotected/.test(readout(CONFIG_BAD_NO_PLANE)['Traffic plane'].value),
'with an unreadable configuration the kill-switch setting is not known, so ' +
'"unprotected" is a claim this page cannot make');
// Control: with a readable configuration that says closed, the claim IS made.
var CLOSED_NO_PLANE = Object.assign({}, ENGINE_UP,
{ plane: 'none', kill_switch: 'closed' });
check('K/none-plane-does-claim-it-when-known',
/unprotected/.test(readout(CLOSED_NO_PLANE)['Traffic plane'].value) &&
readout(CLOSED_NO_PLANE)['Traffic plane'].state === 'bad',
'a known fail-closed setting with nothing installed IS the dangerous state');
// --- L. the launcher: a configured port is not a listening one ---------------
// shaterd starts the panel server in a goroutine and only LOGS a bind failure
// (cmd/shaterd/main.go: "panel server unavailable (daemon continues)"), and
// SHATER_PANEL_ADDR can disable it outright while apply.Status keeps reporting a
// port. Nothing on the wire says the panel is listening, so nothing on this page
// may say it either.
check('L/custom-port-used', page.panelTarget(CUSTOM_PORT).port === 9090 &&
page.panelTarget(CUSTOM_PORT).known === true);
check('L/no-answer-falls-back', page.panelTarget(NO_ANSWER).port === 8088 &&
page.panelTarget(NO_ANSWER).known === false);
check('L/garbage-falls-back', page.panelTarget(null).port === 8088);
var hintKnown = page.panelHint('router', page.panelTarget(CUSTOM_PORT));
process.stdout.write('\n=== L. launcher hint / tooltip ===\n');
process.stdout.write(' hint(known) ' + hintKnown + '\n');
check('L/hint-names-the-url', hintKnown.indexOf('http://router:9090/') !== -1,
'got ' + hintKnown);
check('L/hint-marks-the-port-unchecked', /not a checked one/.test(hintKnown),
'the hint must not read as "the panel is there": ' + hintKnown);
check('L/hint-has-no-placeholders-left', hintKnown.indexOf('%') === -1,
'unsubstituted placeholder in the hint: ' + hintKnown);
var hintUnknown = page.panelHint('router', page.panelTarget(CONFIG_BAD));
process.stdout.write(' hint(unknown) ' + hintUnknown + '\n');
check('L/hint-admits-the-guess', /built-in default/.test(hintUnknown),
'a port nobody reported must be named as this page\'s guess: ' + hintUnknown);
check('L/hint-unknown-has-no-placeholders-left', hintUnknown.indexOf('%') === -1,
'unsubstituted placeholder in the hint: ' + hintUnknown);
// Every tooltip, on every state, carries the port caveat. Closed list of the
// states, so a new branch added without the caveat fails here.
[['up', ENGINE_UP], ['engine-down', ENGINE_DOWN], ['daemon-down', DAEMON_DOWN],
['config-bad', CONFIG_BAD], ['no-answer', NO_ANSWER]].forEach(function(p) {
var t = page.panelTitle(p[1]);
process.stdout.write(' title(' + p[0] + ') ' + t + '\n');
check('L/title-caveat:' + p[0],
/configured one/.test(t) || /built-in default/.test(t),
'the tooltip promises a panel without saying the port is unverified: ' + t);
check('L/title-not-empty:' + p[0], typeof t === 'string' && t.length > 20);
});
// --- Report -----------------------------------------------------------------
+15
View File
@@ -98,6 +98,21 @@ define Package/shater-core/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/shater $(1)/etc/config/shater
# THE SAME FILE AGAIN, READ-ONLY, AS DOCUMENTATION. /etc/config/shater is 271
# lines of which 248 are comment, and on the router it is the only description
# of the schema there is (PORTING.md does not ship). Being a conffile keeps an
# upgrade from replacing it, but it does NOT keep the daemon from rewriting it:
# the config write path replaces the whole package (`uci delete shater` + `uci
# import`), which drops every comment — and it runs without an operator, from
# the panel, the 6-hourly subscription refresh and the 25-second profile
# watcher. So the annotated original is installed a second time where nothing
# rewrites it, and the header of the live file points at it.
#
# INSTALL_DATA, not INSTALL_CONF: this copy is package metadata (refreshed by
# every upgrade so it documents the build actually installed), not user config.
$(INSTALL_DIR) $(1)/usr/share/shater
$(INSTALL_DATA) ./files/etc/config/shater $(1)/usr/share/shater/config.sample
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/etc/uci-defaults/30_shater-core $(1)/etc/uci-defaults/30_shater-core
+27 -4
View File
@@ -12,8 +12,30 @@
# from this file. There is no separate xray/dnsmasq and no generated run.json.
#
# Full schema: docs-shater/PORTING.md (PART A "uci.go — /etc/config/shater
# schema") and shater/model. This file is installed as a conffile — your edits
# survive package upgrades.
# schema") and shater/model.
#
# WHAT SURVIVES WHAT. This file is installed as a conffile, so `apk upgrade
# shater-core` will not replace it: your VALUES survive a package upgrade.
#
# These COMMENTS do not survive the first write, and that write does not need
# you to make it. The daemon and the panel persist the whole package in one go
# (`uci delete shater` + `uci import`), and a package rebuilt by `uci import`
# keeps no comments and no hand-made blank lines; the sections come back in the
# daemon's own order. Three things write here with nobody at the keyboard: a save
# in the admin panel, a subscription refresh (cron, every 6 h) and the profile
# watcher switching profiles (it looks every 25 s). So expect the annotations
# below to be gone shortly after the box is first configured.
#
# THE ANNOTATED COPY IS KEPT: /usr/share/shater/config.sample is this same file,
# installed by the package where nothing rewrites it. Read it there (`cat
# /usr/share/shater/config.sample`) and copy the fragment you need. It is
# refreshed by each package upgrade, so it always documents the build you have.
#
# BEFORE THE FIRST CHANGE, the previous file is copied to
# /etc/shater/config.pre-v<schema>.bak — once per schema version, never
# overwritten afterwards. That copy is the one taken at the transition (a schema
# migration, or the first save on a newly installed build); it is not a rolling
# backup, and it is deliberately not carried across a sysupgrade.
#
config globals 'globals'
@@ -164,8 +186,9 @@ config inbound
# record - tls_record_fragment (alternative; mutually exclusive w/ fragment)
# spoof - tls_spoof (inject a decoy ClientHello; needs root NET_RAW/NET_ADMIN)
# Point a rule's target at it for DPI-blocked-but-not-IP-blocked domains — direct
# and fragmented, no exit node, no extra binary. (The stronger external `byedpi`
# preset is Phase-2b.)
# and fragmented, no exit node, no extra binary. These three are the WHOLE set;
# the external desync egress that once stood beside them is removed (D29), and a
# `type 'byedpi'` egress left over from an older build is blocked, not routed.
#config egress
# option name 'frag'
# option type 'direct'
+149 -14
View File
@@ -252,6 +252,142 @@ _slog() {
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
}
# A line the operator gets EVEN WITH globals.log_syslog=0, without going behind
# that setting's back.
#
# log_syslog is a statement about ONE destination: the syslog stream (see _slog
# above — the same toggle silences the daemon's own stderr->logread fan-out).
# Honouring it by staying silent everywhere turns "keep syslog quiet" into "never
# tell me the config could not be brought forward", which is not what it says and
# not what anybody means by it. So the refusal goes somewhere else instead:
#
# * THIS SCRIPT'S OWN STDERR, unconditionally. That is not the syslog stream; it
# is the reply to whoever invoked the script. Typed by hand it lands on the
# operator's terminal at the moment they are looking at it; run from
# 30_shater-core inside `apk add` / `opkg install` it lands in the package
# manager's output, which is the one screen an installing operator does read.
# At boot it goes to procd's stderr (console) — not durable, hence the file.
# * /etc/shater/migrate-failed, on flash, written on failure and REMOVED on the
# first success. That is the durable half: it survives the reboot nobody
# watched, `cat` reads it, and its absence is the honest all-clear. Written
# AFTER the stderr line on purpose — a full /overlay is one of the named
# causes of the failure it is reporting, so it must never be the only channel.
# * syslog too, but only when log_syslog allows it — that channel keeps
# obeying the operator exactly as before.
#
# One call, one text, three destinations, so the wording cannot drift between
# them. Failures only: _shout is not a status line.
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
_shout() {
echo "shater: $*" >&2
_slog -p daemon.err "$*"
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
}
# --- `shaterd migrate`: which of the four things happened --------------------
#
# The schema version on disk, as an integer. 0 for "absent" and 0 for anything
# non-numeric, DELIBERATELY the same two answers model.readSchemaVersion gives
# (`strconv.Atoi` of a garbage value is 0 with the error dropped) — this number
# is only ever used to name a version in a message, and a shell that disagreed
# with the binary about what v0 means would print a version the binary never saw.
shater_schema_version() {
local v
v=$(uci -q get shater.globals.schema_version) || v=""
case "$v" in
"") echo 0 ;;
*[!0-9]*) echo 0 ;;
*) echo "$v" ;;
esac
}
# shater_migrate_class <rc> <output-of-shaterd-migrate> — prints EXACTLY one of:
#
# ok the binary reported success (it may or may not have had work)
# downgrade REFUSED: the config on disk is NEWER than this build
# unreadable /etc/config/shater could not be read at all
# failed it failed for a reason this script does not recognise
#
# A CLOSED POSITIVE LIST, and the last rung is the point of it. Until v0.2.19 all
# four of these were reported with ONE sentence, and that sentence described only
# the third one: "routing rules that still carry the removed dst_domain/dst_ip
# options stay DISABLED until this succeeds. Free space on /overlay and re-run".
# On a DOWNGRADE every clause of that is false — nothing is disabled, /overlay is
# not the problem, and re-running does not help, because the fix is to put the
# newer package back. A confident wrong diagnosis costs more than no diagnosis.
#
# `downgrade` is recognised from the binary's own words. That is a CONTRACT with
# shater/model: both refusals — model.migrateWith's "config schema v%d newer than
# this build (v%d); upgrade the package" and model.ErrSchemaTooNew's "config
# schema newer than this build" — contain the substring matched below, and
# TestMigrateDowngradeSignatureIsAContract (shater/cmd/shaterd) fails if either
# stops containing it. If the wording is ever changed anyway, this degrades to
# `failed`, which names itself as unrecognised and quotes the binary verbatim —
# the recoverable side. It cannot degrade into one of the confident branches.
shater_migrate_class() {
local rc="$1" out="$2"
[ "$rc" = "0" ] && { echo ok; return 0; }
case "$out" in
*"newer than this build"*) echo downgrade; return 0 ;;
esac
# Asked LAST, so a refusal we can name is never re-labelled as an I/O problem.
# `uci export` fails both when the file is missing and when it does not parse,
# which is the same thing from here: nothing can be said about a schema that
# cannot be read.
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
echo failed
}
# Run the migration and report it. Called from start_service and mirrored by
# /etc/uci-defaults/30_shater-core; see the long note at the call site for why
# this never refuses to start.
shater_migrate() {
local before after out rc class
before=$(shater_schema_version)
out=$("$PROG" migrate 2>&1)
rc=$?
class=$(shater_migrate_class "$rc" "$out")
after=$(shater_schema_version)
case "$class" in
ok)
# The all-clear is the ABSENCE of the breadcrumb, so a fixed router stops
# claiming to be broken the moment it is fixed.
rm -f "$SHATER_MIGRATE_BREADCRUMB"
# Nothing to do is not news; obeys log_syslog like every other status line.
[ "$before" = "$after" ] && return 0
_slog -p daemon.info \
"UCI schema migrated: v$before -> v$after. The config as it was at v$before was copied to /etc/shater/config.pre-v$before.bak before the first change."
;;
downgrade)
_shout "UCI schema migration REFUSED — this is a DOWNGRADE, not a broken config. /etc/config/shater carries schema v$after, which is NEWER than this build understands, so nothing was migrated and nothing on disk was changed. Your settings are intact; they are also unchangeable, because the daemon and the panel refuse every config write for the same reason and a save from the panel will fail too. Nothing on this router fixes it: install a shater build that understands schema v$after — the one that ran here before the downgrade (docs-shater/INSTALL.md has the pinned per-version feed). Starting anyway, so the panel stays reachable. '$PROG migrate' said: ${out:-no output}"
;;
unreadable)
_shout "UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so this script cannot even say which schema is on disk. A config that is missing or does not parse is neither migrated nor repaired here. Starting anyway — the daemon will come up on whatever it can parse, which may be nothing, leaving it inert with the panel still reachable. Check /etc/config/shater by hand; an /etc/shater/config.pre-v*.bak copy from an earlier migration may be next to it. '$PROG migrate' said: ${out:-no output}"
;;
failed)
_shout "UCI schema migration FAILED for a reason this script does not recognise; the config on disk is still at schema v$after. Starting anyway: refusing to start would take the admin panel down with it, and the panel is the only way to fix the box. The mundane cause is a full /overlay, where 'uci commit' cannot write — check 'df /overlay' first, then re-run '$PROG migrate' or restart the service.$(
[ "$after" = "1" ] && printf ' %s' "While the config stays at v1, routing rules that still carry the removed dst_domain/dst_ip options are held DISABLED by the daemon and reported as such — those rules are not in force."
) '$PROG migrate' said: ${out:-no output}"
;;
*)
# shater_migrate_class returns a closed set and every member of it is
# handled above, so this is unreachable. It exists to say "this script
# disagrees with itself" out loud instead of picking one of the confident
# branches and being wrong quietly — which is the exact failure the closed
# list replaced.
_shout "INTERNAL: '$PROG migrate' produced a result /etc/init.d/shater cannot classify (class='$class', rc=$rc). That is a bug in this script, not a state of the router. Starting anyway. Output was: ${out:-no output}"
;;
esac
}
# Announce/withdraw "this daemon is being replaced, not switched off". Read by
# `shaterd run` when it receives SIGTERM.
shater_mark_restart() {
@@ -367,20 +503,19 @@ start_service() {
# Bring the UCI schema forward before the daemon reads it (idempotent;
# refuses a newer schema) so an upgraded package never applies a stale config.
#
# THE FAILURE IS LOGGED, NOT SWALLOWED. This is the only place the schema
# migration runs at boot (`shaterd run`, the SIGHUP reconcile and the panel's
# config write all read UCI directly), so if it fails here it does not get
# retried until the next start. And it CAN fail for a mundane reason — a full
# /overlay makes `uci commit` fail — after which the config still carries the
# schema-v1 `dst_domain`/`dst_ip` options. The daemon holds every rule that
# still has them DISABLED and reports it, so nothing is silently misrouted, but
# rules the operator wrote are then not in force and the reason has to be
# visible somewhere. Hence: log the binary's own stderr, and start anyway —
# refusing to start would take the admin panel down with it, and the panel is
# the only way to fix the box.
local migrate_out
migrate_out=$("$PROG" migrate 2>&1) || _slog -p daemon.err \
"UCI schema migration FAILED: ${migrate_out:-no output from $PROG migrate}. Starting anyway; routing rules that still carry the removed dst_domain/dst_ip options stay DISABLED until this succeeds. Free space on /overlay and re-run '$PROG migrate', or restart the service."
# THE FAILURE IS REPORTED, NOT SWALLOWED, AND IT IS NAMED. This is the only
# place the schema migration runs at boot (`shaterd run`, the SIGHUP reconcile
# and the panel's config write all read UCI directly), so if it fails here it
# does not get retried until the next start — which is also why this, and not
# the uci-defaults call, is the report that matters: it comes back at every
# boot and every restart for as long as the problem lasts.
#
# The three outcomes are three different problems with three different fixes
# (free space / put the newer package back / the file is unreadable), and
# shater_migrate says which one it was instead of asserting the middle one at
# all of them. Start regardless in every case: refusing to start would take the
# admin panel down with it, and the panel is the only way to fix the box.
shater_migrate
procd_open_instance shater
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
@@ -162,6 +162,31 @@ shater_stamp_retry() {
# Walk anonymous `config subscription` / `config ruleset` sections by index and
# run any that are due. Echoes non-empty on stdout if at least one item updated.
#
# WHERE A SUBSCRIPTION'S BYTES TRAVEL, and what this loop sees when they cannot.
#
# `shaterd sub update <name>` honours the subscription's own `fetch_via`:
# * fetch_via != proxy — fetched DIRECT by the short-lived CLI process, exactly
# as it always was. Needs no daemon; works at cold start and first boot.
# * fetch_via == proxy — DELEGATED to the running daemon over its control
# socket, because only the daemon owns the engine the fetch has to travel
# through. Until 2026-07-27 the CLI printed one `daemon.warn` line and
# fetched DIRECT instead, so every tick of THIS loop and every fetch-at-boot
# put the feed URL and the router's real address on the plain WAN — while the
# panel's Refresh button (the same operation, through the daemon) worked, so
# it looked configured. It now FAILS instead, exit 1.
#
# CONSEQUENCE HERE, stated rather than discovered later: with a proxy-fetched
# subscription and no live daemon, the `if` below takes the else arm every time —
# one syslog line and shater_stamp_retry, i.e. a fresh attempt every RETRY_SECS
# (300s) for as long as the daemon is down. That is ~288 lines a day per such
# subscription. It is NOT the `ruleset update` situation this file used to have:
# there the work had no owner and the retry could never succeed, whereas here the
# retry succeeds within RETRY_SECS of the daemon coming back, and the loop only
# runs at all while `shater_enabled && shater_active` — a dead daemon is already
# being escalated by shater_watchdog, and with kill_switch=open it stops the stack
# (clearing ACTIVE_FLAG), which makes this loop inert. Deliberately left noisy:
# a subscription that is silently going stale is worse than a repeated line.
shater_run_due() {
local i name en ivl secs stamp changed=""
@@ -36,28 +36,58 @@ mkdir -p /etc/shater
# transaction, and any /etc/init.d/* invocation in that window risks blocking
# the transaction on rc.common's per-service flock (procd_lock).
# Seed the built-in preset packs (disabled) so the LuCI Rules page renders their
# toggles. Idempotent: only creates a section that does not yet exist.
seed_preset() {
local sid="$1" name="$2" s n
uci -q get "shater.$sid" >/dev/null 2>&1 && return 0
# A pack section may already exist under a DIFFERENT section id (created by
# the LuCI seeding or an older release) — match by pack name, not just id,
# or we would duplicate the toggle.
for s in $(uci -q show shater 2>/dev/null | sed -n "s/^shater\.\([^.=]*\)=preset$/\1/p"); do
n=$(uci -q get "shater.$s.name")
[ "$n" = "$name" ] && return 0
# NO preset packs are seeded here, and the ones older releases seeded are removed.
#
# Until now this script created three `config preset` sections (block_ads,
# ru_bypass, private; all `enabled=0`) "so the LuCI Rules page renders their
# toggles". Both halves of that stopped being true in v0.2, and what was left was
# a knob wired to nothing:
#
# * `preset` IS NOT A SECTION TYPE. The type switch in model.ParseUCIExport
# (shater/model/uci.go) has no `preset` branch, and an unknown section type is
# dropped on the floor rather than rejected — TestUnknownSectionAndOptionIgnored
# pins that a config carrying one still parses, because the daemon has to come
# up on whatever it finds. So `uci set shater.block_ads.enabled=1; uci commit`
# edited the file and changed NOTHING about the running router, and there was
# no error anywhere to say so. Nothing in shater/, panel/ or luci-app-shater
# reads the type either.
# * v0.2's luci-app-shater is a launcher for the daemon's own admin panel. There
# is no LuCI Rules page for the toggles to appear on.
# * The sections did not even persist. model.writeUCIWith replaces the WHOLE
# package (`uci delete shater` + `uci import`), so the first save from the
# panel deleted all three. A placeholder that erases itself reads as "this
# broke", not as "this was never here" — which is worse than its absence.
#
# So the packs are not "missing": nothing in v0.2 lost a feature when the sections
# stopped being written, because nothing ever read them. And re-adding the seed is
# not how presets would come back. v0.1's packs were xray `geosite:`/`geoip:`
# matcher lists materialised into synthetic rules (`xrayctl/preset.go` on the v0.1
# branch); in v0.2 a rule's destination IS a `config ruleset` (schema v2), so the
# same pack is an ordinary ruleset + rule — which the panel's Routing page already
# builds, geosite/geoip sources included. Anything richer needs a section type the
# parser knows about, which has to land in shater/model FIRST; seeding UCI ahead
# of the parser only produces silence.
#
# The purge is narrow and safe by construction: it matches on the section TYPE
# being exactly `preset`, and that type is read by no consumer, so there is no
# setting to lose. Bounded and re-querying each round because a `config preset`
# may also be ANONYMOUS (`shater.@preset[0]`), where deleting from a list captured
# up front would shift the remaining indices out from under it.
purge_presets() {
local s n=0 changed=""
[ -f /etc/config/shater ] || return 0
while [ "$n" -lt 32 ]; do
s=$(uci -q show shater 2>/dev/null |
sed -n 's/^shater\.\([^.=]*\)=preset$/\1/p' | head -n 1)
[ -n "$s" ] || break
uci -q delete "shater.$s" || break
changed=1
n=$((n + 1))
done
uci set "shater.$sid=preset"
uci set "shater.$sid.name=$name"
uci set "shater.$sid.enabled=0"
[ -n "$changed" ] && uci -q commit shater
return 0
}
if uci -q get shater.globals >/dev/null 2>&1 || [ -f /etc/config/shater ]; then
seed_preset block_ads block-ads
seed_preset ru_bypass ru-bypass
seed_preset private private
uci -q commit shater
fi
purge_presets
# Introduce the daemon-created `shater-l3*` TUN to fw4 (L3 ingress, D-L3). The
# daemon policy-routes LAN ICMP into that device from OUR nft table
@@ -253,7 +283,97 @@ migrate_l3_zone_wildcard() {
migrate_l3_zone_wildcard
# Bring the UCI schema forward on upgrade (idempotent; refuses a newer schema).
[ -x /usr/bin/shaterd ] && /usr/bin/shaterd migrate >/dev/null 2>&1
#
# THE RESULT IS NO LONGER THROWN AWAY. `>/dev/null 2>&1` discarded stdout, stderr
# AND the exit status, so a refusal here was indistinguishable from success — on
# the one screen the operator who caused it is actually looking at.
#
# WHY THIS STILL `exit 0`s (the script ends with one, and this block does not
# change that). A uci-defaults script that exits non-zero is NOT deleted and runs
# again at every boot. That is the wrong trade here, three times over:
#
# * This file does far more than migrate — it seeds rt_tables, the shater_l3
# fw4 zone and its per-zone forwardings, applies sysctl, and launches a
# DETACHED BRING-UP that enables and RESTARTS shater/shater-cron and reloads
# the firewall. Re-running all of that at every boot to carry one bit of "the
# migration failed" would bounce the tunnel on every boot, after S99 had
# already started it. One recoverable failure would become permanent churn.
# * The exit status is not a reporting channel: nothing reads a uci-defaults
# script's status, and neither procd nor the package manager surfaces it. It
# buys no diagnosis, only the re-run.
# * The retry it would buy already exists, and is better. /etc/init.d/shater
# runs the same migration on EVERY start with the full classified report, so
# a failure is retried at every boot and every restart regardless. And
# re-running cannot fix either real cause anyway: a downgrade is fixed by
# installing the right package, a full /overlay by freeing space.
#
# So: capture the status, name the cause, record it durably, and exit 0. The
# script has done everything it can do, and the failure is not lost.
#
# The classification is the same closed positive list /etc/init.d/shater uses
# (shater_migrate_class there, with the long argument for why the list is closed
# and why `downgrade` is matched on the binary's own words). It is repeated here
# rather than shared because the package installs no shell library the two could
# both source; TestMigrateClassifiersAgree (shater/cmd/shaterd) runs both over
# the same inputs and fails if they ever disagree.
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
shater_migrate_class() {
local rc="$1" out="$2"
[ "$rc" = "0" ] && { echo ok; return 0; }
case "$out" in
*"newer than this build"*) echo downgrade; return 0 ;;
esac
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
echo failed
}
# stderr, unconditionally: inside `apk add` / `opkg install` that is the package
# manager's own output, i.e. the installing operator's screen. Plus the durable
# breadcrumb on flash, which /etc/init.d/shater removes on the first successful
# migration. Deliberately NOT syslog — globals.log_syslog may be off by the
# operator's choice, and this path honours it by using other channels instead.
shater_migrate_shout() {
echo "shater: $*" >&2
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
}
run_migrate() {
local out rc class schema
[ -x /usr/bin/shaterd ] || return 0
out=$(/usr/bin/shaterd migrate 2>&1)
rc=$?
class=$(shater_migrate_class "$rc" "$out")
schema=$(uci -q get shater.globals.schema_version)
case "$schema" in ""|*[!0-9]*) schema=0 ;; esac
case "$class" in
ok)
rm -f "$SHATER_MIGRATE_BREADCRUMB"
;;
downgrade)
shater_migrate_shout "install: UCI schema migration REFUSED — /etc/config/shater is schema v$schema and the shater build being installed understands an older one, so this is a DOWNGRADE. Nothing was migrated and nothing on disk was changed: your settings are intact, and also unchangeable, because the daemon and the panel refuse every config write for the same reason. Install a build that understands schema v$schema again (docs-shater/INSTALL.md has the pinned per-version feed). 'shaterd migrate' said: ${out:-no output}"
;;
unreadable)
shater_migrate_shout "install: UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so the schema on disk cannot even be named. Check the file by hand before configuring anything. 'shaterd migrate' said: ${out:-no output}"
;;
failed)
shater_migrate_shout "install: UCI schema migration FAILED for a reason this script does not recognise; the config is still at schema v$schema. The mundane cause is a full /overlay ('uci commit' cannot write) — check 'df /overlay'. /etc/init.d/shater retries this on every start and reports it there too. 'shaterd migrate' said: ${out:-no output}"
;;
*)
# Unreachable: shater_migrate_class returns a closed set, all of it
# handled above. Named rather than swept up, so a script that disagrees
# with itself says so instead of picking a confident branch and being
# wrong quietly.
shater_migrate_shout "install: INTERNAL — 'shaterd migrate' produced a result this script cannot classify (class='$class', rc=$rc). That is a bug in /etc/uci-defaults/30_shater-core. Output was: ${out:-no output}"
;;
esac
return 0
}
run_migrate
# Apply our sysctl knobs NOW (boot applies them via procd's sysctl service, but
# on a live opkg/apk install nothing else re-reads sysctl.d — without this, an
+160
View File
@@ -432,6 +432,18 @@
letter-spacing: 0.06em;
color: var(--faint);
}
/* Findings come from the last SUCCESSFUL apply. When the config on disk was
refused, that is a different configuration from the one the reader just saved —
and under a heading reading "Last apply" a clean list means "your edit is
fine". A sentence about the whole list, so it gets its own line above it rather
than a third cell in the header, where 390 px left it four words a column. */
.findings-stale {
margin: calc(var(--u, 8px) * 1.25) 0 0;
max-width: 76ch;
font-size: 12px;
line-height: 1.5;
color: var(--crit);
}
.findings-list {
margin: calc(var(--u, 8px) * 1.5) 0 0;
padding: 0;
@@ -626,3 +638,151 @@
.inline-rename-input:disabled {
opacity: 0.55;
}
/* ---- pre-apply hazard band (Apply.tsx ApplyRiskBand ← planeState.applyRisk) ----
Shown on Apply and Overview when pressing the button below would leave the
network with no way out.
It borrows the CRIT vocabulary because the outcome really is crit-severity, but
a forecast must not be mistaken for an observed fault — the panel's other crit
bands all report something that has already happened. Two things keep them
apart: the band leads with an eyebrow naming the tense, and it ends in the
ACCENT rather than in more red. Crit says how bad this is; the accent marks the
controls that prevent it. Deliberately taller and quieter-edged than
.plane-band, because unlike a banner this one is meant to be read, not
glanced at. */
.risk-band {
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
margin-top: calc(var(--u, 8px) * 2);
padding: 14px 16px 15px;
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
border-left: 3px solid var(--crit);
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
.risk-band-hd {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.25);
}
.risk-eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 700;
letter-spacing: var(--track-label-wide, 0.24em);
text-transform: uppercase;
color: var(--crit);
}
.risk-headline {
margin: 0;
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.35;
color: var(--ink);
}
.risk-detail,
.risk-undo {
margin: 0;
max-width: 76ch;
font-size: 12.5px;
line-height: 1.55;
color: var(--dim);
}
/* The missing auto-rollback is the part that decides whether a mistake here costs
two minutes or an SSH session, so it is the one line drawn at full ink. */
.risk-undo.hot {
color: var(--ink);
font-weight: 600;
}
.risk-steps {
margin: calc(var(--u, 8px) * 0.5) 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
max-width: 76ch;
}
.risk-steps li {
position: relative;
padding-left: 18px;
font-size: 12.5px;
line-height: 1.55;
color: var(--ink);
}
/* A square tick in the accent — the panel's "this is a control you touch" colour,
and the only accent in a band that is otherwise entirely red. */
.risk-steps li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 1px;
background: var(--accent);
}
@media (max-width: 560px) {
.risk-band {
padding: 12px 13px 13px;
}
.risk-detail,
.risk-undo,
.risk-steps li {
font-size: 12px;
}
}
/* ---- the config on disk is not the one running (Overview.tsx NotAppliedBand) ----
Same crit vocabulary and the same internals as .risk-band, and deliberately so:
the severity is identical. What separates them is the TENSE, which the eyebrow
states — the hazard band forecasts what a button would do, this one reports a
state the box is already in. So this band ends in crit rather than in the
accent: there is nothing to prevent any more, and the control that clears it is
an edit to the configuration, not a button on this page.
It sits ABOVE the findings because it says what the findings are about. */
.stale-band {
display: flex;
flex-direction: column;
gap: calc(var(--u, 8px) * 0.75);
margin-top: calc(var(--u, 8px) * 2);
padding: 14px 16px 15px;
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
border-left: 3px solid var(--crit);
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
/* The step that refused — the one word an operator acts on, so it is the one
thing here drawn at full ink inside a line of body copy. */
.stale-stage {
color: var(--ink);
font-weight: 700;
}
/* The daemon's reason, verbatim. Monospaced because it is machine text quoted
into prose, and boxed so a long generator error cannot be mistaken for our own
sentence about it. */
.stale-cause {
padding: 8px 10px;
border: 1px solid var(--groove);
border-radius: 6px;
background: color-mix(in srgb, var(--sink) 75%, transparent);
font-size: 11.5px;
line-height: 1.5;
color: var(--ink);
overflow-wrap: anywhere;
}
@media (max-width: 560px) {
.stale-band {
padding: 12px 13px 13px;
}
.stale-cause {
font-size: 11px;
}
}
+21 -3
View File
@@ -7,7 +7,7 @@ import type { Status } from './api'
import { usePendingConfirm } from './pendingConfirm'
import { bootstrapSession } from './session'
import { ROUTES, navigate, useRoute } from './router'
import { engineState, protectionState } from './planeState'
import { engineState, protectionState, serviceIntent } from './planeState'
import { truncationNote } from './findings'
import type { Route } from './router'
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
@@ -270,7 +270,9 @@ function Page({
if (route === 'dns') return <DNS />
if (route === 'targets') return <Targets />
if (route === 'devices') return <Devices />
if (route === 'insights') return <Insights />
// Insights takes `status` for ONE reason: so it can say that its ten empty
// sections are empty because the service is off. It polls its own stats.
if (route === 'insights') return <Insights status={status} />
if (route === 'settings') return <Settings />
if (route === 'profiles') return <Profiles />
if (route === 'apply') return <Apply />
@@ -328,13 +330,29 @@ function StatusBar({ status }: { status: Status | null }) {
* engine that had failed to start. It now asks {@link engineState}, whose whole
* job is to be able to answer "down", and refuses to guess when nothing has been
* reported: an unlit socket, not a green light.
*
* AND IT ASKS WHETHER THE ENGINE WAS SUPPOSED TO BE RUNNING. `engineState` alone
* cannot tell a crashed engine from one nobody started: `Status.running` is the
* DAEMON's liveness and `engine_running` is the ENGINE's, and neither is the
* operator's switch. So the lamp above every page on a correctly installed,
* not-yet-configured router was crit "Engine down" — the product's most visible
* lamp reporting a failure that had not happened. {@link serviceIntent} is the
* missing question, and only a positive `off` takes this branch: with the
* configuration unreadable the intent is `unknown` and the crit stands, because
* that is the case where the LAN really has been cut off.
*/
function masterIndicator(
phase: Phase,
status: Status | null,
): { label: string; variant: LedVariant; pulse?: boolean } {
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
switch (engineState(status)) {
const engine = engineState(status)
if (engine === 'down' && serviceIntent(status) === 'off') {
// Named for the act, not the symptom: "Switched off" says a person did this
// and can undo it, where "Engine down" says the appliance broke.
return { label: 'Switched off', variant: 'off' }
}
switch (engine) {
case 'down':
return { label: 'Engine down', variant: 'crit' }
case 'up':
+181
View File
@@ -0,0 +1,181 @@
// Editing an alert channel — and, above all, editing a secret the panel refuses
// to show.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// What these protect:
//
// 1. AN EMPTY TOKEN BOX KEEPS THE STORED TOKEN. The row masks the bot token
// deliberately, so it cannot be prefilled; an empty box that meant "clear it"
// would destroy a secret on every save of an unrelated field, and the only
// way to get it back is BotFather. This is the project's rule — a save may
// clear only a field the editor was able to SHOW — applied to the one field
// that can never be shown.
// 2. A TYPED TOKEN STILL REPLACES. Otherwise the fix for a typo'd token is no
// fix at all.
// 3. THE OTHER TYPE'S SETTINGS SURVIVE A TYPE SWITCH, for the same reason: the
// form in Telegram mode renders no URL box, so it may not clear a URL.
// alert/notifier.go's buildPayload reads only the selected type's fields, so
// carrying them costs nothing and switching back costs nothing either.
// 4. VALIDATION KNOWS THE DIFFERENCE between "no token was given" and "no token
// exists". The add form's rule ("Telegram needs a bot token") is one an edit
// can never satisfy, and that is exactly why they now share a validator.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Alert } from './api'
import {
buildAlert,
draftFromAlert,
hasStoredToken,
validateAlertDraft,
TOKEN_KEEP_HINT,
} from './alertEdit.ts'
import type { AlertDraft } from './alertEdit.ts'
const tg: Alert = {
Name: 'tg',
Enabled: true,
Type: 'telegram',
Token: '123456:REAL-SECRET',
ChatID: '-1001',
Events: ['killswitch'],
}
const hook: Alert = {
Name: 'hook',
Enabled: false,
Type: 'webhook',
URL: 'https://hooks.example.com/abc?key=xyz',
Events: ['apply_fail'],
}
const draft = (over: Partial<AlertDraft> = {}): AlertDraft => ({
name: 'tg',
type: 'telegram',
token: '',
chatId: '-1001',
url: '',
events: ['killswitch'],
via: 'direct',
fallback: false,
...over,
})
// --- 1 + 2. the secret -------------------------------------------------------
test('an edit form starts with the token box EMPTY, never prefilled', () => {
const d = draftFromAlert(tg, 'direct')
assert.equal(d.token, '', 'the token must not be handed back to the browser')
// Everything the form CAN show is prefilled, so an edit is an edit and not a
// re-entry exercise.
assert.equal(d.chatId, '-1001')
assert.deepEqual(d.events, ['killswitch'])
})
test('saving with an empty token box KEEPS the stored token', () => {
// The whole point: fix the chat ID without going back to BotFather.
const next = buildAlert(tg, draft({ chatId: '-1002' }))
assert.equal(next.Token, '123456:REAL-SECRET', 'an unshown secret must never be cleared by a save')
assert.equal(next.ChatID, '-1002')
})
test('a typed token replaces the stored one, trimmed', () => {
const next = buildAlert(tg, draft({ token: ' 999:NEW-SECRET ' }))
assert.equal(next.Token, '999:NEW-SECRET')
})
test('the interface SAYS that empty means keep — it is not left to be inferred', () => {
assert.equal(hasStoredToken(tg), true)
assert.equal(hasStoredToken(hook), false)
assert.equal(hasStoredToken(null), false)
assert.match(TOKEN_KEEP_HINT, /leave this empty to keep/i)
})
// --- 3. a type switch is not a delete ----------------------------------------
test('switching telegram → webhook keeps the token and chat ID stored', () => {
const next = buildAlert(
tg,
draft({ type: 'webhook', url: 'https://hooks.example.com/x', token: '' }),
)
assert.equal(next.Type, 'webhook')
assert.equal(next.URL, 'https://hooks.example.com/x')
assert.equal(next.Token, '123456:REAL-SECRET', 'the form showed no token box — it may not clear one')
assert.equal(next.ChatID, '-1001')
})
test('saving a telegram channel does not clear a URL the form never showed', () => {
const both: Alert = { ...tg, URL: 'https://hooks.example.com/keep' }
const next = buildAlert(both, draft({ chatId: '-1003' }))
assert.equal(next.URL, 'https://hooks.example.com/keep')
})
test('a field the form DID show can be emptied — that is the difference', () => {
// In webhook mode the URL box is on screen, so clearing it is a decision the
// operator made and can see. (Validation refuses to save it; the builder's job
// is only to not invent a value.)
const next = buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: '' }))
assert.equal(next.URL, undefined)
})
test('the row switch owns Enabled — an edit never flips it', () => {
assert.equal(buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: hook.URL! })).Enabled, false)
assert.equal(buildAlert(tg, draft()).Enabled, true)
// A NEW channel arrives on.
assert.equal(buildAlert(null, draft({ token: 't' })).Enabled, true)
})
test('delivery: a direct channel carries no Via and no Fallback key at all', () => {
const direct = buildAlert(tg, draft({ via: 'direct', fallback: true }))
assert.equal('Via' in direct, false)
assert.equal('Fallback' in direct, false)
const routed = buildAlert(tg, draft({ via: 'group:eu', fallback: true }))
assert.equal(routed.Via, 'group:eu')
assert.equal(routed.Fallback, true)
// Turning the detour back off drops both — a stale Fallback on a direct channel
// would describe a retry path that does not exist.
const back = buildAlert(routed, draft({ via: 'direct', fallback: false }))
assert.equal('Via' in back, false)
assert.equal('Fallback' in back, false)
})
// --- 4. validation ------------------------------------------------------------
test('an edit with an empty token box passes, because one is already stored', () => {
assert.equal(validateAlertDraft(draft(), new Set(['tg']), tg), null)
})
test('a NEW telegram channel with no token is refused', () => {
const problem = validateAlertDraft(draft({ name: 'fresh', token: '' }), new Set(), null)
assert.notEqual(problem, null)
assert.match(problem!, /bot token/i)
})
test('converting a webhook to telegram demands a token — there is none to keep', () => {
const problem = validateAlertDraft(
draft({ name: 'hook', type: 'telegram', token: '', chatId: '-1' }),
new Set(['hook']),
hook,
)
assert.notEqual(problem, null)
assert.match(problem!, /none is stored/i)
})
test('renaming to its own name is allowed; colliding with another is not', () => {
const taken = new Set(['tg', 'other'])
assert.equal(validateAlertDraft(draft({ name: 'tg' }), taken, tg), null)
const problem = validateAlertDraft(draft({ name: 'other' }), taken, tg)
assert.match(problem!, /already exists/i)
})
test('the remaining shape rules still bite', () => {
assert.match(validateAlertDraft(draft({ name: ' ' }), new Set(), tg)!, /name/i)
assert.match(validateAlertDraft(draft({ chatId: '' }), new Set(), tg)!, /chat ID/i)
assert.match(validateAlertDraft(draft({ events: [] }), new Set(), tg)!, /event/i)
assert.match(
validateAlertDraft(draft({ type: 'webhook', url: 'hooks.example.com' }), new Set(), tg)!,
/http/i,
)
})
+143
View File
@@ -0,0 +1,143 @@
import type { Alert } from './api'
/**
* Editing an alert channel that already exists — and, in particular, editing a
* secret the panel refuses to show.
*
* WHY THIS MODULE EXISTS. Alerts could be created and toggled, and their delivery
* path (Via/Fallback) changed, but Type / Token / ChatID / URL / Events were
* write-once: the only way to fix a typo in a chat ID was to delete the channel
* and build it again. For the bot token that is worse than tedious — the row
* masks it deliberately (`secret hidden`), so "re-enter it" means going back to
* BotFather for a token you already own.
*
* THE RULE THIS FILE ENCODES. A save may clear only a field the editor was able
* to SHOW. The token is never shown, so an empty token box cannot mean "erase the
* stored token" — it means "keep it". The same reasoning covers the fields of the
* OTHER channel type: while the form is in Telegram mode it shows no URL box, so
* a save in Telegram mode leaves a stored URL alone (and vice versa). That is
* also why switching type is non-destructive — the settings of the other kind sit
* there unused until you switch back. Nothing reads them meanwhile:
* alert/notifier.go `buildPayload` switches on Type and touches only that type's
* fields.
*
* It lives outside `pages/Alerts.tsx` because it is the part that must be TESTED,
* and the panel's runner is `node --test src/*.test.ts`: plain modules only, no
* JSX, no DOM (same reason as ruleset.ts and egressEdit.ts).
*/
export type AlertType = 'telegram' | 'webhook'
/** The live fields of either alert form (add or edit). */
export interface AlertDraft {
name: string
type: AlertType
/** Telegram bot token. EMPTY MEANS "keep whatever is stored" — never "clear it". */
token: string
chatId: string
url: string
events: string[]
/** Canonical delivery value from the picker; 'direct' (or '') ⇒ no detour. */
via: string
fallback: boolean
}
const HTTP_RE = /^https?:\/\//i
/** Does this channel already hold a bot token? Drives which hint the form shows. */
export function hasStoredToken(a: Alert | null): boolean {
return !!(a?.Token ?? '').trim()
}
/** Prefill an edit form from a stored channel. The token box always starts EMPTY. */
export function draftFromAlert(a: Alert, via: string): AlertDraft {
return {
name: a.Name,
type: a.Type === 'webhook' ? 'webhook' : 'telegram',
token: '',
chatId: a.ChatID ?? '',
url: a.URL ?? '',
events: [...(a.Events ?? [])],
via,
fallback: a.Fallback ?? false,
}
}
/**
* What the form is allowed to submit, or the message to show instead.
*
* `stored` is the channel being edited (null when adding). It is what makes the
* token rule work in both directions: an edit passes with an empty token box
* because one is already stored, and a NEW Telegram channel — or one being
* converted from a webhook, which has no token — still has to be given one.
*/
export function validateAlertDraft(
d: AlertDraft,
taken: ReadonlySet<string>,
stored: Alert | null,
): string | null {
const nm = d.name.trim()
if (!nm) return 'Give the alert a name.'
if (nm !== (stored?.Name ?? '') && taken.has(nm)) return `An alert named “${nm}” already exists.`
if (d.type === 'telegram') {
if (!d.token.trim() && !hasStoredToken(stored)) {
return 'Telegram needs a bot token — none is stored for this alert yet.'
}
if (!d.chatId.trim()) return 'Telegram needs a chat ID.'
} else if (!HTTP_RE.test(d.url.trim())) {
return 'Enter an http(s):// webhook URL.'
}
if (d.events.length === 0) return 'Pick at least one event to notify on.'
return null
}
/**
* Build the channel a save writes.
*
* The stored channel is spread in first, so anything this form does not model
* survives untouched — the same idiom the rule editor uses for Order/Enabled/Kill.
* Enabled is deliberately taken from storage too: the row's own switch owns it,
* and an edit form that has no switch must not decide it.
*/
export function buildAlert(stored: Alert | null, d: AlertDraft): Alert {
const typed = d.token.trim()
// Telegram fields: the token box, when filled, replaces; when empty it keeps.
// In webhook mode the box is not rendered at all, so it can never speak here.
const token = d.type === 'telegram' && typed ? typed : (stored?.Token ?? '')
const chatId = d.type === 'telegram' ? d.chatId.trim() : (stored?.ChatID ?? '')
const url = d.type === 'webhook' ? d.url.trim() : (stored?.URL ?? '')
const routed = d.via !== '' && d.via !== 'direct'
const out: Alert = {
...(stored ?? ({} as Alert)),
Name: d.name.trim(),
Enabled: stored ? stored.Enabled : true,
Type: d.type,
Events: [...d.events],
}
// Written when non-empty, dropped when the operator emptied a field the form
// actually showed (a webhook URL in webhook mode, a chat ID in Telegram mode).
// `delete` rather than `''` so the PUT body stays the shape the add form sends.
if (token) out.Token = token
else delete out.Token
if (chatId) out.ChatID = chatId
else delete out.ChatID
if (url) out.URL = url
else delete out.URL
if (routed) out.Via = d.via
else delete out.Via
if (routed && d.fallback) out.Fallback = true
else delete out.Fallback
return out
}
/** Said under the token box when one is already stored. */
export const TOKEN_KEEP_HINT =
'A bot token is stored. Leave this empty to keep it — type a new one only to replace it. It is never shown back.'
/** Said under the token box when there is nothing to keep. */
export const TOKEN_NEW_HINT = 'Stored secretly and never shown again — you can replace it later.'
/** Said when an edit switches an existing channel to the other type. */
export const TYPE_SWITCH_NOTE =
'The other type’s settings stay stored and unused, so you can switch back without entering them again.'
+527 -29
View File
@@ -164,6 +164,81 @@ export interface Status {
can_rollback?: boolean // a rollback would revert something (armed snapshot or engine last-good)
// Is the sing-box engine process actually up? Absent on older daemons.
engine_running?: boolean
/**
* IS THE CONFIGURATION ON DISK THE ONE THAT IS RUNNING? (apply.Status.ConfigApplied)
*
* `false` means /etc/config/shater was READ and then REFUSED, and what the
* engine carries is a DIFFERENT, EARLIER configuration. It is a question no
* neighbouring field answers, and every one of them can sit at its healthiest
* value while this is false:
*
* `running` / `engine_running` — TRUE, and honestly so. A previous config is
* running. Do not contradict this; say WHICH configuration is running.
* `hash` — a real hash of a real running config: the OLDER one. Drawn
* unqualified it is the single most misleading value on the page.
* `traffic` — where traffic goes under that older config.
* `warnings` — from the last SUCCESSFUL apply, so they describe the older
* config too. The refusal itself arrives as a separate critical warning
* (section "config", name "rejected").
* `plane` — "full", because the kernel side of the older config is installed.
*
* ABSENT IS A THIRD STATE and the panel reads it as one — see
* appliedConfig.appliedState. `false` is a MEASURED refusal and gets the alarm;
* absence is not a measurement at all. A LIVE daemon always publishes one of
* the two verdicts, so absence can only come from an object that is not a live
* status: the offline stub `shaterd status` prints when no daemon answers —
* built by a process that never applied anything, over a data plane that may
* have been running for weeks — or a daemon predating the field. Neither is
* evidence that the hash on this page is stale, and stamping "your edit is not
* in effect" on no evidence is the same class of lie as hiding it when it is
* true.
*
* This panel never receives the offline stub: the daemon itself serves this
* response, and a dead daemon answers nothing at all. The field is a pointer
* with `omitempty` on the Go side (apply.Status.ConfigApplied) exactly so that
* the stub cannot raise the alarm as the zero value of a bool — read
* Status.Applied() there rather than dereferencing.
*/
config_applied?: boolean
/** Why the configuration on disk was refused, verbatim from the daemon. ''/absent
* when nothing was refused. Present WITH {@link Status.apply_error_stage}: the
* daemon never records a refusal without a cause (apply.noteRejected returns
* early on a nil cause rather than publish the alarming half alone). */
apply_error?: string
/**
* WHICH STEP refused it — a CLOSED set of three phrases, written as sentences an
* operator can act on rather than as codes (apply.go stageSchemaGate /
* stageGenerate / stageEngine):
*
* "the schema gate" — the config did not pass validation.
* "building the engine configuration" — it validated and could not be
* turned into an engine config.
* "starting the engine" — it built and the engine refused to
* come up on it.
*
* ''/absent means the daemon named no step. Read it through
* appliedConfig.rejectedReading, which keeps the list closed: an unrecognised
* phrase is shown VERBATIM and labelled as unrecognised, never dropped and
* never folded into one of the three.
*/
apply_error_stage?: string
/**
* How many times THIS SAME configuration has been tried, and when it was first
* refused (router clock, seconds).
*
* They exist because the state does not otherwise say whether it is a blip or a
* standing condition: the retry is on a widening interval (three free, then
* doubling to a 15-minute ceiling), and any edit to the configuration cancels
* the wait. A consumer that showed only "refused" could not tell an operator
* whether anything is still trying.
*
* `apply_failed_since_unix` is the ROUTER's clock and the router has no RTC, so
* it may sit far from the browser's. Render it as a router-clock time — never as
* a browser-computed "failing for N minutes", which would be fiction. The
* attempt COUNT is the honest measure of persistence.
*/
apply_attempts?: number
apply_failed_since_unix?: number
// How much of the data plane is installed. Absent on older daemons ⇒ unknown,
// in which case the UI shows nothing rather than guessing "full".
plane?: Plane
@@ -186,12 +261,6 @@ export interface Status {
// Absent on older daemons ⇒ show nothing rather than guessing.
started_unix?: number
uptime_seconds?: number
// Is the `ciadpi` binary (optional `byedpi` package) present on the router?
// A byedpi egress without it is dead (fail-closed), so the Targets editor
// refuses to create NEW byedpi egresses when this is false. Absent on older
// daemons ⇒ unknown, in which case the UI does NOT gate (never lock an
// operator out of a control on a guess).
byedpi_installed?: boolean
}
/** POST /api/apply|confirm|rollback — mirrors the control socket result. */
@@ -206,12 +275,90 @@ export interface QueryLogEntry {
unix: number
domain: string
qtype: string // A | AAAA | HTTPS | ...
/**
* The DNS response code. Worth reading on a FAILED row, where it separates the
* two shapes of failure: -1 means the server never answered at all (a timeout,
* a network error, a loopback), any other value means it answered with exactly
* that code (2 SERVFAIL, 5 REFUSED).
*/
rcode: number
blocked: boolean // blocked by the DNS filter — NOT set for failed lookups or an upstream's organic NXDOMAIN
server: string
/**
* WHICH PATH the lookup took — block | proxy | pass. It is NOT an outcome:
* a query that left through a detour and then timed out is `proxy` here and
* `failed` in {@link status}. Read the two together, never one for the other.
*/
action: string // block | proxy | pass
device: string // source of the lookup: a LAN device IP/hostname, or "router" for the appliance's own resolutions (urltest probes / sub fetches). "" on older data.
seq: number // monotonic cursor, newest = highest value; used for append/live pagination
/**
* THE OUTCOME OF THE RESOLUTION — did it produce an answer at all? A CLOSED set
* of three, and the empty string is one of them (stats.LogEntry.Status):
*
* answered — an answer was produced: by the resolver, from the cache, or
* synthesized by the DNS filter when it blocked. It does NOT claim
* the answer was useful — an upstream's organic NXDOMAIN is
* `answered` too, and `blocked`/`action` say which case this is.
* failed — NO usable answer: a timeout or network error, a resolver refusal
* (SERVFAIL or the response checker), a loopback, or a cached
* rejection. The same fact `totals.failed` counts. A failed row is
* never `blocked` — the filter answering is not a failure.
* '' — NOT RECORDED. An older row out of the persistent store, or a
* producer this build does not recognise. It says NOTHING about
* the outcome and must never be drawn as answered, as failed, or
* as healthy.
*
* A value outside those three is `''` — see logRoute.dnsRowMark, the only place
* the panel is allowed to decide this.
*
* It is a SEPARATE AXIS from {@link action}, not a fourth action word, because
* the two answer different questions and a failure has an answer to both.
* Folding them would erase the one reading that matters most: `action=proxy`
* with `status=failed` is the tunnel breaking, and it used to render as the
* healthiest-looking row in the whole log.
*/
status: string // answered | failed | '' (not recorded)
/**
* The failure cause EXACTLY as the resolver reported it, absent when empty.
* Meaningful ONLY when `status === 'failed'`.
*
* There is no guaranteed structure — real values are the literals `loopback`,
* `rejected (cached)`, `rejected`, or the transport's own text such as
* `dial udp 1.1.1.1:53: i/o timeout` — so it is shown verbatim and never parsed
* into categories. Clamped by the daemon to 160 bytes; a clamped value ends in
* an ellipsis so a truncated cause can never read as the whole one.
*
* `failed` WITHOUT a cause is honest and reachable: the lookup failed and the
* cause was not recorded. It is drawn as exactly that, never as silence.
*/
error?: string
/**
* WHERE THIS LOOKUP WENT OUT — read this BEFORE `outbound`. A CLOSED set of
* four, and the empty string is one of them (stats.LogEntry.OutboundKind):
*
* detour — the resolver that answered is bound to a detour, and `outbound`
* names the tag its packets took.
* default — the resolver names NO detour, so the lookup left over the
* router's own WAN, outside the tunnel. `outbound` is '' because
* there is no tag to name. This is a RECORDED fact and, on a box
* configured for DNS anti-leak, the interesting one.
* local — no exchange happened at all: a cache hit, an optimistic answer,
* or a filter block. Nothing egressed. (`server`, when set, is the
* resolver whose cached answer was reused — not one this query
* talked to.)
* '' — NOT RECORDED. Written by a build that predates the capture, or by
* a producer that set no recognised source. It says NOTHING about
* egress, and must never be drawn as "local" or as "default".
*
* A value outside those four is `''` — see logRoute.dnsOutbound, which is the
* only place the panel is allowed to decide this.
*/
outbound_kind: string // detour | default | local | '' (not recorded)
/** The outbound tag, meaningful ONLY when `outbound_kind === 'detour'`; '' in
* every other case. It is the CONFIGURED binding, not an observed dial: a
* group tag here names the group, not whichever member was live. */
outbound: string
}
/**
@@ -221,6 +368,10 @@ export interface QueryLogEntry {
* (`dest` — a sniffed domain, else the raw IP) on `port`, over `network`
* (tcp|udp), optionally with a sniffed application `proto` (tls|http|quic|…,
* may be ''), and out which `outbound`. Newest first.
*
* `rule_kind`/`rule`/`chain` answer WHY it went there, which is the whole reason
* a "this site does not open" report can be closed from the log instead of by
* reasoning about the config.
*/
export interface ConnLogEntry {
unix: number
@@ -233,6 +384,37 @@ export interface ConnLogEntry {
proto: string // tls | http | quic | … (may be '')
outbound: string
seq: number // monotonic cursor, newest = highest value; used for append/live pagination
/**
* HOW TO READ `rule` AND `chain` — read this first. A CLOSED set of three, and
* the empty string is one of them (stats.ConnLogEntry.RuleKind):
*
* matched — a route rule matched, and `rule` holds its text.
* default — NO route rule matched: the connection took route.Final, the
* default egress. `rule` is '' because there is no rule to name.
* A RECORDED fact, not a missing one.
* '' — NOT RECORDED. The row predates rule capture, or its producer
* never set it. `rule` and `chain` say NOTHING about routing here.
*
* The two empty-`rule` states are therefore different facts and must never be
* drawn the same way. A value outside the three is `''` — see logRoute.connRule,
* the only place the panel is allowed to decide this.
*/
rule_kind: string // matched | default | '' (not recorded)
/**
* The ENGINE's own text form of the rule's MATCH CONDITION — e.g.
* "protocol=tls domain_suffix=youtube.com", "rule_set=rs-youtube". It is NOT
* the rule NAME from /etc/config/shater: nothing survives code generation that
* ties an engine rule back to the model rule, so a name here could only be
* guessed. Meaningful only when `rule_kind === 'matched'`.
*/
rule: string
/**
* The outbound path the connection took, ordered FINAL FIRST: `chain[0]` is the
* outbound that dialled (the node), each following element is the group that
* selected it, out to the tag the rule named. Absent for a single-hop route
* (Go omits an empty slice). It does NOT include the transport detour tail.
*/
chain?: string[]
}
/**
@@ -751,6 +933,62 @@ export interface Globals {
* is in sole charge.
*/
UntunnelableEgress?: string
/**
* Where a `source=geosite` / `source=geoip` list (a {@link Ruleset}, a
* {@link Blocklist} or an {@link Allowlist}) fetches its data from
* (model.Globals.GeoProvider, UCI `geo_provider`; consumed by
* generate.SetGeoProvider).
*
* FIVE values, and the default is not one upstream but a per-category CHOICE:
*
* ''/auto — two-letter country codes from SagerNet, every other geoip
* category from Loyalsoldier; geosite from SagerNet. This is
* byte-for-byte what every existing config already resolves to.
* sagernet — SagerNet only. Its geoip publishes COUNTRY CODES ONLY (238
* two-letter files: no google, no netflix, no telegram), so a
* named geoip category simply does not exist here.
* loyalsoldier — all geoip from Loyalsoldier, country codes included; geosite
* still falls back to SagerNet (Loyalsoldier publishes no .srs
* domain rule-sets).
* metacubex — MetaCubeX/meta-rules-dat (branch `sing`) for both planes.
* custom — the two URL templates below, for a private mirror.
*
* The cost gap is the point, not a detail: `netflix` is ~108 prefixes, `us` is
* ~159 000 (~20 MB of kernel memory) — the same intent at ~1500x the price.
*
* A value outside the five degrades to `auto` with a warning
* (generate.ValidateGeoProviderConfig never errors — the project's fail-open
* rule, since a router that refuses to come up is a LAN that is offline while
* the kill-switch holds closed).
*/
GeoProvider?: string // ''|auto|sagernet|loyalsoldier|metacubex|custom
/**
* The geosite URL template, READ ONLY WHEN GeoProvider === 'custom' — on any
* other provider the value is stored and ignored, so the panel must not present
* it as being in effect.
*
* It must contain the literal `{category}` exactly once; the daemon splices the
* category in there and REFUSES to append it, because silently appending would
* build a plausible-looking wrong URL. Empty, or missing the placeholder, makes
* that ONE SOURCE fall back to the built-in `auto` chain (with a warning) —
* the other source is unaffected.
*/
GeositeURL?: string
/** The geoip URL template. Same `{category}` contract and same custom-only
* scope as {@link GeositeURL}. */
GeoipURL?: string
/**
* A GitHub git-trees API URL whose entries name the published files, used to
* SUGGEST categories in the pickers (GET /api/ruleset/categories → the geosite /
* geoip `<datalist>` on the Routing rule-set form and the DNS blocklist form).
*
* Suggestions only: "" means this collection is not enumerable, which is legal —
* the picker then offers nothing and the field stays free text, exactly as it
* already degrades when the fetch fails. It never gates what you can type.
*/
GeositeIndexURL?: string
/** The geoip category index. Same suggestions-only role as {@link GeositeIndexURL}. */
GeoipIndexURL?: string
DNSFilter?: boolean // master enable for the in-engine blocklist filter
DNSIntercept?: boolean // force ALL LAN plaintext DNS (:53) through the engine, incl. router-addressed queries
BlockDoH?: boolean // block known public DoH resolvers (by host + IP:443 + Firefox canary) so clients fall back to plaintext :53
@@ -837,6 +1075,18 @@ export interface Allowlist {
// For source=geosite: one or more sing-geosite categories (see
// Blocklist.Categories). null/empty for every other source.
Categories?: string[] | null
/**
* How often a url/geosite allowlist is re-fetched. Same field and same default
* as {@link Blocklist.UpdateInterval} ("" ⇒ the daemon's 24h); ignored for
* inline/file, which are read straight out of the config.
*
* It is here because the asymmetry had a failure mode, not for symmetry's sake
* (model.go Allowlist.UpdateInterval says so too): an allowlist is HOW a
* blocklist's false positive gets corrected, so a remote allowlist stuck on 24h
* while the blocklist that broke the site refreshes faster delivers the fix up
* to a day after the breakage.
*/
UpdateInterval?: string
}
export interface Node {
@@ -858,8 +1108,31 @@ export interface Subscription {
Enabled: boolean
URL: string
UpdateInterval?: string
/**
* `direct` or `proxy`. ON ITS OWN, `proxy` NAMES NO ROUTE — see FetchDetour.
*/
FetchVia?: string // direct|proxy
FetchDetour?: string // detour when FetchVia=proxy: ''|direct | group:<n> | node:<n> | egress:<n>
/**
* WHICH route a `FetchVia=proxy` fetch takes:
* `''` | `direct` | `group:<n>` | `chain:<n>` | `node:<n>` | `egress:<n>`.
*
* EMPTY IS NOT "no preference". `engine.ViaToTag('')` returns the tag `direct`
* (engine/httpclient.go), so `proxy` with no detour pulls the feed over the
* plain WAN — dialled from inside the daemon process, so the update succeeds
* and looks identical, while the provider logs this router's real address. That
* address is the single thing `FetchVia=proxy` is chosen to hide, which makes
* the pair the setting and `FetchVia` alone a statement of intent. The panel
* reads this through `subEdit.proxyFetchGoesDirect` and must never draw the
* combination as protection.
*
* `chain:<n>` is real and resolved by apply.resolveVia, which rewrites the name
* to the running box's `chain-<n>-hN` entry tag. Its caveat: chains are built
* LAZILY, only for a chain some enabled rule/egress/DNS detour references, and a
* fetch detour is NOT such a reference — so a chain nothing else uses has no
* outbound and the fetch is refused BY NAME (ErrOutboundUnknown ⇒ 400), never
* quietly sent direct.
*/
FetchDetour?: string
UA?: string
HWID?: string // auto|<fixed>
DeviceOS?: string
@@ -1068,20 +1341,25 @@ export interface Inbound {
/**
* A `config egress` — a named way OUT of the router (model.go Egress).
*
* Exactly THREE types produce an outbound, and a type outside them emits nothing,
* Exactly TWO types produce an outbound, and a type outside them emits nothing,
* which is fail-CLOSED: every binding to it is blocked rather than quietly sent
* over the plain WAN.
*
* interface — a direct outbound bound to that device + the egress routing mark.
* direct — a plain direct outbound; its purpose is to carry a native DPI
* preset (see DPI) on the rules routed to it.
* byedpi — a SOCKS5 outbound to the local ciadpi desync proxy on
* 127.0.0.1:Port.
*
* `proxy` and `block` were removed: neither ever emitted an outbound, so every
* reference to them dangled and that traffic left over the plain WAN with the real
* address. Route to a group/node/chain for the former, and to the `block` TARGET
* for the latter.
*
* A third type was RETIRED — it worked, and then the defect it was compensating
* for got fixed, so it became weight. That spelling still arrives here from an
* un-migrated /etc/config/shater: it emits no outbound, so it is fail-closed like
* any other unbuilt type, and the editor names it by hand instead of calling it
* unrecognised. The type and its sentence live in ONE place, egressEdit.ts
* RETIRED_EGRESS_TYPES; do not copy either here.
*/
/*
* THERE IS DELIBERATELY NO `Target`. It was declared here as "legacy field of the
@@ -1095,12 +1373,19 @@ export interface Inbound {
* rejected the WHOLE write with `json: unknown field "Target"` (verified against
* the running daemon), losing an unrelated edit somewhere else on the page.
*/
/*
* THERE IS DELIBERATELY NO `Port` EITHER, for the same reason. It was the local
* desync proxy's listen port, and no other type ever dialled anything; the Go
* model dropped the field along with the type. Leaving it typed here would keep a
* field the editor could still write, and PUT /api/config decodes with
* DisallowUnknownFields — the daemon would reject the WHOLE write with
* `json: unknown field "Port"`, losing whatever else was being saved with it.
*/
export interface Egress {
Name: string
Type: string // interface|direct|byedpi
Type: string // interface|direct
Interface?: string // type=interface: the UCI interface name
Port?: number // type=byedpi ONLY: the local ciadpi listen port (default 1080)
DPI?: string // type=interface|direct: off|fragment|record|spoof (byedpi desyncs itself)
DPI?: string // type=interface|direct: off|fragment|record|spoof
}
export interface Rule {
@@ -1172,7 +1457,31 @@ export interface DNSRule {
Resolver: string
}
/** Per-device policy (a `config device`). Identity is MAC-first, else IP. */
/**
* Per-device policy (a `config device`). Identity is MAC-first, else IP.
*
* FOUR list slots, and the order the engine applies them is not the order they
* are declared in — it is:
*
* 1. Allow — allow domains typed on this device
* 2. Block — block domains typed on this device
* 3. Allowlists — allow lists attached to this device
* 4. Blocklists — block lists attached to this device
* 5. the network-wide DNS filter
*
* i.e. typed beats attached, and within a tier allow beats block. Without that
* order a parent who types `youtube.com` into a child's Block loses to whatever
* a large attached geosite category permits, and the panel would draw a chip
* saying "blocked" over a rule that does nothing.
*
* `Blocklists`/`Allowlists` hold the NAMES of `config blocklist` / `config
* allowlist` sections. Attaching one is itself the switch for this device: a list
* with `Enabled=0` still runs here, because that flag means "participates in the
* network-wide filter", not "this object is dead". An attached ALLOW list is
* terminal, so it also lifts the network blocklists off everything it covers.
* The reply for a blocked name comes from the list (`Blocklist.Response`), never
* from the device.
*/
export interface Device {
Name: string
MAC?: string
@@ -1180,6 +1489,8 @@ export interface Device {
Enabled: boolean
Block?: string[] | null // domains blocked for this device only
Allow?: string[] | null // domains allowed for this device (overrides blocklists)
Blocklists?: string[] | null // names of `config blocklist` sections attached here
Allowlists?: string[] | null // names of `config allowlist` sections attached here
}
/**
@@ -1487,6 +1798,35 @@ export interface StatsLogQuery {
limit?: number
before?: number
after?: number
/**
* Case-insensitive SUBSTRING filter, applied by the DAEMON on the same walk as
* the cursor (so `limit` counts MATCHING rows, not scanned ones). The searched
* fields are a POSITIVE, CLOSED list — query log: domain, qtype, server,
* action, device, outbound, error; connection log: src_ip, src_name, dest,
* dest_ip, network, proto, outbound, rule, chain hops. Ports, sequence numbers,
* timestamps and the two vocabulary words (`rule_kind`, `outbound_kind`) are
* deliberately NOT searched: `q=default` matching every default-egress row
* would look like a filter while being a trap.
*
* `error` is in the query-log list because the failure cause is how failures
* are found at all — `q=timeout` is the search somebody actually runs, and
* stats/filter.go matchLog has always answered it. This list said otherwise,
* and the SAME sentence in the daemon said otherwise too until it was fixed
* there; the copy here was missed. Mirrored in logRoute.logSearchFields, which
* is what the `?mock` backend searches — the three must not drift again.
*
* The FOLD is ASCII-only, on both sides, exactly as stats/filter.go does it
* (normalizeFilter/lowerASCII lower the needle, matchAtFold lowers the
* haystack byte). Non-ASCII therefore compares byte for byte: an upper-case
* non-ASCII needle does NOT find its lower-case form, which is what the
* daemon's own non-ASCII case in stats/logfilter_test.go TestContainsFold
* asserts. The panel mirrors it in logRoute.rowMatches.
*
* Empty/whitespace is dropped rather than sent — a `q=` that matches every row
* is not a filter, and the daemon would then charge the request a scan budget
* for nothing. See {@link StatsLogPage.truncated} for what that budget does.
*/
q?: string
}
function statsLogQS(q: StatsLogQuery): string {
@@ -1494,6 +1834,8 @@ function statsLogQS(q: StatsLogQuery): string {
if (q.limit != null) p.set('limit', String(q.limit))
if (q.before != null) p.set('before', String(q.before))
if (q.after != null) p.set('after', String(q.after))
const text = q.q?.trim() ?? ''
if (text) p.set('q', text)
const s = p.toString()
return s ? `?${s}` : ''
}
@@ -1518,20 +1860,56 @@ export interface StatsLogPage<T> {
rows: T[]
pending: number // rows still newer than rows[0]; 0 when there is no backlog
more: boolean // true ⇒ call again with after = rows[0].seq
/**
* THE SCAN STOPPED BEFORE THE DATA DID — a short page that is NOT the end.
*
* A filtered walk has to examine rows it will not return, so the daemon bounds
* it (stats.MaxFilterScan). When that bound is what ended the walk, the page is
* short for a reason that has nothing to do with how much data exists, and a
* client that renders it as "end of log" shows a partial answer as a complete
* one. That is the single lie this flag prevents; whatever reads it must NAME
* the state and offer {@link cursor} to continue.
*
* Unfiltered requests have no budget and are never truncated, so `false` here
* is the ordinary case and genuinely means "the data ran out".
*/
truncated: boolean
/**
* The resume point: the seq of the LAST ROW THE WALK EXAMINED, returned or not.
* Always the right value to page from — history continues with `before=cursor`,
* a live tail with `after=cursor` — and on a complete page it simply equals the
* seq of the last row in `rows`. 0 ⇒ the walk examined nothing at all.
*
* It is what makes a truncated page recoverable: with zero matching rows there
* is no row seq to page from, and only this cursor can carry the search on.
*/
cursor: number
}
const PENDING_HEADER = 'X-Stats-Log-Pending'
const MORE_HEADER = 'X-Stats-Log-More'
const TRUNCATED_HEADER = 'X-Stats-Log-Truncated'
const CURSOR_HEADER = 'X-Stats-Log-Cursor'
/** Read the two cursor headers off a log response, defaulting safely when an
* older daemon doesn't send them (no headers ⇒ no backlog ⇒ single page). */
/**
* Read the page-state headers off a log response.
*
* Absent headers default to the quiet side (no backlog, not truncated, no
* cursor). That is safe HERE and would not be in general: the SPA is embedded in
* the daemon binary, so the build that serves these headers is the build that
* shipped this file — there is no old-daemon/new-panel skew to be honest about.
* The only header-less responses in practice are `?mock` and a dev proxy.
*/
function logPage<T>(env: { body: T[] | null; headers: Headers }): StatsLogPage<T> {
const rows = env.body ?? []
const pendingRaw = Number.parseInt(env.headers.get(PENDING_HEADER) ?? '', 10)
const cursorRaw = Number.parseInt(env.headers.get(CURSOR_HEADER) ?? '', 10)
return {
rows,
pending: Number.isFinite(pendingRaw) && pendingRaw > 0 ? pendingRaw : 0,
more: env.headers.get(MORE_HEADER) === 'true',
truncated: env.headers.get(TRUNCATED_HEADER) === 'true',
cursor: Number.isFinite(cursorRaw) && cursorRaw > 0 ? cursorRaw : 0,
}
}
@@ -1805,14 +2183,73 @@ export function getGroupsHealth(
* does not exist, which is a different thing and a different fix.
*/
export interface GroupTestResult {
group: string // group name — or a chain name for a chain row
group: string // group name — or a chain, or a NODE name (see `kind`)
selected: string // the member node the group (or the chain's exit group) chose
/**
* WHAT WAS MEASURED — a closed set of four, and `''` is one of them:
* `group` | `chain` | `node` | `''`.
*
* `group` carries a bare name and a group, a chain and a node can share one,
* so this is what says which card a result belongs to. `''` means the running
* box resolved the name to NOTHING, so what it would have been is exactly what
* could not be determined — never guess "group" there. A run started with
* `kind:'node'` always answers `node`, even when the name resolves to nothing,
* because that kind came from the request rather than from a resolution.
*
* Absent on daemons older than the field (additive).
*/
kind?: string // group | chain | node | ''
delay_ms: number
exit_ip: string // may be '' even when ok
exit_country: string // ISO code; may be '' even when ok
ok: boolean
error: string // '' when ok
tested_unix: number
/**
* WHICH INSTRUMENT PRODUCED `ok`/`delay_ms` — a closed set of three, and this
* is the most load-bearing field on the shape:
*
* observatory — the background prober measured the target's own rule-routed
* dial path and this run reported that observation.
* on-demand — THIS run measured it, once, right now, over the target's own
* outbound. Only a NODE no enabled rule routes through gets
* here: there is no background measurement to wait for.
* '' — NOTHING WAS MEASURED. Every refusal and every "could not
* check" carries it, and the `ok:false` sitting beside it is
* "not checked", NOT "dead".
*
* A measuring instrument whose negative result is indistinguishable from a
* check that never ran is useless, so the panel must draw those two
* differently — see testResult.ts, the only place allowed to decide it.
*
* Absent on daemons older than the field, where the error prose is the only
* instrument there is (testResult.ts falls back to it, and only then).
*/
source?: string // observatory | on-demand | ''
/**
* THE 1-BASED CHAIN HOP THAT STOPPED THIS ONE. `0` — the value every other
* result carries — means "not about this".
*
* The prober walks a chain in order and stops at the first hop that does not
* answer, so the chain's exit is never dialled and there is no end-to-end
* measurement. The daemon therefore files the row with `source:''`, which a
* client reading `source` strictly would put in the "nobody looked" bucket —
* the wrong colour, because a probe DID run, at the hop, and it failed.
*
* This is the field that says so. It deliberately does NOT set `source`:
* nothing measured THIS target's own path, and stamping an instrument on a
* measurement that never happened is the exact lie `source` was added to
* prevent. `blocked_by > 0` beside `source:''` is the whole honest statement —
* not measured here, here is why, and here is where to go and look.
*
* READ THE NUMBER, NOT THE MESSAGE. The panel used to keep such a row loud by
* matching a fragment of the daemon's error sentence; that was the last place
* it decided anything from prose. A message is for a person, a field is for a
* program, and the two must not be the same string.
*
* Absent on daemons older than the field (additive) ⇒ read as 0.
*/
blocked_by?: number
}
/**
@@ -1824,14 +2261,38 @@ export interface GroupTestResult {
* "Running" means the observatory is working through an out-of-turn refresh pass
* over the named targets and this endpoint is collecting what it measures. It is
* not the panel dialling anything.
*
* ONE BOARD, AND A RUN NO LONGER WIPES IT. Starting a run used to replace
* `results` outright, so pressing Test on one NODE blanked every group and chain
* card and nothing on screen explained why — the daemon had simply thrown true
* measurements away. Now the rows a run will not itself re-measure are CARRIED
* FORWARD (engine.startTestRun), newest first, capped at 64 with the oldest
* evicted. A target is superseded by name AND kind, so testing the node `nl-1`
* does not drop the group `nl-1`.
*
* So `results` is THE LATEST KNOWN READING PER TARGET, not "the readings of this
* run", and the two kinds of row must not be drawn alike:
*
* row.group ∈ scope → this run covers it; done/total describe its progress;
* row.group ∉ scope → an earlier run measured it, at row.tested_unix.
*
* A carried row can be twenty minutes old. Telling them apart is `scope`, not a
* comparison of timestamps — see testResult.rowOrigin, the only place the panel
* is allowed to decide it. A MISSING row is still "no reading right now" and
* never a verdict.
*/
export interface GroupTestStatus {
running: boolean
done: number
total: number
/**
* The group and chain names THIS run covers. Always an array (never JSON
* null); absent only on daemons older than the split.
* The target names THIS run covers — groups and chains, or the single NODE
* name of a `kind:'node'` run. Always an array (never JSON null); absent only
* on daemons older than the split.
*
* A name alone does not say WHICH KIND of card it belongs to (a group and a
* node may share one), so a page that shows both reads `results[].kind` for
* attribution and uses `scope` only for "is this run about me".
*
* It is what makes `running` usable. On its own that flag says only "a group
* test is happening somewhere", which is why pressing Test on one group used to
@@ -1840,7 +2301,13 @@ export interface GroupTestStatus {
* exactly that name; a run started with no name carries every group and
* every chain, and then the indicator on every card is correct. The scope
* PERSISTS after the run ends, so displayed results stay attributable to the
* cards they came from.
* cards they came from — which is also what tells a CARRIED row (measured by
* an earlier run, kept on the board) from one this run produced.
*
* An EMPTY array is never a real answer: a run always covers at least one
* target. It means the daemon published none, exactly as an absent field does,
* and both must read as "cannot attribute" rather than as "everything here is
* carried".
*/
scope?: string[]
results: GroupTestResult[]
@@ -1855,22 +2322,53 @@ export interface GroupTestStart {
reason?: string
}
/**
* Which resolver POST /api/groups/test sends the name through — a CLOSED list of
* exactly two, and anything else is a 400 (never a silent fall-through to the
* default one).
*
* '' — resolve the name as a group, then as a chain. An empty name then
* means "every group and every chain". Older panels sent no `kind`
* at all and this is byte-for-byte what they got.
* 'node' — resolve the name as a single NODE, and a name is REQUIRED: there is
* deliberately no "test every node" sweep.
*/
export type TestKind = '' | 'node'
/**
* POST /api/groups/test — ask the observatory for an out-of-turn refresh pass,
* then report what it measured. Pass a group or chain name to refresh one; pass
* nothing (or '') for every group and every chain.
*
* It does NOT dial. The observatory is the only thing in the daemon that
* measures anything, and it measures along the real dial path — so this is the
* "don't wait for the next probe interval" button, not a second opinion. The
* numbers it returns are the same numbers the cards are already showing, just
* fresher. Singleton: a second call while a pass is in flight resolves to
* `{started:false, reason:'already running'}` rather than failing.
* It does NOT dial (for `kind:''`). The observatory is the only thing in the
* daemon that measures anything, and it measures along the real dial path — so
* this is the "don't wait for the next probe interval" button, not a second
* opinion. The numbers it returns are the same numbers the cards are already
* showing, just fresher. Singleton: a second call while a pass is in flight
* resolves to `{started:false, reason:'already running'}` rather than failing.
*
* WITH `kind:'node'` it runs the SAME instrument on ONE node — same singleton,
* same runner, same result shape, and the result arrives through the same GET
* below (a node run is not a second poll). A node the observatory does not cover
* is measured once, here, over the node's own configured path, and that result
* says so with `source:'on-demand'`. Three refusals, and they are three
* different facts that must not collapse into one "error":
*
* 400 — no name was sent. There is no "test every node".
* 404 — no node in the CONFIGURATION carries that name (unsaved, renamed, or
* a typo). It is about the name, not about the node's health.
* 503 — the configuration could not be READ to check. An UNKNOWN, and never a
* verdict about the node.
*
* All three arrive as an {@link ApiError} carrying that `status`.
*/
export function postGroupsTest(name = ''): Promise<GroupTestStart> {
export function postGroupsTest(name = '', kind: TestKind = ''): Promise<GroupTestStart> {
return MOCK
? mock().postGroupsTest(name)
: req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) })
? mock().postGroupsTest(name, kind)
: req<GroupTestStart>('api/groups/test', {
method: 'POST',
body: JSON.stringify({ name, kind }),
})
}
/** GET /api/groups/test — progress + results of the current/last group test. */
+210
View File
@@ -0,0 +1,210 @@
// The configuration on disk was refused, and everything else on the page is
// about a different one.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// THE MEASUREMENT THIS EXISTS FOR (stand, shater/apply/apply.go): with a config
// the engine could not accept, reconciliation retried it every 60 seconds — each
// retry a full engine swap that failed and rolled back — while the status said
// `engine_running: true` and carried the OLD config's hash and the OLD config's
// warnings, and named the cause nowhere.
//
// WHAT THESE PROTECT:
//
// 1. THE REFUSED STATE AND THE APPLIED STATE MUST NOT RENDER ALIKE, and the
// refused one must NAME THE STAGE.
// 2. THE CONTROL, BOTH WAYS. On a healthy box nothing new appears at all —
// otherwise "it warns when refused" is satisfiable by a helper that warns
// always. And a daemon that does not publish the field must not be alarmed
// either, because there is no evidence about it.
// 3. IT MUST NOT CONTRADICT `engine_running`. True is TRUE in this state. The
// reading has to say WHICH configuration is running, not deny that one is.
// 4. THE REST OF THE PAGE IS DISOWNED BY NAME. "Refused" alone leaves the hash,
// the traffic verdict and the findings reading as if they were about the
// edit that was just saved.
// 5. NO BROWSER-COMPUTED ELAPSED TIME. The router has no RTC.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Status } from './api.ts'
import { appliedState, rejectedReading, shortHash } from './appliedConfig.ts'
const status = (over: Partial<Status>): Status => ({
running: true,
enabled: true,
active: true,
table: true,
hash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
version: '0.2.19',
engine_running: true,
config_readable: true,
plane: 'full',
...over,
})
/** A box whose saved edit was refused at validation while the tunnel kept working. */
const refused = status({
config_applied: false,
apply_error: 'rule "kids": target "de-hysteria" resolves to nothing',
apply_error_stage: 'the schema gate',
apply_attempts: 7,
apply_failed_since_unix: 1_700_000_000,
})
const healthy = status({ config_applied: true, apply_error: '', apply_error_stage: '' })
// ---- the three states -------------------------------------------------------
test('the three states are three, and false is not folded into absent', () => {
assert.equal(appliedState(healthy), 'applied')
assert.equal(appliedState(refused), 'rejected')
assert.equal(appliedState(status({})), 'unknown') // a daemon without the field
assert.equal(appliedState(null), 'unknown')
})
test('a refused configuration produces a statement, and it names the stage', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r, 'the refusal produced nothing to show')
assert.equal(r.stage, 'schema')
assert.match(r.stageText, /the schema gate/)
assert.match(r.stageHint, /did not pass validation/)
// The daemon's reason, verbatim — not summarised into a mood.
assert.match(r.cause, /target "de-hysteria" resolves to nothing/)
})
// ---- the controls, both ways ------------------------------------------------
test('CONTROL: a healthy box gets nothing new at all', () => {
// Without this, every assertion above is satisfied by a helper that reports a
// refusal unconditionally.
assert.equal(rejectedReading(healthy, '09:14:00'), null)
})
test('CONTROL: a daemon that does not publish the field is not alarmed either', () => {
// Absent is not false. There is no evidence this box's hash is stale, and
// stamping "your edit is not in effect" across the page on no evidence is the
// same class of lie as hiding it when it is true.
assert.equal(rejectedReading(status({}), ''), null)
assert.equal(rejectedReading(null, ''), null)
})
test('CONTROL: refused and applied cannot be drawn the same way', () => {
// The one assertion the whole defect reduces to. It fails if the two states
// ever produce the same screen.
const a = rejectedReading(healthy, '09:14:00')
const b = rejectedReading(refused, '09:14:00')
assert.notEqual(a === null, b === null, 'refused and applied produced the same rendering')
assert.equal(a, null)
assert.ok(b)
})
// ---- what it says, and what it must not say ---------------------------------
test('it does NOT contradict engine_running — it says which config is running', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
assert.match(r.scope, /The engine IS running/)
assert.match(r.scope, /PREVIOUS configuration/)
// The running config named by its own short hash, so the two can be told apart.
assert.match(r.scope, /a1b2c3d4e5f6/)
})
test('the hash, the traffic verdict and the findings are disowned BY NAME', () => {
// "It was refused" is not enough. Every other reading on the page stays green
// and keeps describing the configuration that is running.
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
for (const named of [/config hash/, /where traffic goes/, /every finding/]) {
assert.match(r.scope, named)
}
assert.match(r.scope, /Your edit is not in effect/)
})
test('a stopped engine is not told the tunnel still works', () => {
const r = rejectedReading(status({ ...refused, engine_running: false }), '09:14:00')
assert.ok(r)
assert.doesNotMatch(r.scope, /tunnel still works/)
assert.match(r.scope, /Nothing of this configuration is running/)
// …and the rest of the page is still disowned, because the readings are still
// there and still about something else.
assert.match(r.scope, /every finding/)
})
test('persistence says whether anything is still trying, with the router’s clock', () => {
const r = rejectedReading(refused, '09:14:00')
assert.ok(r)
assert.match(r.persistence, /Tried 7 times/)
assert.match(r.persistence, /first refused at 09:14:00 on the router’s clock/)
assert.match(r.persistence, /retried on a widening interval/)
assert.match(r.persistence, /saving any change to the configuration cancels the wait/)
})
test('no elapsed time is computed in the browser — the router has no RTC', () => {
// Passing no formatted clock must not make the panel invent one from
// `apply_failed_since_unix` against the browser's clock.
const r = rejectedReading(refused, '')
assert.ok(r)
assert.doesNotMatch(r.persistence, /minute\(s\)|ago|for \d+ /)
assert.match(r.persistence, /Tried 7 times\./)
})
// ---- the closed stage vocabulary --------------------------------------------
test('the three stages are recognised and each says something different', () => {
const seen = new Set<string>()
for (const [phrase, kind] of [
['the schema gate', 'schema'],
['building the engine configuration', 'generate'],
['starting the engine', 'engine'],
] as const) {
const r = rejectedReading(status({ ...refused, apply_error_stage: phrase }), '')
assert.ok(r)
assert.equal(r.stage, kind)
assert.match(r.stageText, new RegExp(phrase))
assert.ok(r.stageHint.length > 0, `${kind} has no hint`)
seen.add(r.stageHint)
}
assert.equal(seen.size, 3, 'two stages tell the operator the same thing')
})
test('an unrecognised stage is shown verbatim and labelled, never guessed', () => {
const r = rejectedReading(status({ ...refused, apply_error_stage: 'installing the data plane' }), '')
assert.ok(r)
assert.equal(r.stage, 'unrecognised')
assert.match(r.stageText, /installing the data plane/)
assert.match(r.stageText, /does not recognise/)
assert.equal(r.stageHint, '', 'a stage we cannot name must not carry advice about another one')
})
test('a stage the daemon never named is its own state, not one of the three', () => {
const r = rejectedReading(status({ ...refused, apply_error_stage: '' }), '')
assert.ok(r)
assert.equal(r.stage, 'unnamed')
assert.match(r.stageText, /did not name/)
})
test('a refusal with no recorded reason still reads as a refusal', () => {
const r = rejectedReading(status({ ...refused, apply_error: '' }), '')
assert.ok(r)
assert.match(r.cause, /recorded no reason/)
assert.notEqual(r.cause, '', 'an empty cause line reads as “nothing wrong”')
})
test('shortHash matches the daemon’s own short form, prefix or not', () => {
assert.equal(shortHash('a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
assert.equal(shortHash('abc'), 'abc')
assert.equal(shortHash(''), '')
// A prefixed hash must shorten to the SAME twelve characters, or the band and
// the Engine module's hash row print two different strings for one config on
// one screen — in the one state where telling configs apart is the whole job.
assert.equal(shortHash('sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
})
test('the band names the running config exactly as the hash row shortens it', () => {
const h = 'sha256:9f7c0abc12345678'
const r = rejectedReading(status({ ...refused, hash: h }), '')
assert.ok(r)
assert.match(r.scope, new RegExp(`\\(${shortHash(h)}\\)`))
assert.doesNotMatch(r.scope, /sha256:/)
})
+208
View File
@@ -0,0 +1,208 @@
// Is the configuration on disk the one that is RUNNING — and if not, what is
// everything else on the screen actually describing?
//
// Backend contract: shater/apply/apply.go — Status.ConfigApplied / ApplyError /
// ApplyErrorStage / ApplyAttempts / ApplyFailedSinceUnix, and Applier.rejected,
// the record that makes them possible.
//
// THE MEASUREMENT THIS EXISTS FOR. With a configuration on disk the engine
// cannot accept, the daemon's reconciliation retried it every 60 seconds — each
// retry a full engine swap that failed and rolled back — while the status body
// said `engine_running: true` and carried the OLD configuration's hash and the
// OLD configuration's warnings. The reason for the refusal appeared nowhere in
// it. The owner watched a healthy engine while the tunnel restarted once a
// minute and their edit did nothing.
//
// `engine_running: true` is TRUE in that state and this module never contradicts
// it. The whole job here is to say WHICH configuration is running, and to mark
// every value on the page that describes that older one instead of the one the
// operator just saved.
import type { Status } from './api'
/**
* A CLOSED set of three, and `unknown` is a real member.
*
* applied — the configuration on disk IS the one running.
* rejected — it was read and REFUSED. Something else is running.
* unknown — the key is ABSENT: nobody measured it, so there is nothing to
* assert in either direction.
*
* WHY `unknown` IS NOT FOLDED INTO `rejected`. `false` is a MEASURED refusal and
* earns the alarm; absence is not a measurement at all, and the daemon spends a
* pointer to keep the two apart (apply.Status.ConfigApplied is a *bool with
* `omitempty`). It has to: `false` publishes "THE CONFIGURATION ON DISK HAS NOT
* BEEN APPLIED … your edit is not in effect", so with a plain bool that alarm
* would be the ZERO VALUE OF THE TYPE — raised by any object that simply never
* mentioned the field, such as the offline stub `shaterd status` prints with no
* daemon answering, over a data plane that may have been running for weeks.
*
* A live status always carries one of the two verdicts, so absence here means
* either that stub or a daemon older than the field — and neither is evidence
* that this router's hash is stale. Stamping "your edit is not in effect" across
* a page on no evidence is the same class of lie as hiding it when it is true.
* `unknown` therefore claims nothing: it neither raises the alarm nor certifies
* the page. This is the same treatment `plane` and `traffic` already get.
*/
export type AppliedState = 'applied' | 'rejected' | 'unknown'
export function appliedState(status: Status | null | undefined): AppliedState {
const v = status?.config_applied
if (v === true) return 'applied'
if (v === false) return 'rejected'
return 'unknown'
}
/** The stage vocabulary, normalised. POSITIVE and CLOSED — `unrecognised` is how
* a future fourth stage arrives, and it is shown verbatim rather than guessed
* at. `unnamed` is the daemon saying nothing, which is not the same thing. */
export type ApplyStage = 'schema' | 'generate' | 'engine' | 'unnamed' | 'unrecognised'
/** The three phrases apply.go publishes (stageSchemaGate / stageGenerate /
* stageEngine), verbatim. Matched exactly: the daemon writes these constants, so
* a value that is merely close is a value this build does not know. */
const STAGES: Record<string, ApplyStage> = {
'the schema gate': 'schema',
'building the engine configuration': 'generate',
'starting the engine': 'engine',
}
export interface RejectedReading {
stage: ApplyStage
/** The step, named. Always non-empty — silence here is itself reported. */
stageText: string
/** What the operator does about this stage. '' when the stage is not known. */
stageHint: string
/** The daemon's reason, verbatim, or a statement that it gave none. */
cause: string
/** Whether anything is still trying, and how long it has been like this. */
persistence: string
/**
* WHAT THE REST OF THE PAGE IS ABOUT. This is the sentence the defect was
* missing: it is not enough to say the config was refused, because every other
* reading on screen stays green and keeps describing the configuration that is
* running.
*/
scope: string
}
/**
* The full statement of a standing refusal — or `null` when there is nothing to
* state, which is every status that is not `rejected`.
*
* `hash` is the short form of the RUNNING configuration's hash, and it is in the
* text on purpose: the single most misleading thing about this state is that
* `hash` in the same status body looks entirely normal. It names the config the
* operator is actually running so the two can be told apart.
*
* NO ELAPSED TIME IS COMPUTED HERE. `apply_failed_since_unix` is the router's
* clock, the router has no RTC, and a browser-side "failing for 12 minutes" would
* be fiction whenever the two clocks differ. The attempt COUNT carries
* persistence; the instant is handed back to the caller to format against the
* router's clock, like every other router timestamp in this panel.
*/
export function rejectedReading(
status: Status | null | undefined,
/** Already-formatted router-clock time of `apply_failed_since_unix`; '' when
* there is none. The caller owns formatting — this module has no locale. */
sinceClock = '',
): RejectedReading | null {
if (appliedState(status) !== 'rejected') return null
const raw = (status?.apply_error_stage ?? '').trim()
const stage: ApplyStage = raw === '' ? 'unnamed' : (STAGES[raw] ?? 'unrecognised')
const err = (status?.apply_error ?? '').trim()
const attempts = status?.apply_attempts ?? 0
return {
stage,
stageText: stageText(stage, raw),
stageHint: stageHint(stage),
// A refusal whose reason was not recorded is still a refusal, and saying so is
// the point — an empty line here would read as "no reason, so probably fine".
cause: err || 'The daemon recorded no reason for the refusal.',
persistence: persistenceText(attempts, sinceClock),
scope: scopeText(status),
}
}
function stageText(stage: ApplyStage, raw: string): string {
switch (stage) {
case 'schema':
return 'the schema gate'
case 'generate':
return 'building the engine configuration'
case 'engine':
return 'starting the engine'
case 'unrecognised':
// Verbatim, and labelled. A step this build does not know is still the step
// that refused, and guessing which of the three it resembles would send the
// operator to the wrong page.
return `a step this panel does not recognise — the daemon calls it “${raw}”`
case 'unnamed':
return 'a step the daemon did not name'
}
}
function stageHint(stage: ApplyStage): string {
switch (stage) {
case 'schema':
return 'The configuration did not pass validation, so nothing was built from it. The cause names the setting.'
case 'generate':
return 'The configuration is valid and could not be turned into an engine configuration — a reference that resolves to nothing, or a combination the generator refuses.'
case 'engine':
return 'The configuration built, and the engine would not come up on it — a port already taken, or a transport that fails at start.'
case 'unrecognised':
case 'unnamed':
return ''
}
}
/**
* Is anything still trying, and since when.
*
* The retry is on a widening interval and ANY edit cancels the wait, so "it will
* be retried" is true and worth saying: an operator who reads "refused" alone
* cannot tell whether the box has given up.
*/
function persistenceText(attempts: number, sinceClock: string): string {
const tries =
attempts > 0
? `Tried ${attempts} time${attempts === 1 ? '' : 's'}`
: 'The daemon did not say how many times it has been tried'
const since = sinceClock ? `, first refused at ${sinceClock} on the router’s clock` : ''
return `${tries}${since}. It is retried on a widening interval, and saving any change to the configuration cancels the wait and tries it again at once.`
}
/**
* The sentence that names what everything else on screen describes.
*
* It is built from what the status actually carries, so it never promises a
* reading that is not on the page: with no `hash` there is no hash to disown.
*/
function scopeText(status: Status | null | undefined): string {
const running = shortHash(status?.hash ?? '')
const engineUp = status?.engine_running === true
const head = engineUp
? `The engine IS running — on the PREVIOUS configuration${running ? ` (${running})` : ''}, not this one. That is why the tunnel still works.`
: `Nothing of this configuration is running${running ? `; the hash shown is ${running}` : ''}.`
return `${head} Everything else on this page describes that older configuration: the config hash, where traffic goes, and every finding below. Your edit is not in effect.`
}
/**
* The leading 12 characters of a config hash — the same short form the daemon's
* own warning uses (apply.shortHash), so the two texts name the config
* identically.
*
* The `sha256:` prefix is stripped first. The daemon's hash is bare hex
* (engine.hashOptions), but a prefixed form exists in fixtures and older data, and
* without the strip the twelve characters would be spent on the word "sha256:" —
* so this band and the Engine module's hash row would print two different strings
* for one configuration, on one screen, in the one state where telling two
* configurations apart is the whole job. Overview's hash row calls THIS function
* for that reason; there is deliberately only one normalisation.
*/
export function shortHash(h: string): string {
const bare = h.replace(/^sha256:/, '')
return bare.length <= 12 ? bare : bare.slice(0, 12)
}
+287
View File
@@ -0,0 +1,287 @@
// applyRisk — the warning shown BEFORE the apply that takes the network down.
//
// Traced through the daemon: nothing rejects an empty config (model.Validate is
// advisory, generate errors only on a nil model, the engine starts because the
// outbound list always holds direct+block); with the kill-switch closed and no
// catch-all rule generate/route.go sets `route.final = "block"`; and the tproxy
// divert for the shipped `lan` inbound is installed, so every TCP connection and
// UDP flow from the LAN is handed to the engine and dropped.
//
// A predictive warning is only worth having if it is quiet on configs that are
// fine, so every "it fires" case below is paired with the single-field change
// that must silence it. Four conditions, four ways for the danger to be absent.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyRisk, isCatchAllRule, isInterceptingInbound } from './planeState.ts'
import type { ApplyRisk, ApplyRiskInput, CatchAllRule } from './planeState.ts'
/** The shipped config with the service switched on and nothing else changed:
* fail-closed, one enabled tproxy inbound, no rules, no resolvers, no window. */
function shipped(over: Partial<ApplyRiskInput> = {}): ApplyRiskInput {
return {
Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 0 },
Rules: [],
Inbounds: [{ Enabled: true, Type: 'tproxy' }],
Resolvers: [],
...over,
}
}
/** A rule with no matcher of any kind — the effective catch-all (route.Final). */
function catchAll(over: Partial<CatchAllRule> = {}): CatchAllRule {
return { Enabled: true, ...over }
}
// --- it fires on the dangerous config ---------------------------------------
test('warns before an apply that blocks every device', () => {
const r = applyRisk(shipped())
assert.ok(r, 'the shipped config, switched on, blocks everything')
// Says what the ACTION does, in traffic terms, not what is wrong with a field.
assert.match(r.headline, /cuts every device off the internet/)
assert.match(r.detail, /drops it/)
// Only certain claims: TCP and UDP are what tproxy diverts. ICMP depends on
// the Untunnelable policy, so it is not promised here either way.
assert.match(r.detail, /TCP connection and UDP flow/)
// What still works, so nobody power-cycles a router they can still reach.
assert.match(r.detail, /reach each other and this panel/)
// And the fix, naming the page and the shape of the rule.
assert.match(r.steps[0], /Routing page/)
assert.match(r.steps[0], /no conditions/)
})
test('names the missing auto-rollback, and the value to set', () => {
const r = applyRisk(shipped())
assert.ok(r)
assert.equal(r.noAutoRollback, true)
assert.match(r.undo, /no auto-rollback/)
assert.match(r.undo, /SSH/)
// The README's documented first-apply value, which the panel never mentioned.
assert.ok(
r.steps.some((s) => /120 seconds/.test(s) && /Settings/.test(s)),
'the confirm-window remedy must be offered, with the documented value',
)
})
test('an armed confirm window changes the undo line, not the warning', () => {
const r = applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }))
assert.ok(r, 'a confirm window does not make blocking the network unremarkable')
assert.equal(r.noAutoRollback, false)
assert.match(r.undo, /120s/)
assert.match(r.undo, /reverts to the last-good one/)
assert.doesNotMatch(r.undo, /SSH/)
// ...and it stops offering a remedy the operator has already applied.
assert.ok(!r.steps.some((s) => /120 seconds/.test(s)))
})
test('zero resolvers is named, because DNS is the one thing that still leaves', () => {
// CONTROL FIRST: one configured resolver and the step is gone. A DNS warning
// that appears whatever the DNS config says is not a reading of the DNS config.
const withResolver = applyRisk(shipped({ Resolvers: [{}] }))
assert.ok(withResolver)
assert.ok(!withResolver.steps.some((s) => /DNS page/.test(s)))
const without = applyRisk(shipped())
assert.ok(without)
const dns = without.steps.find((s) => /DNS page/.test(s))
assert.ok(dns, 'with no resolver, lookups go to the provider unprotected — say so')
assert.match(dns, /in the clear/)
// IT IS NOT ONLY THE QUERIES ADDRESSED TO THE ROUTER. The shipped
// `dns_intercept '1'` pulls a query aimed at a resolver the device picked for
// ITSELF into the engine too, and it leaves as the same clear UDP/53 — measured
// both ways on the stand, each producing its own plaintext packet on the WAN.
// The earlier wording covered only the router-addressed half, which reads as a
// promise that the other half is contained. It is not.
assert.match(dns, /to the router or to a resolver they picked themselves/)
// ...and encrypted DNS is not the way out of it either: :853 out of the LAN
// measured connects=0, because the plan rejects it.
assert.match(dns, /:853/)
// The claim the detail below is not allowed to contradict.
assert.match(dns, /only thing that still leaves this network/)
})
// --- the band may not contradict itself -------------------------------------
//
// MEASURED on the stand: in this exact state 0 packets left the WAN across the
// whole run (27 in the control that differs only by an added catch-all) — except
// for exactly 2, both plaintext UDP/53. So the DNS step is right and the detail's
// "nothing reaches the internet" was wrong by those 2 packets.
//
// The wording fix alone does not survive the next editor, because the two halves
// live 15 lines apart and each reads fine on its own. What follows is therefore
// not a check of the words but of the INVARIANT between them: no clause of the
// band may claim that nothing leaves while another clause names something that
// does. Edit either half back without the other and this fails.
/** The band is one utterance to one operator, so a claim in the steps and a claim
* in the detail are claims in the same breath. Flatten it to clauses. */
function bandClauses(r: ApplyRisk): string[] {
return [r.headline, r.detail, r.undo, ...r.steps]
.join(' ')
.split(/[.;:](?=\s|$)/)
.map((c) => c.trim())
.filter(Boolean)
}
/** "…<verb> … <the outside>" — a clause that talks about traffic leaving. */
const EXIT = /\b(?:reach(?:es)?|leaves?|leaving|gets? out|goes? out|going out)\b.*?\b(?:internet|this network|your provider)\b/i
/** Universal negation, and ONLY universal negation: "No resolver is configured"
* must NOT count, or the instrument goes blind on the very clause it exists to
* see. */
const NONE = /\bnothing\b|\bno traffic\b|\bnone of it\b/i
/** What turns "nothing leaves" into a survivable claim by admitting an exception. */
const QUALIFIED = /\belse\b|\bexcept\b|\bapart from\b|\bother than\b|\baside from\b/i
const saysNothingLeaves = (c: string) => EXIT.test(c) && NONE.test(c) && !QUALIFIED.test(c)
const saysSomethingLeaves = (c: string) => EXIT.test(c) && !NONE.test(c)
test('the band never says both "nothing leaves" and "DNS leaves"', () => {
// The instrument, proved on a fabricated band before it is trusted on a real
// one: the absolute claim IS detectable, and it IS detected next to the DNS
// clause. Without this, a regex that matches nothing would pass silently.
const control = bandClauses({
headline: 'x',
detail: 'Devices can still reach each other and this panel; nothing reaches the internet.',
undo: 'x',
steps: ['It is the only thing that still leaves this network.'],
noAutoRollback: true,
})
assert.ok(control.some(saysNothingLeaves), 'the absolute-claim detector must be able to fire')
assert.ok(control.some(saysSomethingLeaves), 'the something-leaves detector must be able to fire')
// Now the real bands, in every shape that renders one.
const bands = [
shipped(),
shipped({ Resolvers: [{}] }),
shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }),
shipped({ Rules: [catchAll({ Enabled: false })] }),
shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })], Resolvers: [{}, {}] }),
].map((cfg) => {
const r = applyRisk(cfg)
assert.ok(r, 'this config must still produce a band, or the check below is vacuous')
return r
})
for (const r of bands) {
const clauses = bandClauses(r)
const denies = clauses.filter(saysNothingLeaves)
const admits = clauses.filter(saysSomethingLeaves)
assert.ok(
!(denies.length > 0 && admits.length > 0),
`the band contradicts itself:\n denies: ${JSON.stringify(denies)}\n admits: ${JSON.stringify(admits)}`,
)
}
// AND THE INSTRUMENT IS LOOKING AT THE LIVE TEXT. Without a resolver the band
// must contain a clause admitting that something leaves — if that ever stops
// being true, the loop above passes for the wrong reason.
const dangerous = applyRisk(shipped())
assert.ok(dangerous)
assert.ok(
bandClauses(dangerous).some(saysSomethingLeaves),
'the no-resolver band must still name the traffic that gets out',
)
// The detail's half of the invariant, pinned by hand so the one-word edit that
// breaks it is named in the failure and not just inferred.
assert.match(dangerous.detail, /nothing else reaches the internet/)
assert.doesNotMatch(dangerous.detail, /nothing reaches the internet/)
// CONTROL: with a resolver configured nothing is claimed to leave at all, so
// the invariant is satisfied by the other side of the same test.
const safe = applyRisk(shipped({ Resolvers: [{}] }))
assert.ok(safe)
assert.equal(bandClauses(safe).filter(saysSomethingLeaves).length, 0)
})
// --- the four ways it must stay silent (the controls) -----------------------
test('silent when the service will be off — no plane is built at all', () => {
assert.equal(
applyRisk(shipped({ Globals: { Enabled: false, KillSwitch: 'closed', ConfirmTimeout: 0 } })),
null,
)
})
test('silent when the kill-switch is open — unmatched traffic leaves, it is not dropped', () => {
assert.equal(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'open', ConfirmTimeout: 0 } })),
null,
)
// ...and the daemon's own normalisation is what decides "closed", so every
// spelling that blocks on the router must still warn here.
for (const spelling of ['closed', 'Closed', ' closed ', '', 'CLOSED', 'whatever']) {
assert.ok(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: spelling, ConfirmTimeout: 0 } })),
`kill_switch '${spelling}' blocks on the router, so it must warn here`,
)
}
// Only a case-insensitive, trimmed "open" is fail-open.
assert.equal(
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: ' Open ', ConfirmTimeout: 0 } })),
null,
)
})
test('silent when nothing intercepts — no enabled tproxy inbound diverts anything', () => {
assert.equal(applyRisk(shipped({ Inbounds: [] })), null)
assert.equal(applyRisk(shipped({ Inbounds: null })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: false, Type: 'tproxy' }] })), null)
// socks/http/dokodemo are local listeners; they intercept nothing on their own.
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'socks' }] })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'http' }] })), null)
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'dokodemo' }] })), null)
// An absent/empty Type IS tproxy (model.Inbound.EffectiveType), so it warns.
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: '' }] })))
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true }] })))
})
test('silent when a default rule exists — that is what stops final being block', () => {
assert.equal(applyRisk(shipped({ Rules: [catchAll()] })), null)
// A DISABLED catch-all is not one: it is not emitted, so final stays block.
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Enabled: false })] })))
// Neither is a rule that only matches SOME traffic.
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Src: ['192.168.1.0/24'] })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' })] })))
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Proto: 'tcp' })] })))
// One catch-all among specific rules is still a catch-all.
assert.equal(
applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' }), catchAll()] })),
null,
)
})
test('an UNMIGRATED rule is never the default — the case that would hide the warning', () => {
// Its destination is still in schema-v1 options the parser no longer reads, so
// "no matchers" means "unreadable destination", not "matches everything" — and
// the daemon holds it disabled. Counting it would silence this warning on
// exactly the config that needs it.
assert.equal(isCatchAllRule(catchAll({ LegacyDst: ['example.com'] })), false)
assert.ok(
applyRisk(shipped({ Rules: [catchAll({ LegacyDst: ['example.com'] })] })),
'an unmigrated rule must not be mistaken for a default route',
)
// CONTROL: the same rule once migrated does silence it.
assert.equal(applyRisk(shipped({ Rules: [catchAll({ LegacyDst: [] })] })), null)
})
// --- the small predicates, directly -----------------------------------------
test('isInterceptingInbound is a closed positive list', () => {
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'TPROXY' }), true)
assert.equal(isInterceptingInbound({ Enabled: true, Type: ' tproxy ' }), true)
assert.equal(isInterceptingInbound({ Enabled: true }), true)
// Anything the panel does not know about must NOT be assumed to intercept —
// an open `!== 'socks'` test would warn about a router that diverts nothing.
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'something-new' }), false)
assert.equal(isInterceptingInbound({ Enabled: false }), false)
})
test('null and missing input produce no warning rather than a guess', () => {
assert.equal(applyRisk(null), null)
assert.equal(applyRisk(undefined), null)
assert.equal(applyRisk(shipped({ Rules: null, Resolvers: null })) !== null, true)
})
+137
View File
@@ -0,0 +1,137 @@
// A BLOCKED CHAIN IS A FIELD NOW, NOT A SENTENCE.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THIS PROTECTS. The prober walks a chain in order and stops at the first
// hop that does not answer, so the chain's exit is never dialled. There is no
// end-to-end measurement, and the daemon correctly files the row `source:''` —
// which a client reading `source` strictly puts in the "nobody looked" bucket.
// That is the wrong colour: a probe DID run, at the hop, and it failed.
//
// The panel used to keep such a row red by matching a fragment of the daemon's
// error sentence. It was the last place prose decided anything here, and a
// reworded message would have silently turned a red row grey. `blocked_by` is
// the same fact as a NUMBER.
//
// 1. A NON-ZERO blocked_by KEEPS THE ROW RED, though nothing measured it.
// 2. THE CONTROL: zero does NOT. Written so that a helper which reds
// everything, or one which reds nothing, fails.
// 3. NO PROSE. The daemon's sentence can be reworded to anything at all and
// the colour does not move.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import { blockedHop, readingTone, testReading } from './testResult.ts'
const chainRow = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'ewan-wg-subs',
selected: '',
kind: 'chain',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error: '',
tested_unix: 1_700_000_000,
source: '',
blocked_by: 0,
...over,
})
// --- 1 + 2. red on a hop, not red on zero -------------------------------------
test('a chain blocked at a hop stays RED, though nothing measured its exit', () => {
const blocked = chainRow({
blocked_by: 3,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
})
assert.equal(blockedHop(blocked), 3, 'the hop is 1-based and comes off the field')
assert.equal(testReading(blocked), 'not-measured', 'there is still no end-to-end measurement')
assert.equal(readingTone(blocked), 'crit', 'but a probe did run, at the hop, and failed')
})
test('CONTROL: blocked_by = 0 is NOT red — the escalation is exactly one case', () => {
// Every non-chain result carries 0. If this went red too, the field would have
// replaced a narrow prose match with a blanket one, which is worse than what
// it replaced.
const quiet = chainRow({
blocked_by: 0,
error:
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
})
assert.equal(blockedHop(quiet), 0)
assert.equal(
readingTone(quiet),
'unknown',
'an unlit lamp: painting this red reports a fault nobody has found',
)
assert.notEqual(readingTone(quiet), 'crit')
})
test('CONTROL: the helper can still say a row is good, and a measured failure bad', () => {
// Without these, "0 is not crit" would also be satisfied by a classifier that
// never says anything at all.
const alive = chainRow({ ok: true, delay_ms: 42, source: 'observatory' })
const dead = chainRow({
source: 'observatory',
error: 'the observatory’s probe through this path failed',
})
assert.equal(readingTone(alive), 'good')
assert.equal(readingTone(dead), 'crit')
assert.equal(blockedHop(alive), 0, 'a working chain names no blocking hop')
})
// --- 3. the prose no longer decides anything -----------------------------------
test('the daemon may reword its sentence freely — the colour comes off the field', () => {
// This is the regression the change exists for. Under the old string match,
// every one of these rows would have gone quiet grey.
for (const error of [
'hop 3 did not respond',
'chain stopped at hop 3',
'',
'a sentence this build has never seen',
]) {
const r = chainRow({ blocked_by: 3, error })
assert.equal(readingTone(r), 'crit', JSON.stringify(error))
}
})
test('…and the old sentence WITHOUT the field no longer reds a row on its own', () => {
// The mirror: prose alone is not evidence any more. A daemon that sends the
// sentence and no field is one this panel does not ship with, and guessing
// from its wording is exactly the coupling that was removed.
const proseOnly = chainRow({
blocked_by: undefined,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
})
assert.equal(blockedHop(proseOnly), 0)
assert.equal(readingTone(proseOnly), 'unknown')
})
test('a nonsense hop number claims nothing — the escalation is not entered by accident', () => {
for (const raw of [0, -1, 0.5, NaN, Infinity, '3' as unknown as number]) {
assert.equal(blockedHop(chainRow({ blocked_by: raw })), 0, String(raw))
}
assert.equal(blockedHop(chainRow({ blocked_by: 1 })), 1, 'one IS a valid hop — 1-based')
assert.equal(blockedHop(chainRow({ blocked_by: 4.9 })), 4, 'a fractional hop floors to a real one')
})
test('a measured failure is never downgraded by a missing hop, nor upgraded by one', () => {
const measuredFail = chainRow({
source: 'observatory',
blocked_by: 0,
error: 'the observatory’s probe through this path failed',
})
assert.equal(readingTone(measuredFail), 'crit')
const measuredOK = chainRow({ ok: true, source: 'observatory', blocked_by: 3 })
assert.equal(
readingTone(measuredOK),
'good',
'blocked_by only ever escalates a not-measured row; it cannot overturn a measurement',
)
})
+132
View File
@@ -0,0 +1,132 @@
// TWO KINDS OF ROW ON ONE BOARD, and they may not look alike.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THIS PROTECTS. Starting a test run used to replace the results outright,
// so pressing Test on one NODE blanked every group and chain card on the Targets
// screen — the daemon threw true measurements away and nothing on screen
// explained it. It no longer does: the rows a run will not itself re-measure are
// carried forward (engine.startTestRun), capped at 64, oldest evicted first.
//
// That fixes one lie and opens the door to another. A card can now show a
// reading taken twenty minutes ago beside a card showing one taken a second ago,
// and if both are drawn as a bare timestamp the whole board reads "as of now".
//
// Attribution is NOT by comparing timestamps: the router has no RTC, so its
// clock can sit far from the browser's and any computed "n minutes ago" would be
// fiction. The daemon publishes `scope`, the set of names this run covers.
//
// 1. THIS RUN'S ROW AND A CARRIED ROW ARE DIFFERENT ON SCREEN. Written so
// that drawing them identically fails.
// 2. THE CONTROL. The same helper must produce the plain, unqualified stamp
// too, or "they differ" is satisfied by a function that marks everything.
// 3. AN EMPTY SCOPE IS "CANNOT ATTRIBUTE", NOT "EVERYTHING IS CARRIED". A run
// always covers at least one target, so an empty set only ever means the
// daemon published none — and the panel's normalizer turns an absent field
// into exactly that.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import { originStamp, rowOrigin } from './testResult.ts'
const row = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'auto',
selected: 'nl-reality-2',
kind: 'group',
delay_ms: 42,
exit_ip: '185.12.34.56',
exit_country: 'NL',
ok: true,
error: '',
tested_unix: 1_700_000_000,
source: 'observatory',
...over,
})
const CLOCK = '14:02:11'
// --- 1 + 2. the distinction, and the control ---------------------------------
test('a row this run measured and a row it carried are attributed differently', () => {
const scope = ['stealth']
assert.equal(rowOrigin(row({ group: 'stealth' }), scope), 'this-run')
assert.equal(
rowOrigin(row({ group: 'auto' }), scope),
'carried',
'this run never touched `auto`; its reading is from an earlier press',
)
})
test('and they are DRAWN differently — the same fact, at the pixel level', () => {
const mine = originStamp('this-run', CLOCK)
const carried = originStamp('carried', CLOCK)
assert.notEqual(
mine.text,
carried.text,
'a screen that stamps both with the same bare time says the board is current when half of it is not',
)
assert.match(carried.text, /earlier run/, 'the words have to name the fact, not hint at it')
assert.equal(mine.text, CLOCK, 'this run needs no qualifier — it IS the reading just taken')
assert.notEqual(mine.hint, carried.hint)
})
test('CONTROL: the same helper does produce an unqualified stamp', () => {
// Without this, "they differ" would also be satisfied by a function that
// stamped EVERY row "earlier run" — which would be a different lie, and the
// one that makes an operator distrust a number they just asked for.
for (const origin of ['this-run', 'unattributed'] as const) {
assert.equal(
originStamp(origin, CLOCK).text,
CLOCK,
`${origin} must not be dressed as carried`,
)
assert.doesNotMatch(originStamp(origin, CLOCK).text, /earlier/)
}
})
test('a carried row says so even when it carries no timestamp at all', () => {
// The worst case for silence: nothing to print, and the row is still not this
// run's. `tested_unix: 0` reaches here as an empty clock string.
const carried = originStamp('carried', '')
assert.equal(carried.text, 'earlier run')
assert.notEqual(carried.text, originStamp('this-run', '').text)
assert.equal(originStamp('this-run', '').text, '', 'and an unstamped fresh row prints nothing')
})
// --- 3. an empty scope claims nothing ------------------------------------------
test('an EMPTY scope is "cannot attribute", not "everything is carried"', () => {
// The panel's normalizer turns an absent `scope` into `[]` before this sees
// it, so this branch is the pre-scope daemon. Reading it as a real scope would
// stamp "earlier run" on every row of a run that had just measured them all.
for (const scope of [[], undefined, null]) {
assert.equal(rowOrigin(row({}), scope), 'unattributed', JSON.stringify(scope))
}
assert.equal(originStamp('unattributed', CLOCK).text, CLOCK)
assert.doesNotMatch(
originStamp('unattributed', CLOCK).hint,
/earlier run/,
'saying nothing is the honest answer here; guessing "carried" is not',
)
})
test('a run over every target leaves nothing carried', () => {
const all = ['auto', 'stealth', 'via-tunnel']
for (const group of all) {
assert.equal(rowOrigin(row({ group }), all), 'this-run', group)
}
})
test('no row is not a row from an earlier run', () => {
assert.equal(rowOrigin(undefined, ['auto']), 'unattributed')
assert.equal(rowOrigin(null, ['auto']), 'unattributed')
})
test('the three origins produce three distinct hints', () => {
const hints = new Set(
(['this-run', 'carried', 'unattributed'] as const).map((o) => originStamp(o, CLOCK).hint),
)
assert.equal(hints.size, 3, 'three different situations, three different explanations')
})
+168
View File
@@ -0,0 +1,168 @@
/* ListPicker — the additions on top of SrcPicker.css.
*
* Everything structural (the machined slot, the flush-docked popover, the shelf
* strips, the sunken rows, the chip shell and its ×) is SrcPicker's and is reused
* verbatim; only what this picker says differently lives here:
*
* .lstp-lane / .lstp-step the two ranked lanes inside one slot, carrying the
* engine step number that ties the control back to the
* order rail on the device card;
* .lstp-dot / .lstp-load the load reading — the one colour on a list chip. It
* is driven by data-load, never by "is it attached":
* on = matching, warn = loaded but empty, crit = not
* loaded / no such list, unknown = the engine has not
* said, which is DIM and never green.
*/
/* --- ranked lanes --------------------------------------------------------- */
.lstp-lane {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
padding: 1px 4px 1px 1px;
border-radius: 6px;
background: color-mix(in srgb, var(--panel) 55%, transparent);
}
/* The step number. A machined stamp, not a bullet: it is the same number printed
* on the card's order rail, so the two read as one instrument. */
.lstp-step {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 15px;
height: 15px;
padding: 0 3px;
border: 1px solid var(--groove);
border-radius: 3px;
background: var(--sink);
box-shadow: 0 1px 1px var(--shadow) inset;
color: var(--dim);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9.5px;
font-weight: 700;
line-height: 1;
}
/* --- chips ---------------------------------------------------------------- */
/* A typed entry is hand-made: amber dot, same as SrcPicker's custom value. It
* carries NO kind tag — the marker it was typed with is already the first word of
* the label, and "full:discord.com FULL" says the same thing twice. */
/* An attached list carries the engine's verdict, and nothing else. */
.lstp-dot {
background: var(--faint);
}
[data-load='on'] > .lstp-dot,
.lstp-dot[data-load='on'] {
background: var(--led-on);
box-shadow: 0 0 4px var(--led-on);
}
[data-load='warn'] > .lstp-dot,
.lstp-dot[data-load='warn'] {
background: var(--amber);
box-shadow: 0 0 4px var(--amber);
}
[data-load='crit'] > .lstp-dot,
.lstp-dot[data-load='crit'] {
background: var(--crit);
box-shadow: 0 0 4px var(--crit);
}
[data-load='unknown'] > .lstp-dot,
.lstp-dot[data-load='unknown'] {
background: var(--faint);
box-shadow: none;
}
/* The verdict, in full. It is never truncated: "not loaded" clipped to
* "NOT LOAD…" is the one word on this card a parent has to be able to read. */
.lstp-load {
flex: 0 0 auto;
white-space: nowrap;
color: var(--faint);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
[data-load='on'] > .lstp-load,
.lstp-load[data-load='on'] {
color: var(--led-on);
}
[data-load='warn'] > .lstp-load,
.lstp-load[data-load='warn'] {
color: var(--amber);
}
[data-load='crit'] > .lstp-load,
.lstp-load[data-load='crit'] {
color: var(--crit);
}
.lstp-chip-list[data-load='crit'] {
border-color: color-mix(in srgb, var(--crit) 45%, var(--groove));
}
.lstp-chip-list[data-load='warn'] {
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
}
/* --- popover rows --------------------------------------------------------- */
.lstp-list {
max-height: 168px;
}
.lstp-row {
gap: 6px;
}
.lstp-row-detail {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--faint);
font-size: 10px;
}
.lstp-row-tags {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
}
.lstp-tag {
padding: 0 4px;
border: 1px solid var(--groove);
border-radius: 3px;
color: var(--dim);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9px;
letter-spacing: var(--track-label, 0.08em);
text-transform: uppercase;
}
/* --- notes ---------------------------------------------------------------- */
.lstp-hint,
.lstp-foot {
margin: 0;
padding: 6px 9px;
border-top: 1px solid var(--groove);
background: var(--panel);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 11px;
line-height: 1.5;
color: var(--dim);
}
.lstp-hint .mono {
color: var(--ink);
font-size: 10.5px;
}
.lstp-foot {
color: var(--faint);
}
@media (max-width: 560px) {
.lstp-row-detail {
display: none;
}
}
+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { describeDomainEntry, parseDomainEntry } from '../deviceLists'
import type { ListLoad } from '../deviceLists'
import './SrcPicker.css'
import './ListPicker.css'
/**
* ListPicker — the Faceplate chooser for ONE direction of a device's domain
* policy: the named lists attached to it plus the domains typed on it, in a
* single slot.
*
* It borrows SrcPicker's LANGUAGE wholesale (a machined slot of chips, a popover
* docked flush under it with hairline shelves, a Custom row at the bottom, Esc and
* click-outside, a dot encoding the chip's kind) and its stylesheet — but not its
* code. SrcPicker is welded to `useSrcOptions()`, to IP/CIDR validation, and to an
* empty state that reads "everyone · all LAN clients"; on a BLOCK list that
* sentence would mean the exact opposite of the truth. Generalising it would have
* produced a component with two of everything and a props list nobody reads.
*
* ## What the slot has to make legible
*
* The two chip kinds are not equal, and they are not adjacent in the engine's
* order either. A device is evaluated:
*
* 1 allow typed · 2 block typed · 3 allow attached · 4 block attached · 5 network
*
* so the typed chips and the attached chips inside ONE control sit two steps
* apart. The slot therefore renders them as two labelled lanes carrying their own
* step number, which is what ties this control back to the order rail above it on
* the card. Sorting them the other way round would draw a precedence that does not
* exist.
*
* ## What a chip is not allowed to say
*
* A list chip reports what the ENGINE says about that list (via
* /api/ruleset/status, through `listLoad`), never the fact that someone attached
* it. A list whose URL is unreachable, whose file is missing, or whose geosite
* category resolved to nothing is a parental control that does not work; it is
* drawn `not loaded`, in crit, and a list the engine has not mentioned at all is
* drawn `load unknown` rather than green.
*/
export type ListPickerKind = 'block' | 'allow'
/** One attachable named list, as the page knows it. */
export interface ListOption {
/** The `config blocklist` / `config allowlist` section name — the stored value. */
name: string
/** Where its content comes from: "url · big.oisd.nl", "3 domains", "geosite · telegram". */
detail: string
/** Blocklist/Allowlist.Enabled — whether it is in the NETWORK-wide filter.
* It does NOT gate this device: attaching the list is the switch here. */
networkEnabled: boolean
/** Blocklist.Response — the reply a blocked name gets. Block lists only. */
response?: string
/** What the running engine reports about it. */
load: ListLoad
}
export interface ListPickerProps {
kind: ListPickerKind
/** Attached list names — Device.Blocklists / Device.Allowlists. */
lists: string[]
/** Hand-typed entries — Device.Block / Device.Allow. */
domains: string[]
/** Every list that could be attached, in config order. */
options: ListOption[]
/** The engine step the TYPED lane is (1 for allow, 2 for block). */
typedStep: number
/** The engine step the ATTACHED lane is (3 for allow, 4 for block). */
listStep: number
disabled?: boolean
ariaLabel: string
onListsChange: (v: string[]) => void
onDomainsChange: (v: string[]) => void
}
const WORD: Record<ListPickerKind, { noun: string; verb: string; shelf: string }> = {
block: { noun: 'block', verb: 'Blocked', shelf: 'Blocklists' },
allow: { noun: 'allow', verb: 'Allowed', shelf: 'Allowlists' },
}
const EMPTY_TEXT: Record<ListPickerKind, string> = {
block: 'nothing blocked for this device',
allow: 'nothing forced through for this device',
}
const CUSTOM_PLACEHOLDER: Record<ListPickerKind, string> = {
block: 'example.com or keyword:tiktok',
allow: 'school.example.edu',
}
/** Said at the moment of choosing, because both facts change what the operator is
* about to do — and neither is visible from the chip afterwards. */
const FOOTNOTE: Record<ListPickerKind, string> = {
block:
'Attaching a list runs it for this device even when the list is off for the network. The reply a blocked name gets comes from the list, not from the device.',
allow:
'An attached allow list is terminal: everything it covers is also lifted out of the network blocklists for this device. A big list here removes a lot of filtering.',
}
/** The reading for a name the config no longer has. Built once — it never varies. */
const MISSING_LOAD: ListLoad = {
tone: 'crit',
tag: 'no such list',
detail: 'This device points at a list that is not in the config — it filters nothing.',
ruleCount: 0,
}
export function ListPicker({
kind,
lists,
domains,
options,
typedStep,
listStep,
disabled,
ariaLabel,
onListsChange,
onDomainsChange,
}: ListPickerProps) {
const [open, setOpen] = useState(false)
const [custom, setCustom] = useState('')
const [customErr, setCustomErr] = useState<string | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const fieldRef = useRef<HTMLDivElement>(null)
const customRef = useRef<HTMLInputElement>(null)
const byName = useMemo(() => new Map(options.map((o) => [o.name, o])), [options])
const attached = useMemo(() => new Set(lists), [lists])
const free = useMemo(() => options.filter((o) => !attached.has(o.name)), [options, attached])
// A stored name with no matching option is a device pointing at a deleted list.
// It keeps its chip and says so, rather than vanishing from the card.
const listChips = useMemo(
() =>
lists.map((name) => {
const opt = byName.get(name)
return { name, opt, load: opt ? opt.load : MISSING_LOAD }
}),
[lists, byName],
)
const domainChips = useMemo(
() => domains.map((d) => ({ value: d, ...describeDomainEntry(d) })),
[domains],
)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false)
fieldRef.current?.focus()
}
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const w = WORD[kind]
const attachList = (name: string) => {
if (attached.has(name)) return
onListsChange([...lists, name])
}
const detachList = (name: string) => onListsChange(lists.filter((x) => x !== name))
const removeDomain = (v: string) => onDomainsChange(domains.filter((x) => x !== v))
const addCustom = () => {
const parsed = parseDomainEntry(custom)
if (!parsed.ok) {
setCustomErr(parsed.reason)
return
}
if (domains.some((d) => d.toLowerCase() === parsed.value)) {
setCustomErr(`${parsed.value} is already on this list.`)
return
}
onDomainsChange([...domains, parsed.value])
setCustom('')
setCustomErr(null)
customRef.current?.focus()
}
const toggleOpen = () => {
if (disabled) return
setOpen((o) => !o)
}
const onFieldKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
}
}
const empty = listChips.length === 0 && domainChips.length === 0
return (
<div className="srcp lstp" ref={rootRef}>
<div
ref={fieldRef}
className={disabled ? 'srcp-field disabled' : 'srcp-field'}
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
onClick={toggleOpen}
onKeyDown={onFieldKeyDown}
>
{empty ? (
<span className="srcp-empty">{EMPTY_TEXT[kind]}</span>
) : (
<>
{domainChips.length > 0 && (
<span
className="lstp-lane"
role="group"
aria-label={`Step ${typedStep} — domains typed here`}
>
<span className="lstp-step" aria-hidden="true">
{typedStep}
</span>
{domainChips.map((c) => (
<span key={c.value} className="srcp-chip lstp-chip-typed" title={c.detail}>
<span className="srcp-dot srcp-dot-custom" aria-hidden="true" />
<span className="srcp-chip-name mono">{c.label}</span>
<button
type="button"
className="srcp-chip-x"
aria-label={`Remove ${c.value} from the ${w.noun} list`}
onClick={(e) => {
e.stopPropagation()
removeDomain(c.value)
}}
disabled={disabled}
>
×
</button>
</span>
))}
</span>
)}
{listChips.length > 0 && (
<span
className="lstp-lane"
role="group"
aria-label={`Step ${listStep} — attached ${w.shelf.toLowerCase()}`}
>
<span className="lstp-step" aria-hidden="true">
{listStep}
</span>
{listChips.map((c) => (
<span
key={c.name}
className="srcp-chip lstp-chip-list"
data-load={c.load.tone}
title={`${c.name} — ${c.load.detail}`}
>
<span className="srcp-dot lstp-dot" aria-hidden="true" />
<span className="srcp-chip-name mono">{c.name}</span>
<span className="lstp-load">{c.load.tag}</span>
<button
type="button"
className="srcp-chip-x"
aria-label={`Detach list ${c.name} — ${c.load.detail}`}
onClick={(e) => {
e.stopPropagation()
detachList(c.name)
}}
disabled={disabled}
>
×
</button>
</span>
))}
</span>
)}
</>
)}
<span className="srcp-caret" aria-hidden="true">
▾
</span>
</div>
{open && !disabled && (
<div className="srcp-pop" role="dialog" aria-label={`${ariaLabel} — choose lists and domains`}>
{/* Named lists ---------------------------------------------------- */}
<div className="srcp-shelf" aria-hidden="true">
<span>
{w.shelf} · step {listStep}
</span>
<span className="srcp-shelf-n">{free.length || '—'}</span>
</div>
{free.length > 0 ? (
<ul className="srcp-list lstp-list">
{free.map((o) => (
<li key={o.name}>
<button
type="button"
className="srcp-row lstp-row"
onClick={() => attachList(o.name)}
title={o.load.detail}
>
<span className="srcp-dot lstp-dot" data-load={o.load.tone} aria-hidden="true" />
<span className="srcp-row-name">{o.name}</span>
<span className="lstp-row-detail mono">{o.detail}</span>
<span className="lstp-row-tags">
{o.response === 'zero' && <span className="lstp-tag">0.0.0.0</span>}
{!o.networkEnabled && (
<span className="lstp-tag" title="Off for the network — attaching it still runs it here">
network off
</span>
)}
<span className="lstp-load" data-load={o.load.tone}>
{o.load.tag}
</span>
</span>
</button>
</li>
))}
</ul>
) : (
<p className="srcp-none">
{options.length
? `every ${w.shelf.toLowerCase().replace(/s$/, '')} is already attached`
: `no ${w.shelf.toLowerCase()} configured — add one on the DNS page`}
</p>
)}
{/* Typed domains -------------------------------------------------- */}
<div className="srcp-shelf" aria-hidden="true">
<span>
Type a domain · step {typedStep}
</span>
</div>
<div className="srcp-custom">
<input
ref={customRef}
className="srcp-custom-input mono"
type="text"
value={custom}
onChange={(e) => {
setCustom(e.target.value)
if (customErr) setCustomErr(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCustom()
}
}}
placeholder={CUSTOM_PLACEHOLDER[kind]}
aria-label={`Domain to ${w.noun} for this device`}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
className="srcp-custom-add"
onClick={addCustom}
disabled={!custom.trim()}
>
{kind === 'block' ? 'Block' : 'Allow'}
</button>
</div>
{customErr && (
<p className="srcp-note" role="alert">
{customErr}
</p>
)}
<p className="lstp-hint">
A bare entry covers the domain and its subdomains.{' '}
<span className="mono">full:</span> one exact name,{' '}
<span className="mono">keyword:</span> any host containing it.
</p>
<p className="lstp-foot">{FOOTNOTE[kind]}</p>
</div>
)}
</div>
)
}
+19
View File
@@ -2,6 +2,25 @@
* former page-local .set-select in Settings.css so promoting it to a shared
* component changed nothing visually. */
.fp-select {
/* A <select> shrink-wraps to its WIDEST OPTION and, as a flex/grid item,
* refuses to shrink below that min-content width. Nothing capped it here, so
* one long label — "Auto — country codes from SagerNet, the rest from
* Loyalsoldier" on Settings → Geo data provider — measured 501px inside a
* 375px viewport and gave the whole PAGE a horizontal scrollbar (measured:
* documentElement.scrollWidth 559 vs clientWidth 375 at a 390px window).
*
* The labels are load-bearing — they are where the coverage and cost
* difference between providers is stated — so the control is capped rather
* than the sentence shortened; truncation is the browser's job once there is a
* definite width to truncate against. Both declarations are needed: max-width
* bounds it against the containing block, min-width lets it actually shrink
* there instead of insisting on min-content.
*
* This lives on the component because every page that puts a long label in a
* dropdown inherits the same bug; Settings.css and Networks.css each carried
* their own narrow copy of this fix before it. */
max-width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--groove);
border-radius: 7px;
+2
View File
@@ -22,5 +22,7 @@ export type { ConfirmDialogProps, ConfirmOptions, ConfirmTone } from './ConfirmD
export { Clock } from './Clock'
export { CatSuggest } from './CatSuggest'
export { SrcPicker } from './SrcPicker'
export { ListPicker } from './ListPicker'
export type { ListOption, ListPickerKind, ListPickerProps } from './ListPicker'
export { ThemeSwitch } from './ThemeSwitch'
export { usePrefersReducedMotion } from './usePrefersReducedMotion'
+121
View File
@@ -0,0 +1,121 @@
// A connection the router KILLED must not read as traffic it carried.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// THE DEFECT THIS EXISTS FOR. `outbound:"block"` is a legitimate rule target and
// the value of route.Final on a fail-closed box (shater/generate/route.go), so it
// is what a kill-switch drop looks like in the connection log. The row was drawn
// as plain mono text, indistinguishable from `outbound:"nl-reality-1"` — on the
// same page where a DNS row about the same host gets a crit rail and a BLOCK
// mark. The connection log is the first place a "this site does not open" report
// is read, and the row that IS the answer looked like carried traffic.
//
// WHAT THESE PROTECT:
//
// 1. KILLED AND CARRIED MUST NOT RENDER ALIKE. Asserted on the state, the label
// AND the tooltip, because a single shared field would let two of the three
// collapse unnoticed.
// 2. THE CONTROL. The same helper must produce the ordinary reading too, or "the
// two differ" is satisfiable by a function that calls everything killed.
// 3. NOT RECORDED IS ITS OWN STATE. An absent outbound is neither a kill nor a
// carry, and it may not be drawn as either.
// 4. THE MATCH IS EXACT. `block` is reserved by an exact string comparison in
// the generator (generate/outbound.go, generate/group.go skip a node or group
// named exactly that), so a node someone named `Block` IS a real destination
// and calling it a kill would be a lie about where the traffic went.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ConnLogEntry } from './api.ts'
import { connFate, connRule } from './logRoute.ts'
const conn = (over: Partial<ConnLogEntry>): ConnLogEntry => ({
unix: 1_700_000_000,
src_ip: '192.168.1.42',
src_name: 'ipad-kids',
dest: 'blocked-ad.example',
dest_ip: '203.0.113.77',
port: 80,
network: 'tcp',
proto: 'http',
outbound: 'block',
seq: 12,
rule_kind: 'matched',
rule: 'rule_set=rs-ads',
chain: ['block'],
...over,
})
const killed = connFate(conn({ outbound: 'block' }))
const carried = connFate(conn({ outbound: 'nl-reality-1', chain: ['nl-reality-1'] }))
test('a killed connection and a carried one do not render alike', () => {
assert.notEqual(killed.fate, carried.fate, 'both connections got the same state')
assert.notEqual(killed.label, carried.label, 'both connections got the same label')
assert.notEqual(killed.title, carried.title, 'both connections got the same tooltip')
})
test('the killed row SAYS the router did not carry it', () => {
assert.equal(killed.fate, 'killed')
assert.match(killed.label, /killed/i)
assert.match(killed.title, /did NOT carry/)
// It must not name the decision — that is connRule's job beside it, and a second
// copy could only disagree with the first.
assert.doesNotMatch(killed.title, /kill-switch|rule_set/)
})
test('CONTROL: the same helper produces the ordinary carried reading', () => {
// Without this, "killed differs from carried" is satisfied by a function that
// marks every row killed.
assert.equal(carried.fate, 'carried')
assert.match(carried.title, /nl-reality-1/, 'the carried reading does not name the exit it took')
})
test('carried claims a ROUTE, never a result — the log records neither success nor health', () => {
// The engine logs the routing decision. A row that says "carried" must not be
// readable as "the flow worked", which is the failure mode the DNS log already
// paid for once (a proxied lookup that timed out drawn as the healthiest row).
assert.match(carried.title, /which WAY it went/)
// The only mention of success is a DENIAL of one. Asserted as the negated
// phrase rather than as a banned word, because banning the word would also ban
// the sentence that does the work.
assert.match(carried.title, /not whether the flow then succeeded/)
})
test('no outbound recorded is a third state, and claims nothing either way', () => {
const blank = connFate(conn({ outbound: '' }))
assert.equal(blank.fate, 'unrecorded')
for (const other of [killed, carried]) {
assert.notEqual(blank.fate, other.fate)
assert.notEqual(blank.label, other.label)
assert.notEqual(blank.title, other.title)
}
assert.match(blank.title, /NOT a claim that it was carried/)
assert.match(blank.title, /NOT a claim that it was blocked/)
})
test('the reserved tag is matched EXACTLY — a node named “Block” is a destination', () => {
// generate/outbound.go and generate/group.go reserve the tag with `==`, so a
// node named `Block` is emitted under that tag and really is where the traffic
// went. Folding the comparison here would report a kill that never happened.
assert.equal(connFate(conn({ outbound: 'Block' })).fate, 'carried')
assert.equal(connFate(conn({ outbound: 'block-list-egress' })).fate, 'carried')
assert.equal(connFate(conn({ outbound: 'noblock' })).fate, 'carried')
// …and surrounding whitespace is not a way to hide a kill.
assert.equal(connFate(conn({ outbound: ' block ' })).fate, 'killed')
})
test('the fate axis is independent of the rule axis — both readings survive together', () => {
// A kill-switch drop and a rule-target drop are both `killed`, and the DIFFERENCE
// between them is carried by connRule alone. If the fate ever started answering
// that too, the row would state it twice and could state it twice differently.
const byRule = conn({ outbound: 'block', rule_kind: 'matched', rule: 'rule_set=rs-ads' })
const byDefault = conn({ outbound: 'block', rule_kind: 'default', rule: '', chain: ['block'] })
assert.equal(connFate(byRule).fate, 'killed')
assert.equal(connFate(byDefault).fate, 'killed')
assert.equal(connFate(byRule).title, connFate(byDefault).title)
// …while the rule axis still tells them apart.
assert.notEqual(connRule(byRule).kind, connRule(byDefault).kind)
})
+81
View File
@@ -0,0 +1,81 @@
// The default route, named.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// What these protect: the Routing page's lead and empty state now NAME the route
// unmatched traffic takes, instead of calling it "the default route" and leaving
// it at that. The case that matters is the fresh install — no rules, kill-switch
// closed — where the answer is `block`, i.e. the LAN has no internet. That is the
// state the kill-switch alarm sends people to this page in, so getting it wrong
// means telling someone whose network is down that everything is following the
// default route, which is true and useless.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { effectiveTarget, liveDefaultRoute } from './defaultRoute.ts'
import type { TargetedRule } from './defaultRoute.ts'
/** Stand-in for Routing's isLiveDefault: conditionless, on, not shadowed. */
const live = (names: string[]) => (r: TargetedRule) => names.includes(r.Name)
test('no rules + fail-closed ⇒ the default route is block, and no rule owns it', () => {
const d = liveDefaultRoute([], live([]), 'closed')
assert.equal(d.target, 'block')
assert.equal(d.rule, null)
})
test('no rules + fail-open ⇒ direct', () => {
const d = liveDefaultRoute([], live([]), 'open')
assert.equal(d.target, 'direct')
assert.equal(d.rule, null)
})
test('an unreadable kill-switch value falls to the blocking side, like the daemon', () => {
// Callers pass planeState.killSwitchClosed's verdict, and its rule is
// "everything that is not literally open is closed". Anything but 'open' here
// must therefore not become 'direct'.
for (const ks of ['', 'closed', 'Closed', 'nonsense']) {
assert.equal(liveDefaultRoute([], live([]), ks).target, 'block', ks)
}
})
test('a live catch-all owns the default and is named', () => {
const rules: TargetedRule[] = [
{ Name: 'stream', Target: 'direct' },
{ Name: 'fallback', Target: 'group:eu' },
]
const d = liveDefaultRoute(rules, live(['fallback']), 'closed')
assert.equal(d.target, 'group:eu')
assert.equal(d.rule, 'fallback')
})
test('among several catch-alls the LAST one wins — the generator overwrites Final in order', () => {
const rules: TargetedRule[] = [
{ Name: 'first', Target: 'block' },
{ Name: 'last', Target: 'direct' },
]
const d = liveDefaultRoute(rules, live(['first', 'last']), 'closed')
assert.equal(d.rule, 'last')
assert.equal(d.target, 'direct')
})
test('a catch-all that is not in force does not own the default', () => {
const rules: TargetedRule[] = [{ Name: 'off', Target: 'direct' }]
// Switched off, shadowed, or overridden by the active profile — the predicate
// is the caller's, and when it says no, the kill-switch decides.
const d = liveDefaultRoute(rules, live([]), 'closed')
assert.equal(d.target, 'block')
assert.equal(d.rule, null)
})
test('a rule with only a bare Egress routes there — it is a target too', () => {
assert.equal(effectiveTarget({ Name: 'x', Egress: 'wan2' }), 'egress:wan2')
assert.equal(effectiveTarget({ Name: 'x', Target: 'group:eu', Egress: 'wan2' }), 'group:eu')
assert.equal(effectiveTarget({ Name: 'x' }), 'direct')
assert.equal(effectiveTarget({ Name: 'x', Target: ' ' }), 'direct')
// …and the default route reports it, rather than reporting `direct` at a row
// the panel draws as egress:wan2.
const d = liveDefaultRoute([{ Name: 'wan2only', Egress: 'wan2' }], live(['wan2only']), 'closed')
assert.equal(d.target, 'egress:wan2')
})
+80
View File
@@ -0,0 +1,80 @@
/**
* Where traffic that matched no rule actually goes — the one fact the routing
* page never stated.
*
* The page said "traffic that reaches the bottom follows the default route", and
* the empty state said "No rules — all traffic follows the default route". Both
* are true and neither is an answer. On a fresh install the default route is
* `block`: with no catch-all rule the generator sets `final := tagBlock`, and only
* an open kill-switch swaps that for `direct` (generate/route.go). So "no rules"
* means "the LAN has no internet", and the page described it as a routine
* fallback.
*
* The omission is expensive in one specific place: the kill-switch alarm SENDS
* PEOPLE HERE — its banner says "Add a default rule on the Routing page". Someone
* whose network has just gone down arrives, reads that everything follows the
* default route, and cannot learn from this screen that the default route is what
* took it down.
*
* It lives outside `pages/Routing.tsx` so it can be tested: the panel's runner is
* `node --test src/*.test.ts`, plain modules only (same reason as ruleset.ts).
*/
/** The rule shape this module needs. Structurally a subset of Routing's RRule. */
export interface TargetedRule {
Name: string
Target?: string
Egress?: string
}
/**
* A rule's effective routing target: `Target` wins, and a bare `Egress` is a
* target too.
*
* The last clause is the daemon's (model.EffectiveRuleTarget, mirrored by
* generate's effectiveRuleTarget). A rule carrying only `option egress wan2` used
* to fall through to resolveTarget("") == "direct" and leave over the DEFAULT WAN
* while the panel showed `egress:wan2` — a silent mis-route on exactly the
* multi-WAN setups the field exists for. Reading it the same way here is what
* keeps the panel's answer and the router's answer the same answer.
*/
export function effectiveTarget(r: TargetedRule): string {
if (r.Target && r.Target.trim()) return r.Target.trim()
if (r.Egress && r.Egress.trim()) return `egress:${r.Egress.trim()}`
return 'direct'
}
/** What the router does with unmatched traffic, and which rule decided it. */
export interface DefaultRoute {
/** The outbound tag: a rule's target, or the kill-switch's `block` / `direct`. */
target: string
/** The rule that owns route.Final, or null when the kill-switch decides. */
rule: string | null
}
/**
* The default route in force right now.
*
* `isLive` is the caller's own "is this rule the engine's route.Final" predicate
* (Routing's isLiveDefault: conditionless, not shadowed, in force after the
* active profile has had its say). It is a parameter and not a reimplementation
* because the badge on the row, the delete confirmation and this sentence must
* name the SAME rule — three copies of that predicate is how they came to name
* three different ones.
*
* The scan runs backwards because the generator overwrites `Final` as it walks
* the list in order: among several conditionless rules the LAST one wins.
*/
export function liveDefaultRoute<T extends TargetedRule>(
rules: readonly T[],
isLive: (r: T) => boolean,
killSwitch: string,
): DefaultRoute {
for (let i = rules.length - 1; i >= 0; i--) {
if (isLive(rules[i])) return { target: effectiveTarget(rules[i]), rule: rules[i].Name }
}
// No rule claims it, so generate/route.go's own default stands: `block`, unless
// the kill-switch is open. Normalised the daemon's way (planeState.killSwitchClosed
// is the canonical comparison; callers pass its verdict as 'open' / 'closed').
return { target: killSwitch === 'open' ? 'direct' : 'block', rule: null }
}
+323
View File
@@ -0,0 +1,323 @@
// Per-device domain policy — the three things this panel is not allowed to get
// wrong about a parental control.
//
// Run with `npm test` (node's built-in runner + native type stripping).
// deviceLists.ts has no runtime imports, so this runs against the real module.
//
// THE CASES THESE WERE WRITTEN FOR, each one a control that looked applied and
// was not:
//
// 1. The form 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. Opening the charset instead of listing the prefixes would
// have been worse: a lone `keyword:` is strings.Contains(host, "") — true
// for EVERY host — and a bare `keyword:` in a child's Block list takes that
// device off the internet with no error anywhere.
//
// 2. A chip said "attached", which is not evidence of anything. A list whose
// fetch never succeeded blocks nothing, and has to look like it.
//
// 3. "Blocklists below are configured but inactive" stopped being true the day
// a device could attach one — attaching IS the switch for that device.
//
// Each test therefore has a POSITIVE and a NEGATIVE half where one exists: a
// reading that only ever says "bad" proves as little as one that only says "good".
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
attachedDeviceNames,
compactCount,
describeDomainEntry,
devicesWithAttachedLists,
dnsFilterOffNote,
listLoad,
listRowState,
nameList,
parseDomainEntry,
} from './deviceLists.ts'
import type { Device, RulesetStatus } from './api.ts'
// ---------------------------------------------------------------------------
// 1. What the form accepts
// ---------------------------------------------------------------------------
test('the three engine prefixes are typeable — the panel is no longer poorer than the engine', () => {
const full = parseDomainEntry('full:Discord.com')
assert.deepEqual(full, { ok: true, value: 'full:discord.com', kind: 'full' })
const kw = parseDomainEntry('keyword:TikTok')
assert.deepEqual(kw, { ok: true, value: 'keyword:tiktok', kind: 'keyword' })
// `suffix:` and a bare entry build the SAME matcher in generate/dnsfilter.go,
// so they must canonicalise to one stored value — otherwise the two spellings
// read as two different rules and the duplicate check misses.
const suf = parseDomainEntry('suffix:Example.COM')
assert.deepEqual(suf, { ok: true, value: 'example.com', kind: 'suffix' })
assert.equal(parseDomainEntry('example.com').ok && parseDomainEntry('example.com').value, 'example.com')
assert.equal(parseDomainEntry('.example.com').ok && parseDomainEntry('.example.com').value, 'example.com')
assert.equal(parseDomainEntry('*.example.com').ok && parseDomainEntry('*.example.com').value, 'example.com')
})
test('a bare marker is refused, and the keyword one is refused for the reason that matters', () => {
const kw = parseDomainEntry('keyword:')
assert.equal(kw.ok, false)
// Not a generic "invalid entry": an empty keyword matches every host, so the
// message has to say what accepting it would do.
assert.match(kw.ok === false ? kw.reason : '', /every host/i)
assert.match(kw.ok === false ? kw.reason : '', /off the internet/i)
for (const bad of ['full:', 'suffix:', 'full: ', 'keyword: ']) {
assert.equal(parseDomainEntry(bad).ok, false, `${bad} must be refused`)
}
// A lone dot is not an entry either — bareDomain strips it to nothing.
assert.equal(parseDomainEntry('.').ok, false)
assert.equal(parseDomainEntry('').ok, false)
})
test('an unrecognised prefix is refused and named, because a domain cannot contain a colon', () => {
const r = parseDomainEntry('regexp:.*ads.*')
assert.equal(r.ok, false)
assert.match(r.ok === false ? r.reason : '', /regexp:/)
assert.match(r.ok === false ? r.reason : '', /full:, suffix: or keyword:/)
const g = parseDomainEntry('geosite:youtube')
assert.equal(g.ok, false)
assert.match(g.ok === false ? g.reason : '', /geosite:/)
})
test('an address is refused as an address, not mistaken for a prefixed entry', () => {
for (const addr of ['192.168.1.10', '10.0.0.0/8', '2001:db8::1', 'fe80::1']) {
const r = parseDomainEntry(addr)
assert.equal(r.ok, false, `${addr} must be refused`)
assert.match(r.ok === false ? r.reason : '', /IP address/i, addr)
}
})
test('a keyword may only be hostname material, and a stored entry reads back as what it does', () => {
assert.equal(parseDomainEntry('keyword:has space').ok, false)
assert.equal(parseDomainEntry('keyword:a:b').ok, false)
assert.equal(describeDomainEntry('example.com').detail, 'example.com and its subdomains')
assert.equal(describeDomainEntry('full:example.com').kind, 'full')
assert.match(describeDomainEntry('full:example.com').detail, /subdomains are not matched/)
assert.equal(describeDomainEntry('keyword:tiktok').kind, 'keyword')
assert.match(describeDomainEntry('keyword:tiktok').detail, /containing/)
// An unknown prefix already in the config is still shown, verbatim — the chip
// is not the place to make a stored value disappear.
assert.equal(describeDomainEntry('weird:thing').label, 'weird:thing')
})
// ---------------------------------------------------------------------------
// 2. Did the attached list load?
// ---------------------------------------------------------------------------
const st = (o: Partial<RulesetStatus>): RulesetStatus => ({
tag: 'bl-x',
name: 'x',
category: '',
kind: 'blocklist',
remote: true,
last_updated: '',
interval_seconds: 86_400,
rule_count: 0,
...o,
})
test('the load reading gives a POSITIVE result — a fetched list with rules is green and counted', () => {
const r = listLoad([st({ last_updated: '2026-07-26T10:00:00Z', rule_count: 218_431 })], true)
assert.equal(r.tone, 'on')
assert.equal(r.tag, '218k')
assert.equal(r.ruleCount, 218_431)
// CONTROL that the green branch is reachable at all, and on a real payload:
// only a remote set carries a count, so this is the shape the daemon really
// sends for a list that loaded and holds entries.
const small = listLoad([st({ last_updated: '2026-07-26T10:00:00Z', rule_count: 3 })], true)
assert.equal(small.tone, 'on')
assert.equal(small.tag, '3')
})
test('an INLINE list has no entry count, and "not published" is not "empty"', () => {
// THE DEFECT, AND THE BROKEN INSTRUMENT THAT HID IT. engine.go fills
// RuleSetStat.RuleCount only for *rule.RemoteRuleSet; `LocalRuleSet.RuleCount`
// does not exist anywhere in the tree. So an inline list ALWAYS arrives with
// rule_count 0, and the panel called a perfectly working list
// "empty — nothing matches".
//
// The old test for this asked `st({remote:false, rule_count:3})` — a record the
// daemon cannot produce. A fixture of an impossible state is not a control; it
// is a green light wired to nothing.
const local = listLoad([st({ remote: false, last_updated: '', rule_count: 0 })], true)
assert.equal(local.tone, 'unknown', 'unlit: not a fault, and not a confirmation either')
assert.equal(local.tag, 'size unknown')
assert.notEqual(local.tag, 'empty', 'the list may hold five hundred entries — nobody said')
assert.doesNotMatch(local.detail, /nothing matches/)
assert.notEqual(local.tone, 'on', 'and an unknown is never drawn as healthy')
})
test('CONTROL: a REMOTE list that really is empty still says so', () => {
// Without this, "a zero count is unknown" would also be satisfied by deleting
// the empty state outright — and a fetched list that genuinely came back with
// no entries is a real finding the operator needs.
const empty = listLoad([st({ remote: true, last_updated: '2026-07-26T10:00:00Z', rule_count: 0 })], true)
assert.equal(empty.tone, 'warn')
assert.equal(empty.tag, 'empty')
assert.notEqual(empty.tone, listLoad([st({ remote: false, rule_count: 0 })], true).tone)
})
test('a MIXED group is counted as a floor, not as a whole', () => {
// One name resolving to a remote set and an inline one: the sum covers only the
// part that reports. Printing it bare would understate the list while looking
// exact.
const mixed = listLoad(
[
st({ category: 'ads', last_updated: '2026-07-26T10:00:00Z', rule_count: 1_284 }),
st({ category: '', remote: false, last_updated: '', rule_count: 0 }),
],
true,
)
assert.equal(mixed.tone, 'on')
assert.equal(mixed.tag, '1,284+', 'the plus is the whole point')
assert.match(mixed.detail, /At least/)
})
test('and a NEGATIVE one — never fetched, empty, unreported and unknown are four different things', () => {
assert.equal(listLoad([st({ last_updated: '', rule_count: 0 })], true).tone, 'crit')
assert.equal(listLoad([st({ last_updated: '', rule_count: 0 })], true).tag, 'not loaded')
assert.equal(listLoad([st({ last_updated: '2026-07-26T10:00:00Z', rule_count: 0 })], true).tone, 'warn')
assert.equal(listLoad([st({ last_updated: '2026-07-26T10:00:00Z', rule_count: 0 })], true).tag, 'empty')
// Nothing reported is UNKNOWN, never green.
assert.equal(listLoad([], true).tone, 'unknown')
assert.equal(listLoad(null, true).tone, 'unknown')
assert.notEqual(listLoad(null, true).tone, 'on')
// A device pointing at a list the config no longer has.
assert.equal(listLoad([st({ rule_count: 9 })], false).tone, 'crit')
assert.equal(listLoad([st({ rule_count: 9 })], false).tag, 'no such list')
})
test('a geo list cannot hide a category that never arrived behind a healthy sibling', () => {
const r = listLoad(
[
st({ category: 'youtube', last_updated: '2026-07-26T10:00:00Z', rule_count: 1_284 }),
st({ category: 'google', last_updated: '', rule_count: 0 }),
],
true,
)
assert.equal(r.tone, 'crit')
assert.equal(r.tag, 'not loaded')
})
test('the chip count stays chip-sized', () => {
assert.equal(compactCount(3), '3')
assert.equal(compactCount(9_999), '9,999')
assert.equal(compactCount(218_431), '218k')
})
// ---------------------------------------------------------------------------
// 3. What the DNS page may claim
// ---------------------------------------------------------------------------
const dev = (o: Partial<Device>): Device => ({ Name: 'd', Enabled: true, ...o })
test('with nothing attached the DNS page keeps its old off-text', () => {
assert.equal(dnsFilterOffNote(null), null)
assert.equal(dnsFilterOffNote([]), null)
assert.equal(dnsFilterOffNote([dev({ Name: 'Max', Block: ['a.com'] })]), null)
})
test('one attached list makes "configured but inactive" a lie, and the replacement names who', () => {
const one = dnsFilterOffNote([dev({ Name: 'Kids iPad', Blocklists: ['family-extra'] })])
assert.ok(one)
assert.match(one, /1 device — Kids iPad —/)
assert.match(one, /still apply to it/)
assert.doesNotMatch(one, /configured but inactive/)
const many = dnsFilterOffNote([
dev({ Name: 'Max laptop', Blocklists: ['oisd-basic'] }),
dev({ Name: "Lena's phone", Allowlists: ['school-allow'] }),
dev({ Name: 'Kids iPad', Blocklists: ['family-extra'] }),
dev({ Name: 'Tablet', Blocklists: ['family-extra'] }),
])
assert.ok(many)
assert.match(many, /4 devices/)
assert.match(many, /Max laptop, Lena's phone, Kids iPad and 1 more/)
})
test('a paused device is not counted — the engine emits nothing for it', () => {
const devices = [
dev({ Name: 'Kids iPad', Enabled: false, Blocklists: ['family-extra'] }),
dev({ Name: 'Max laptop', Blocklists: ['oisd-basic'] }),
]
assert.deepEqual(devicesWithAttachedLists(devices), ['Max laptop'])
assert.deepEqual(attachedDeviceNames(devices, 'blocklist', 'family-extra'), [])
assert.deepEqual(attachedDeviceNames(devices, 'blocklist', 'oisd-basic'), ['Max laptop'])
// The kinds do not bleed into each other.
assert.deepEqual(attachedDeviceNames(devices, 'allowlist', 'oisd-basic'), [])
})
test('nameList caps the names and then counts', () => {
assert.equal(nameList([]), '')
assert.equal(nameList(['A']), 'A')
assert.equal(nameList(['A', 'B']), 'A and B')
assert.equal(nameList(['A', 'B', 'C']), 'A, B and C')
assert.equal(nameList(['A', 'B', 'C', 'D', 'E']), 'A, B, C and 2 more')
})
// ---------------------------------------------------------------------------
// 3b. The blocklist row's own state tag
// ---------------------------------------------------------------------------
const rowIn = (o: Partial<Parameters<typeof listRowState>[0]> = {}) => ({
enabled: true,
filterOn: true,
remote: true,
hasStatus: true,
neverAny: false,
ruleCount: 100,
attached: 0,
...o,
})
test('with nothing attached the row keeps exactly the readings it had', () => {
assert.deepEqual(listRowState(rowIn({ enabled: false })), { text: 'off', tone: 'off' })
assert.deepEqual(listRowState(rowIn({ filterOn: false })), { text: 'inactive', tone: 'off' })
assert.deepEqual(listRowState(rowIn({ remote: false })), { text: 'filtering', tone: 'on' })
assert.deepEqual(listRowState(rowIn({ hasStatus: false })), { text: 'load not reported', tone: 'off' })
assert.deepEqual(listRowState(rowIn({ neverAny: true })), {
text: 'not loaded — nothing blocked',
tone: 'warn',
})
assert.deepEqual(listRowState(rowIn({ ruleCount: 0 })), {
text: 'loaded empty — nothing blocked',
tone: 'warn',
})
assert.deepEqual(listRowState(rowIn()), { text: 'filtering', tone: 'on' })
})
test('a list off for the network but attached to devices is neither "off" nor "filtering"', () => {
// Switched off for the network, attached to two devices, and loaded: it works,
// for exactly those two.
assert.deepEqual(listRowState(rowIn({ enabled: false, attached: 2 })), {
text: '2 devices only',
tone: 'on',
})
assert.deepEqual(listRowState(rowIn({ filterOn: false, attached: 1 })), {
text: '1 device only',
tone: 'on',
})
// Attached but never fetched — the count must not upgrade a broken list.
assert.deepEqual(listRowState(rowIn({ enabled: false, attached: 2, neverAny: true })), {
text: 'not loaded — nothing blocked',
tone: 'warn',
})
// Attached and unreported stays unknown, and says who is relying on it.
assert.deepEqual(listRowState(rowIn({ enabled: false, attached: 1, hasStatus: false })), {
text: '1 device only · load not reported',
tone: 'off',
})
})
+411
View File
@@ -0,0 +1,411 @@
// Per-device domain policy: the parts of the Devices / DNS pages that must be
// TESTED, and therefore cannot live inside a .tsx file — `npm test` is
// `node --test src/*.test.ts`, plain modules, no JSX and no DOM. Same reasoning
// as `dnsListEdit.ts`, `ruleset.ts` and `planeState.ts`; the imports here are
// type-only, so the tests run against the real code with nothing stubbed.
//
// Three separate jobs live here, and they share one theme — a per-device rule is
// the one place in this panel where a control that LOOKS applied and is not gets
// read as "the kid is protected".
//
// 1. parseDomainEntry — what the form is allowed to accept. The panel used to
// demand /^[a-z0-9.-]+$/, so a colon was impossible to type and the engine's
// own `full:` / `suffix:` / `keyword:` vocabulary could not be reached from
// the UI at all. Opening the charset would have been worse: the engine drops
// an unrecognised `word:` prefix, drops a marker with no value, and an empty
// `keyword:` is strings.Contains(host, "") — TRUE FOR EVERY HOST, i.e. a
// lone "keyword:" in a child's Block list takes that device off the internet
// with no error anywhere (generate/devices.go:145-175). So the prefixes are
// a closed positive list and a marker without a value is refused by name.
//
// 2. listLoad — whether an attached named list actually materialised, read off
// /api/ruleset/status rather than off the fact that someone attached it. A
// list whose URL is unreachable is a parental control that does not work,
// and it has to look like one.
//
// 3. attachedDeviceNames / dnsFilterOffNote / listRowState — the DNS page's
// sentences, which stop being true the moment a list is attached to a
// device: `Blocklist.Enabled` means "participates in the NETWORK filter",
// not "this object is alive", and a device that references a list runs it
// whatever the network switch says.
import type { Device, RulesetStatus } from './api'
// ---------------------------------------------------------------------------
// 1. Domain entries
// ---------------------------------------------------------------------------
/**
* The `word:` prefixes a DOMAIN list understands — positive and closed, mirroring
* `recognisedDomainMarkers` in generate/dnsfilter.go. Anything else is provably
* unmatchable (a domain name cannot contain ":") and the engine drops it, so the
* form must refuse it instead of storing something that can only ever do nothing.
*
* `regexp:` and `geosite:` are absent ON PURPOSE, not by oversight: the DNS-filter
* path validates neither, and both are already expressed properly elsewhere (an
* inline rule-set, and a geosite-sourced list with category chips).
*/
export const DOMAIN_MARKERS = ['full', 'suffix', 'keyword'] as const
export type DomainMarker = (typeof DOMAIN_MARKERS)[number]
const MARKER_SET: ReadonlySet<string> = new Set<string>(DOMAIN_MARKERS)
/** How a stored entry matches. `suffix` is what a bare entry means in a list. */
export type DomainEntryKind = DomainMarker
export type DomainParse =
| { ok: true; value: string; kind: DomainEntryKind }
| { ok: false; reason: string }
/** A leading `word:` marker. The word must START WITH A LETTER, so a numeric IPv6
* group can never look like one (generate/dnsfilter.go domainMarkerRe). */
const MARKER_RE = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/
/** The characters a hostname (or a substring of one) can be built from. */
const HOST_RE = /^[a-z0-9.-]+$/
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/
const IPV6_RE = /^[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?:\/\d{1,3})?$/i
/** Strip the wildcard/dot decorations a person types around a domain. A LEADING
* DOT IS A SYNONYM OF `suffix:`, not a narrower matcher — the engine strips it
* too, so ".example.com" and "example.com" are the same entry. */
function bareDomain(s: string): string {
return s
.replace(/^\*\./, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
}
/**
* Parse one typed entry for a device's Block/Allow list, or explain the refusal.
*
* Accepts: a bare domain (matches it and its subdomains), `full:<domain>` (that
* exact name), `suffix:<domain>` (identical to bare — canonicalised to bare, as
* the engine does), `keyword:<text>` (substring of the hostname). A leading `*.`
* or `.` is stripped.
*
* Refuses, by name: an IP address, an unknown `word:` prefix, and a marker with
* no value — the three shapes the engine silently discards.
*/
export function parseDomainEntry(raw: string): DomainParse {
const trimmed = raw.trim()
if (!trimmed) return { ok: false, reason: 'Enter a domain like example.com.' }
// Addresses first: they are not domains, and an IPv6 literal is full of colons
// so it would otherwise be mistaken for a prefixed entry.
if (IPV4_RE.test(trimmed) || IPV6_RE.test(trimmed)) {
return {
ok: false,
reason: 'That is an IP address. A device list matches names — route addresses with a Routing rule instead.',
}
}
const m = MARKER_RE.exec(trimmed)
if (m) {
const marker = m[1].toLowerCase()
const value = m[2].trim()
if (!MARKER_SET.has(marker)) {
return {
ok: false,
reason: `“${m[1]}:” is not a matcher, and a domain name cannot contain “:” — this entry could never match. Use full:, suffix: or keyword:.`,
}
}
if (!value) {
return {
ok: false,
reason:
marker === 'keyword'
? 'keyword: needs text after it. An empty keyword matches EVERY host — it would take this device off the internet entirely.'
: `${marker}: needs a domain after it. The engine drops an empty matcher, and an empty domain token stops it from starting.`,
}
}
if (marker === 'keyword') {
const kw = value.toLowerCase()
if (!HOST_RE.test(kw)) {
return {
ok: false,
reason: 'A keyword is a piece of a hostname — letters, digits, dots and dashes only.',
}
}
return { ok: true, value: `keyword:${kw}`, kind: 'keyword' }
}
const dom = bareDomain(value.toLowerCase())
if (!dom || !HOST_RE.test(dom)) {
return { ok: false, reason: `Enter a domain after ${marker}:, like ${marker}:example.com.` }
}
// `suffix:` and a bare entry produce the SAME matcher, so store one shape —
// otherwise the two spellings look like different rules and dedupe misses.
return marker === 'suffix'
? { ok: true, value: dom, kind: 'suffix' }
: { ok: true, value: `full:${dom}`, kind: 'full' }
}
const dom = bareDomain(trimmed.toLowerCase())
if (!dom || !HOST_RE.test(dom)) {
return {
ok: false,
reason: 'Enter a domain like example.com, or full: / suffix: / keyword: with a value after it.',
}
}
return { ok: true, value: dom, kind: 'suffix' }
}
/** Read a STORED entry back, for the chip label and its hover text. Never
* refuses: a value already in the config is shown as what the engine will make
* of it, not hidden. */
export function describeDomainEntry(stored: string): { label: string; kind: DomainEntryKind; detail: string } {
const m = MARKER_RE.exec(stored.trim())
if (m && MARKER_SET.has(m[1].toLowerCase())) {
const marker = m[1].toLowerCase() as DomainMarker
const value = m[2].trim()
if (marker === 'keyword') {
return { label: stored, kind: 'keyword', detail: `any hostname containing “${value}”` }
}
if (marker === 'full') {
return { label: stored, kind: 'full', detail: `exactly ${value} — subdomains are not matched` }
}
return { label: value, kind: 'suffix', detail: `${value} and its subdomains` }
}
return { label: stored, kind: 'suffix', detail: `${stored} and its subdomains` }
}
// ---------------------------------------------------------------------------
// 2. Did the attached list actually load?
// ---------------------------------------------------------------------------
/** on = materialised; warn = present but matches nothing; crit = not working;
* unknown = the engine has not said, which is not the same as fine. */
export type LoadTone = 'on' | 'warn' | 'crit' | 'unknown'
export interface ListLoad {
tone: LoadTone
/** The short tag printed on the chip. */
tag: string
/** One sentence, for the chip's title and its aria description. */
detail: string
ruleCount: number
}
/** 218431 -> "218k"; 1284 -> "1,284". Chips are narrow and a device card holds
* several of them. */
export function compactCount(n: number): string {
if (n < 10_000) return n.toLocaleString('en-US')
return `${Math.round(n / 1000).toLocaleString('en-US')}k`
}
/**
* What the running engine reports about one named list, grouped by name (a
* geosite list with N categories emits N records).
*
* `known` is whether the config still HAS a list by that name: a device pointing
* at a deleted list is the loudest possible failure and gets said first.
*
* The order of the remaining checks matters. "never fetched" outranks "no rules",
* so a geo list whose second category never arrived cannot hide behind a healthy
* sibling; "nothing reported" is its own state and is never drawn as healthy.
*/
export function listLoad(
statuses: RulesetStatus[] | null | undefined,
known: boolean,
): ListLoad {
if (!known) {
return {
tone: 'crit',
tag: 'no such list',
detail: 'This device points at a list that is not in the config — it filters nothing.',
ruleCount: 0,
}
}
const recs = statuses ?? []
if (recs.length === 0) {
return {
tone: 'unknown',
tag: 'load unknown',
detail: 'The engine has not reported this list — it may not have been applied yet. Unknown, not fine.',
ruleCount: 0,
}
}
let ruleCount = 0
let neverAny = false
// How many of these records CAN report a count. Only remote sets do: the engine
// fills RuleSetStat.RuleCount from (*rule.RemoteRuleSet).RuleCount(), and a
// local set has no such method at all — `LocalRuleSet.RuleCount` does not exist
// in the tree. So a local record's zero is the field never being written, not a
// list with nothing in it, and summing the two kinds together silently turns
// "not published" into "empty".
let counted = 0
for (const s of recs) {
if (s.remote) {
counted++
ruleCount += s.rule_count
}
if (s.remote && !s.last_updated) neverAny = true
}
if (neverAny) {
return {
tone: 'crit',
tag: 'not loaded',
detail: 'The engine has never fetched this list, so it matches nothing for this device.',
ruleCount,
}
}
// NOTHING HERE COULD BE COUNTED. An inline or file-backed list is reported by
// the engine — so it exists in the running box — but the daemon publishes no
// size for it, ever. This used to read "empty · nothing matches", which is a
// verdict about a working list built entirely out of a field that is never
// filled in. It is an unknown, and it is drawn as one: unlit, never green and
// never the amber that says something is wrong.
if (counted === 0) {
return {
tone: 'unknown',
tag: 'size unknown',
detail:
'This list is in the running engine, but the daemon publishes an entry count only for lists it fetches from a URL — so how many entries an inline or file list holds is not reported. Not a fault, and not a confirmation that it matches anything.',
ruleCount: 0,
}
}
if (ruleCount === 0) {
return {
tone: 'warn',
tag: 'empty',
detail: 'Loaded, but it holds no entries — nothing matches.',
ruleCount: 0,
}
}
// A group whose records are a MIX (a geosite name resolving to several sets,
// one of them local) can only be counted in part, so the number is a floor and
// says so rather than presenting a partial sum as the whole.
const partial = counted < recs.length
return {
tone: 'on',
tag: partial ? `${compactCount(ruleCount)}+` : compactCount(ruleCount),
detail: partial
? `At least ${ruleCount.toLocaleString('en-US')} entries loaded and matching; the inline part of this list is not counted by the daemon.`
: `${ruleCount.toLocaleString('en-US')} entries loaded and matching.`,
ruleCount,
}
}
// ---------------------------------------------------------------------------
// 3. What the DNS page may claim once a list is attached to a device
// ---------------------------------------------------------------------------
export type ListKind = 'blocklist' | 'allowlist'
/** Which of a device's list slots a kind reads. */
function slotOf(d: Device, kind: ListKind): string[] {
const raw = kind === 'blocklist' ? d.Blocklists : d.Allowlists
return raw ?? []
}
/**
* The ENABLED devices attaching `name`, in config order.
*
* Enabled-only because generate/devices.go resolves only enabled devices — a
* paused device emits no rule at all, so counting it would put a number on the
* DNS page that nothing on the router agrees with.
*/
export function attachedDeviceNames(
devices: Device[] | null | undefined,
kind: ListKind,
name: string,
): string[] {
const out: string[] = []
for (const d of devices ?? []) {
if (d.Enabled === false) continue
if (slotOf(d, kind).includes(name)) out.push(d.Name)
}
return out
}
/** Every enabled device attaching at least one list of either kind, in config
* order, deduped by name. */
export function devicesWithAttachedLists(devices: Device[] | null | undefined): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const d of devices ?? []) {
if (d.Enabled === false) continue
if (slotOf(d, 'blocklist').length === 0 && slotOf(d, 'allowlist').length === 0) continue
const nm = d.Name || '(unnamed device)'
if (seen.has(nm)) continue
seen.add(nm)
out.push(nm)
}
return out
}
/** "A, B and C" / "A, B, C and 2 more" — at most three names, then a count. */
export function nameList(names: string[], max = 3): string {
if (names.length === 0) return ''
if (names.length === 1) return names[0]
const head = names.slice(0, max)
const rest = names.length - head.length
if (rest > 0) return `${head.join(', ')} and ${rest} more`
return `${head.slice(0, -1).join(', ')} and ${head[head.length - 1]}`
}
/**
* The DNS page's third sentence for the master switch.
*
* The two it had were "on: lists are answering" and "off: the lists below are
* configured but inactive". The second becomes a lie the moment a device attaches
* one, because attaching is itself the switch for that device. Returns null when
* no device attaches anything, so the caller keeps the plain off-text.
*/
export function dnsFilterOffNote(devices: Device[] | null | undefined): string | null {
const names = devicesWithAttachedLists(devices)
if (names.length === 0) return null
const who = nameList(names)
return names.length === 1
? `Network-wide filtering is off, but 1 device — ${who} — has lists attached on the Devices page, and those still apply to it. Turn this on to filter DNS for every device.`
: `Network-wide filtering is off, but ${names.length} devices — ${who} — have lists attached on the Devices page, and those still apply to them. Turn this on to filter DNS for every device.`
}
/**
* The state tag on a blocklist/allowlist row.
*
* Extracted from DNS.tsx so it can be tested, and extended with one fact the
* inline version could not know: `attached`. A list switched off for the network
* but referenced by a device is neither "off" nor "filtering" — it runs, for
* exactly those devices, and only if it actually loaded.
*/
export interface ListRowStateIn {
/** Blocklist/Allowlist.Enabled — participates in the NETWORK filter. */
enabled: boolean
/** Globals.DNSFilter. */
filterOn: boolean
/** url/geosite lists are fetched; inline/file are read straight from config. */
remote: boolean
hasStatus: boolean
neverAny: boolean
ruleCount: number
/** How many ENABLED devices attach this list. */
attached: number
}
export function listRowState(i: ListRowStateIn): { text: string; tone: 'on' | 'off' | 'warn' } {
const networkOn = i.enabled && i.filterOn
if (!networkOn && i.attached === 0) {
return i.enabled ? { text: 'inactive', tone: 'off' } : { text: 'off', tone: 'off' }
}
const load: 'ok' | 'unknown' | 'never' | 'empty' = !i.remote
? 'ok'
: !i.hasStatus
? 'unknown'
: i.neverAny
? 'never'
: i.ruleCount === 0
? 'empty'
: 'ok'
// Who is still using it, when the network is not.
const who = networkOn ? '' : `${i.attached} device${i.attached === 1 ? '' : 's'} only`
switch (load) {
case 'unknown':
return { text: who ? `${who} · load not reported` : 'load not reported', tone: 'off' }
case 'never':
return { text: 'not loaded — nothing blocked', tone: 'warn' }
case 'empty':
return { text: 'loaded empty — nothing blocked', tone: 'warn' }
case 'ok':
return { text: who || 'filtering', tone: 'on' }
}
}
+273
View File
@@ -0,0 +1,273 @@
// The DNS page's edit-after-create merges.
//
// Run with `npm test` (node's built-in test runner + native type stripping).
// dnsListEdit.ts has no runtime imports, so this runs against the real module.
//
// THE CASE THESE WERE WRITTEN FOR: before this, a blocklist, an allowlist and a
// resolver could only be configured at CREATION. A typo in a URL meant deleting
// the list and building it again; a resolver's address could not be corrected at
// all — only its detour. Adding the editors introduces the failure `ruleset.ts`
// already documents: a form that REBUILDS an object drops every field it does
// not render, silently, at the moment someone renames something.
//
// So each test below fixes one field the form must carry through, and one field
// switching source must genuinely clear. The compile-time half is `Complete<T>`
// on the return types: a new field in api.ts fails the BUILD in the function that
// has to decide about it, which no runtime test can do.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
allowlistToForm,
blocklistToForm,
isRemoteListSource,
nextAllowlist,
nextBlocklist,
nextResolver,
parseDomains,
resolverNeedsAddress,
resolverToForm,
} from './dnsListEdit.ts'
import type { Allowlist, Blocklist, Resolver } from './api.ts'
// ---- blocklists -------------------------------------------------------------
test('renaming a url blocklist keeps the refresh interval it was given', () => {
// A list written over SSH with a fast cadence, because it is the one that keeps
// breaking a site. The panel pinned every list to 24h and showed no control, so
// this value had nowhere to come back from once dropped.
const stored: Blocklist = {
Name: 'oisd',
Enabled: true,
Source: 'url',
URL: 'https://big.oisd.nl',
Response: 'nxdomain',
UpdateInterval: '1h',
}
const form = blocklistToForm(stored)
const out = nextBlocklist({ ...form, name: 'oisd-big' })
assert.equal(out.Name, 'oisd-big')
assert.equal(out.UpdateInterval, '1h', 'the interval must survive a rename')
assert.equal(out.URL, 'https://big.oisd.nl')
assert.equal(out.Response, 'nxdomain')
})
test('a blocklist keeps its non-default reply through an unrelated edit', () => {
const stored: Blocklist = {
Name: 'ads',
Enabled: true,
Source: 'url',
URL: 'https://example.org/ads.txt',
Response: 'zero',
UpdateInterval: '24h',
}
const out = nextBlocklist({ ...blocklistToForm(stored), url: 'https://example.org/ads-v2.txt' })
assert.equal(out.Response, 'zero', 'a 0.0.0.0 list must not silently become NXDOMAIN')
assert.equal(out.URL, 'https://example.org/ads-v2.txt')
})
test('a file blocklist keeps its path — the source the add form never offered', () => {
// `file` cannot be created in the panel, only over SSH, so a form that dropped
// Path would make every such list unopenable without destroying it.
const stored: Blocklist = {
Name: 'local',
Enabled: true,
Source: 'file',
Path: '/etc/shater/blocked.lst',
Response: 'nxdomain',
}
const out = nextBlocklist({ ...blocklistToForm(stored), name: 'local-hosts' })
assert.equal(out.Path, '/etc/shater/blocked.lst')
assert.equal(out.Source, 'file')
})
test('a geosite blocklist keeps every category chip', () => {
const stored: Blocklist = {
Name: 'trackers',
Enabled: true,
Source: 'geosite',
Categories: ['category-ads-all', 'win-spy'],
Response: 'nxdomain',
UpdateInterval: '12h',
}
const out = nextBlocklist({ ...blocklistToForm(stored), name: 'trackers-2' })
assert.deepEqual(out.Categories, ['category-ads-all', 'win-spy'])
assert.equal(out.UpdateInterval, '12h')
})
test('switching a blocklist from url to inline really does drop the url', () => {
// The other half of the contract. A merge that only ever preserves would leave
// the config carrying a URL the new source ignores.
const stored: Blocklist = {
Name: 'ads',
Enabled: true,
Source: 'url',
URL: 'https://example.org/ads.txt',
UpdateInterval: '6h',
}
const out = nextBlocklist({
...blocklistToForm(stored),
source: 'inline',
entriesText: 'ads.example.com',
})
assert.equal(out.URL, undefined)
assert.deepEqual(out.Entries, ['ads.example.com'])
assert.equal(
out.UpdateInterval,
undefined,
'an inline list is never fetched, so it must not carry a refresh cadence',
)
})
test('an emptied inline list writes no Entries key rather than an empty one', () => {
const stored: Blocklist = { Name: 'manual', Enabled: true, Source: 'inline', Entries: ['a.test'] }
const out = nextBlocklist({ ...blocklistToForm(stored), entriesText: ' ' })
assert.equal(out.Entries, undefined)
})
// ---- allowlists -------------------------------------------------------------
test('an allowlist carries an update interval at all — the field it never had', () => {
// The asymmetry this closes: an allowlist is HOW a blocklist's false positive
// is corrected. Pinned to 24h while the blocklist that broke the site refreshes
// faster, the fix lands up to a day after the breakage.
const stored: Allowlist = {
Name: 'work',
Enabled: true,
Source: 'url',
URL: 'https://example.org/allow.txt',
UpdateInterval: '30m',
}
const form = allowlistToForm(stored)
assert.equal(form.interval, '30m', 'the stored interval must reach the form')
const out = nextAllowlist({ ...form, name: 'work-allow' })
assert.equal(out.Name, 'work-allow')
assert.equal(out.UpdateInterval, '30m', 'and survive a rename')
})
test('an allowlist rebuild emits no Response — the field is not on the shape', () => {
const stored: Allowlist = { Name: 'work', Enabled: true, Source: 'inline', Entries: ['ok.test'] }
const out = nextAllowlist(allowlistToForm(stored))
assert.equal(
'Response' in out,
false,
'PUT /api/config decodes with DisallowUnknownFields — an invented key fails the whole write',
)
})
test('a disabled allowlist stays disabled through an edit', () => {
const stored: Allowlist = { Name: 'work', Enabled: false, Source: 'inline', Entries: ['ok.test'] }
const out = nextAllowlist({ ...allowlistToForm(stored), name: 'work2' })
assert.equal(out.Enabled, false)
})
// ---- resolvers --------------------------------------------------------------
test('renaming a fake-IP resolver keeps its pool', () => {
// The pool was settable only at creation. Losing it is not cosmetic: a fake-IP
// resolver without one hands out addresses from whatever the engine defaults to.
const stored: Resolver = { Name: 'fake', Type: 'fakeip', Pool: '198.18.0.0/15' }
const out = nextResolver({ ...resolverToForm(stored, 'direct'), name: 'fakeip' })
assert.equal(out.Name, 'fakeip')
assert.equal(out.Pool, '198.18.0.0/15')
})
test('editing a DoH resolver keeps the detour its row set', () => {
const stored: Resolver = {
Name: 'cf',
Type: 'doh',
Address: 'https://1.1.1.1/dns-query',
Detour: 'group:eu',
}
const out = nextResolver({
...resolverToForm(stored, 'group:eu'),
address: 'https://1.0.0.1/dns-query',
})
assert.equal(out.Address, 'https://1.0.0.1/dns-query')
assert.equal(out.Detour, 'group:eu', 'the DNS path is set on the row and must not be lost here')
})
test('switching a resolver to fake-IP drops the address and the path', () => {
// Neither means anything to fake-IP: it answers out of the pool and sends
// nothing, so a kept detour would describe a route no packet takes.
const stored: Resolver = {
Name: 'cf',
Type: 'doh',
Address: 'https://1.1.1.1/dns-query',
Detour: 'group:eu',
}
const out = nextResolver({
...resolverToForm(stored, 'group:eu'),
type: 'fakeip',
pool: '198.18.0.0/15',
})
assert.equal(out.Address, undefined)
assert.equal(out.Detour, undefined)
assert.equal(out.Pool, '198.18.0.0/15')
})
test('“direct” is the absence of a detour, not a value to store', () => {
const stored: Resolver = { Name: 'q9', Type: 'plain', Address: '9.9.9.9', Detour: 'group:eu' }
const out = nextResolver({ ...resolverToForm(stored, 'group:eu'), detour: 'direct' })
assert.equal(out.Detour, undefined)
})
test('a local resolver carries no address — there is nothing to dial', () => {
const out = nextResolver({
name: 'system',
type: 'local',
address: 'left over from doh',
pool: '',
detour: 'direct',
})
assert.equal(out.Address, undefined)
})
// ---- the small predicates the forms branch on -------------------------------
test('only url and geosite are refreshed on a cadence', () => {
assert.equal(isRemoteListSource('url'), true)
assert.equal(isRemoteListSource('geosite'), true)
assert.equal(isRemoteListSource('inline'), false)
assert.equal(isRemoteListSource('file'), false)
})
test('the four resolver types that dial a server are the ones needing an address', () => {
for (const t of ['doh', 'dot', 'plain', 'tcp']) assert.equal(resolverNeedsAddress(t), true, t)
for (const t of ['local', 'fakeip']) assert.equal(resolverNeedsAddress(t), false, t)
})
test('the domain textarea drops comments, lower-cases, and unwraps a hosts-file star', () => {
// A bare domain is a SUFFIX match in the engine, so `*.` would only produce an
// entry that matches nothing — stripping it is the difference between a working
// list and a silently dead one.
assert.deepEqual(
parseDomains('# ads\nAds.Example.com\n*.tracker.test\nads.example.com\n!comment'),
['ads.example.com', 'tracker.test'],
)
assert.deepEqual(parseDomains(' \n\n'), [])
assert.deepEqual(parseDomains('a.test, b.test'), ['a.test', 'b.test'])
})
test('a comment is cut at the line, not at the token', () => {
// The whole line after `#` is a note. Splitting the textarea on whitespace and
// dropping only tokens that start with `#` turned this into three entries —
// `ads`, `and`, `trackers` — all valid, all silently blocked.
assert.deepEqual(parseDomains('# ads and trackers\nreal.test'), ['real.test'])
assert.deepEqual(parseDomains('real.test # keep this one'), ['real.test'])
assert.deepEqual(parseDomains('! AdGuard style note\nreal.test'), ['real.test'])
})
test('a stored inline list round-trips through the textarea unchanged', () => {
const stored: Blocklist = {
Name: 'manual',
Enabled: true,
Source: 'inline',
Entries: ['a.test', 'b.test'],
}
const out = nextBlocklist(blocklistToForm(stored))
assert.deepEqual(out.Entries, ['a.test', 'b.test'])
})
+220
View File
@@ -0,0 +1,220 @@
import type { Allowlist, Blocklist, Complete, Resolver } from './api'
/**
* The DNS page's edit-after-create merges: what a Save writes back for a
* blocklist, an allowlist and a resolver.
*
* Lives outside `pages/DNS.tsx` because it is the part that must be TESTED, and
* the panel's runner is `node --test src/*.test.ts` — plain modules, no JSX, no
* DOM. Same reasoning as `egressEdit.ts` and `ruleset.ts`.
*
* # The rule these three functions exist to keep
*
* A save may only CLEAR a field the editor was in a position to SHOW. That rule
* was written after a rule-set rename silently dropped its `Format` (see
* `ruleset.ts`), and it cuts both ways here:
*
* - every one of the three shapes below is rebuilt from the form rather than
* spread onto the stored object, so switching a list from `url` to `inline`
* really does drop the stale URL instead of leaving a value the new source
* ignores;
* - which is only safe because the editor renders a control for EVERY field of
* the shape. So the return type is `Complete<T>`: a field added to `Blocklist`
* / `Allowlist` / `Resolver` in api.ts stops the build HERE, in the function
* that has to decide, instead of vanishing on the next rename.
*
* Writing `undefined` for a field the chosen source has no use for is therefore a
* statement, not an omission.
*/
/** The four `Source` values a DNS list can carry (model.go Blocklist.Source). */
export type ListSource = 'inline' | 'file' | 'url' | 'geosite'
/** What a blocklist answers with when it matches. */
export type BlockResponse = 'nxdomain' | 'zero'
/** Sources fetched on a cadence, and therefore the only ones an interval means anything for. */
const REMOTE_SOURCES: ReadonlySet<string> = new Set<ListSource>(['url', 'geosite'])
/** True when `source` is re-fetched by the engine, so an update interval applies. */
export function isRemoteListSource(source: string): boolean {
return REMOTE_SOURCES.has(source)
}
/**
* Split a textarea of domains: line by line, comments dropped, then whitespace or
* comma separated, lower-cased, a leading `*.` stripped, deduped.
*
* A bare domain is a SUFFIX match in the engine, so `example.com` already covers
* its subdomains and the `*.` a hosts-file habit adds would only make the entry
* fail to match anything.
*
* COMMENTS ARE CUT PER LINE, not per token. The version this replaces split the
* whole textarea on whitespace and dropped only the tokens that THEMSELVES began
* with `#`/`!` — so pasting `# ads and trackers` contributed `ads`, `and` and
* `trackers` as three real blocked domains, from a line the operator wrote as a
* note. Nothing reported it: they are valid entries that simply match nothing,
* until one of them is a name someone needs.
*/
export function parseDomains(text: string): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const line of text.split(/\r?\n/)) {
// `#` (hosts files) and `!` (AdGuard/uBlock) both start a comment; neither is
// legal in a hostname, so cutting at the first one cannot eat a real entry.
const body = line.split(/[#!]/, 1)[0]
for (const raw of body.split(/[\s,]+/)) {
const d = raw.trim().toLowerCase().replace(/^\*\./, '')
if (!d || seen.has(d)) continue
seen.add(d)
out.push(d)
}
}
return out
}
/**
* The editor's state for a blocklist or an allowlist, exactly as the controls
* hold it: `entriesText` is the raw textarea (parsed on save by
* {@link parseDomains}), `categories` is the chip widget's already-validated
* array, and the rest are raw strings.
*/
export interface ListForm {
name: string
enabled: boolean
source: ListSource
url: string
path: string
entriesText: string
categories: string[]
/** Blocklists only — an allowlist has no reply to choose. */
response: BlockResponse
/** Go duration, e.g. `6h`. Blank ⇒ the daemon's 24h default. */
interval: string
}
const trimmed = (v: string): string | undefined => (v.trim() ? v.trim() : undefined)
const list = (v: string[]): string[] | undefined => (v.length ? v : undefined)
const entriesOf = (text: string): string[] | undefined => list(parseDomains(text))
/**
* Rebuild a blocklist from the edit form.
*
* `UpdateInterval` is carried only for the two remote sources. That is not
* tidiness: on an inline list the engine fetches nothing, so a stored interval
* would sit in the config describing a refresh that never happens — and the row
* reads the interval the ENGINE reports, so the two would disagree on screen.
*/
export function nextBlocklist(f: ListForm): Complete<Blocklist> {
const remote = isRemoteListSource(f.source)
return {
Name: f.name.trim(),
Enabled: f.enabled,
Source: f.source,
URL: f.source === 'url' ? trimmed(f.url) : undefined,
Path: f.source === 'file' ? trimmed(f.path) : undefined,
Entries: f.source === 'inline' ? entriesOf(f.entriesText) : undefined,
Categories: f.source === 'geosite' ? list(f.categories) : undefined,
Response: f.response,
UpdateInterval: remote ? trimmed(f.interval) : undefined,
}
}
/** Rebuild an allowlist from the edit form. Same contract as {@link nextBlocklist},
* minus `Response` — an allowlist does not answer, it exempts. */
export function nextAllowlist(f: ListForm): Complete<Allowlist> {
const remote = isRemoteListSource(f.source)
return {
Name: f.name.trim(),
Enabled: f.enabled,
Source: f.source,
URL: f.source === 'url' ? trimmed(f.url) : undefined,
Path: f.source === 'file' ? trimmed(f.path) : undefined,
Entries: f.source === 'inline' ? entriesOf(f.entriesText) : undefined,
Categories: f.source === 'geosite' ? list(f.categories) : undefined,
UpdateInterval: remote ? trimmed(f.interval) : undefined,
}
}
/** Seed the edit form from a stored blocklist. */
export function blocklistToForm(b: Blocklist): ListForm {
return {
name: b.Name,
enabled: b.Enabled,
source: b.Source,
url: b.URL ?? '',
path: b.Path ?? '',
entriesText: (b.Entries ?? []).join('\n'),
categories: b.Categories ?? [],
response: b.Response === 'zero' ? 'zero' : 'nxdomain',
interval: b.UpdateInterval ?? '',
}
}
/** Seed the edit form from a stored allowlist. `response` is a placeholder the
* allowlist branch never reads — {@link nextAllowlist} does not emit it. */
export function allowlistToForm(a: Allowlist): ListForm {
return {
name: a.Name,
enabled: a.Enabled,
source: a.Source,
url: a.URL ?? '',
path: a.Path ?? '',
entriesText: (a.Entries ?? []).join('\n'),
categories: a.Categories ?? [],
response: 'nxdomain',
interval: a.UpdateInterval ?? '',
}
}
// ---- resolvers --------------------------------------------------------------
/** Resolver kinds that dial a remote server and therefore need an address. */
const REMOTE_RESOLVERS: ReadonlySet<string> = new Set(['doh', 'dot', 'plain', 'tcp'])
/** True when this resolver type needs an `Address`. */
export function resolverNeedsAddress(type: string): boolean {
return REMOTE_RESOLVERS.has(type)
}
/** The resolver editor's state. */
export interface ResolverForm {
name: string
type: string
address: string
pool: string
/** Canonical detour value: `direct` | `group:x` | `chain:x` | `egress:x` | `node:x`. */
detour: string
}
/**
* Rebuild a resolver from the edit form.
*
* A fake-IP resolver answers out of a local pool and never sends a packet, so it
* carries neither an address nor a detour — the editor renders no control for
* either, and this writes `undefined` rather than leaving a path the engine
* throws away. `direct` is the absence of a detour, not a value.
*/
export function nextResolver(f: ResolverForm): Complete<Resolver> {
const fakeip = f.type === 'fakeip'
return {
Name: f.name.trim(),
Type: f.type,
Address: resolverNeedsAddress(f.type) ? trimmed(f.address) : undefined,
Pool: fakeip ? trimmed(f.pool) : undefined,
Detour: fakeip || f.detour === 'direct' ? undefined : trimmed(f.detour),
}
}
/** Seed the resolver editor from a stored resolver. `detour` must already be
* canonicalised by the caller (DNS.tsx owns the catalog that resolves a bare
* legacy name), so an unresolvable value passes through and stays visible. */
export function resolverToForm(r: Resolver, canonDetour: string): ResolverForm {
return {
name: r.Name,
type: r.Type,
address: r.Address ?? '',
pool: r.Pool ?? '',
detour: canonDetour,
}
}
+298
View File
@@ -0,0 +1,298 @@
// Tests for the DNS row's OUTCOME reading — logRoute.dnsRowMark.
//
// The defect this file exists to keep dead: a lookup that FAILED was drawn from
// `action` alone. A query that left through a detour and then timed out came back
// as an accent-coloured `proxy` tag and nothing else, which made the row that
// describes the tunnel breaking the healthiest-looking line in the whole log.
// `actionTag()` also had an open `return 'pass'`, so every value the panel did not
// recognise — including every value a future daemon might add — came out green.
//
// Four things are defended, and the CONTROL is the load-bearing one:
//
// 1. the four situations a row can be in (answered / blocked / failed / not
// recorded) are drawn PAIRWISE DIFFERENTLY. An instrument whose failure looks
// like its success is useless; so is one where everything looks like failure;
// 2. `action=proxy` + `status=failed` is distinguishable BOTH from a healthy
// proxy AND from a failed pass — the pair is the whole diagnosis;
// 3. every fallback is closed and lands on the recoverable side;
// 4. a failure with no recorded cause SAYS so.
//
// Run: npm test (node --test src/*.test.ts)
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { LOG_SEARCH_HINT, dnsRowMark, logSearchFields, rowMatches } from './logRoute.ts'
import type { DnsRowMark } from './logRoute.ts'
import type { QueryLogEntry } from './api.ts'
// ---- fixtures ---------------------------------------------------------------
function query(over: Partial<QueryLogEntry> = {}): QueryLogEntry {
return {
time: '12:00:00',
unix: 1_700_000_000,
domain: 'youtube.com',
qtype: 'A',
rcode: 0,
blocked: false,
server: 'cloudflare-doh',
action: 'pass',
device: 'laptop',
seq: 7,
status: 'answered',
outbound_kind: 'default',
outbound: '',
...over,
}
}
/** The four situations, exactly as the daemon can produce them. */
const ANSWERED = query({ status: 'answered', blocked: false, action: 'pass' })
const BLOCKED = query({ status: 'answered', blocked: true, action: 'block', rcode: 3 })
const FAILED = query({
status: 'failed',
blocked: false,
action: 'proxy',
error: 'dial udp 1.1.1.1:53: i/o timeout',
rcode: -1,
})
// An old row out of the persistent store: the outcome was never written.
const UNRECORDED = query({ status: '', blocked: false, action: 'pass' })
/**
* Everything of the reading that reaches the screen, as one string. Two rows are
* "drawn the same" exactly when these match: the row's own marking, both chips'
* classes and texts, and the failure lines.
*/
function drawn(m: DnsRowMark): string {
return JSON.stringify([m.state, m.outcome, m.label, m.path, m.pathLabel, m.cause, m.rcodeNote])
}
// ---- 1 · THE CONTROL: no two of the four may look alike ----------------------
test('CONTROL: the four situations are drawn pairwise differently', () => {
const cases: [string, QueryLogEntry][] = [
['answered', ANSWERED],
['blocked', BLOCKED],
['failed', FAILED],
['not recorded', UNRECORDED],
]
const seen = new Map<string, string>()
for (const [name, row] of cases) {
const key = drawn(dnsRowMark(row))
const clash = seen.get(key)
assert.equal(
clash,
undefined,
`“${name}” is drawn exactly like “${clash}” — ${key}. A reader cannot tell them apart.`,
)
seen.set(key, name)
}
assert.equal(seen.size, 4)
})
// The other half of the control. The test above passes just as happily if every
// row is marked as a failure, which would be the opposite lie: an instrument that
// reads "broken" on healthy traffic is no more useful than one that reads "fine"
// on broken traffic. So the healthy readings are pinned positively.
test('CONTROL: an answered row is not marked as a failure', () => {
const m = dnsRowMark(ANSWERED)
assert.equal(m.state, 'answered')
assert.equal(m.outcome, 'answered')
assert.equal(m.cause, '', 'a row that did not fail has no failure cause to show')
assert.equal(m.rcodeNote, '')
const b = dnsRowMark(BLOCKED)
assert.equal(b.state, 'blocked', 'the filter answering is not a malfunction')
assert.equal(b.outcome, 'answered')
assert.equal(b.cause, '')
})
// ---- 2 · the pair: which path failed ----------------------------------------
test('a failure in the tunnel is distinguishable from a healthy tunnel', () => {
const healthy = dnsRowMark(query({ status: 'answered', action: 'proxy' }))
const broken = dnsRowMark(FAILED)
assert.equal(healthy.path, 'proxy')
assert.equal(broken.path, 'proxy', 'the PATH must survive the failure — it names what broke')
assert.notEqual(drawn(healthy), drawn(broken))
assert.equal(broken.state, 'failed')
assert.equal(broken.label, 'failed')
})
test('a failure in the tunnel is distinguishable from a failure on the direct path', () => {
const inTunnel = dnsRowMark(FAILED)
const direct = dnsRowMark(
query({ status: 'failed', action: 'pass', error: 'rejected', rcode: 2 }),
)
assert.equal(inTunnel.state, direct.state, 'both are failures — the outcome axis agrees')
assert.notEqual(inTunnel.path, direct.path)
assert.notEqual(drawn(inTunnel), drawn(direct))
})
test('the outcome leads: a failed row never keeps a healthy outcome word', () => {
for (const action of ['pass', 'proxy', 'block', '', 'a-word-from-2027']) {
const m = dnsRowMark(query({ status: 'failed', action }))
assert.equal(m.state, 'failed', `action=${action || '(empty)'} must not overrule the outcome`)
assert.equal(m.outcome, 'failed')
}
})
// ---- 3 · every fallback is closed -------------------------------------------
test('an unrecognised status falls to NOT RECORDED, never to answered', () => {
for (const status of ['', 'ANSWERED', 'ok', 'partial', 'tomorrows-word']) {
const m = dnsRowMark(query({ status }))
assert.equal(m.outcome, 'unrecorded', `status=${status || '(empty)'}`)
assert.equal(m.state, 'unrecorded')
assert.equal(m.label, 'not recorded')
}
})
// This is the open `return 'pass'` that used to live in Insights.actionTag: any
// value the panel did not know came out as the green, everything-is-fine path.
test('an unrecognised action falls to UNKNOWN, never to pass', () => {
for (const action of ['', 'PASS', 'reject', 'hijack-dns', 'tomorrows-word']) {
const m = dnsRowMark(query({ action }))
assert.equal(m.path, 'unknown', `action=${action || '(empty)'}`)
assert.equal(m.pathLabel, 'unknown', 'an unknown path is named, not left blank')
}
})
test('an unknown path names the value it could not read', () => {
assert.match(dnsRowMark(query({ action: 'hijack-dns' })).pathTitle, /hijack-dns/)
assert.match(dnsRowMark(query({ action: '' })).pathTitle, /not recorded/i)
})
test('a not-recorded outcome is not a claim that the lookup succeeded', () => {
const m = dnsRowMark(UNRECORDED)
assert.match(m.title, /NOT a claim/)
assert.equal(m.cause, '', 'an unknown outcome has no failure to explain either')
})
// `blocked` is a fact from a different field, and it is only allowed to REFINE a
// recorded answer. A build that never wrote the outcome does not get to have its
// other fields stand in for one.
test('blocked does not manufacture an outcome on a row that has none', () => {
const m = dnsRowMark(query({ status: '', blocked: true, action: 'block' }))
assert.equal(m.outcome, 'unrecorded')
assert.equal(m.state, 'unrecorded')
})
// ---- 4 · a failure with no recorded cause says so ---------------------------
test('a failure shows its cause verbatim, uncategorised', () => {
assert.equal(
dnsRowMark(FAILED).cause,
'cause dial udp 1.1.1.1:53: i/o timeout',
'the resolver text is passed through, not graded into a category',
)
assert.equal(dnsRowMark(query({ status: 'failed', error: 'loopback' })).cause, 'cause loopback')
assert.equal(
dnsRowMark(query({ status: 'failed', error: 'rejected (cached)' })).cause,
'cause rejected (cached)',
)
})
test('a failure with NO cause says the cause was not recorded, not nothing', () => {
for (const e of [undefined, '', ' ']) {
const m = dnsRowMark(query({ status: 'failed', error: e }))
assert.equal(m.cause, 'cause not recorded', `error=${JSON.stringify(e)}`)
}
})
test('rcode separates "never answered" from "answered with a refusal"', () => {
assert.equal(dnsRowMark(query({ status: 'failed', rcode: -1 })).rcodeNote, 'no response')
assert.equal(dnsRowMark(query({ status: 'failed', rcode: 2 })).rcodeNote, 'rcode 2')
assert.equal(dnsRowMark(query({ status: 'failed', rcode: 5 })).rcodeNote, 'rcode 5')
// CONTROL: the rcode line belongs to failures only — on a healthy row it would
// be noise dressed as a diagnosis.
assert.equal(dnsRowMark(query({ status: 'answered', rcode: 3 })).rcodeNote, '')
})
// ---- 5 · the search box, and what it promises -------------------------------
test('search FINDS a failure by its cause', () => {
assert.equal(rowMatches(logSearchFields(FAILED), 'timeout'), true)
assert.equal(rowMatches(logSearchFields(FAILED), 'I/O TIMEOUT'), true)
})
// CONTROL for the assertion above: a field list that returned everything would
// pass it. `status` is a vocabulary word — q=failed must not sweep up every
// failure while the operator is looking for a domain with "failed" in its name.
test('CONTROL search DOES NOT FIND: status is not searched', () => {
assert.equal(
rowMatches(logSearchFields(query({ status: 'failed', error: '', domain: 'a.example', action: 'pass', server: 'r', device: 'd', outbound: '' })), 'failed'),
false,
)
assert.equal(rowMatches(logSearchFields(ANSWERED), 'answered'), false)
})
test('the search hint names error, because the daemon searches it', () => {
assert.match(LOG_SEARCH_HINT, /error/)
})
// ---- 6 · the page actually renders the reading ------------------------------
//
// The reading is pure and testable; the JSX around it is not, under `node --test`
// (no DOM, no JSX transform). So the seam between them is checked as source text:
// a component that quietly stopped drawing `cause`, or that kept its own opinion
// about what an unknown action means, would pass every assertion above.
const INSIGHTS = readFileSync(
fileURLToPath(new URL('./pages/Insights.tsx', import.meta.url)),
'utf8',
)
// Anchored at the RENDER SITE, not at "the identifier appears somewhere": a first
// attempt only checked that the string `mark.cause` was in the file, and it
// survived being rewritten to `{false && mark.cause ? …}`. A grep that a disabled
// render passes is not an instrument.
test('Insights renders every part of the reading', () => {
const sites: [string, RegExp][] = [
['the row-state marking', /st-\$\{mark\.state\}/],
['the outcome chip class', /s-\$\{mark\.outcome\}/],
['the outcome chip text', /\{mark\.label\}/],
['the outcome tooltip', /title=\{mark\.title\}/],
['the path tag class', /tag \$\{mark\.path\}/],
['the path tag text', /\{mark\.pathLabel\}/],
['the failure cause', /\{mark\.cause \?/],
['the rcode note', /\{mark\.rcodeNote \?/],
]
for (const [what, at] of sites) {
assert.match(INSIGHTS, at, `Insights.tsx does not render ${what}`)
}
})
test('Insights keeps no open fallback of its own', () => {
assert.ok(
!/function actionTag/.test(INSIGHTS),
'the open actionTag fallback is back — unknown values are green again',
)
assert.ok(!/return 'pass'/.test(INSIGHTS))
})
// Each selector must open a RULE — `.st-failed` followed by `{` or `,`. Matching
// the bare substring would be satisfied by `.st-failed-DISABLED`, which is exactly
// how one earlier version of this test let a deleted rule through.
test('the four row states each get their own marking in the stylesheet', () => {
const css = readFileSync(
fileURLToPath(new URL('./pages/Insights.css', import.meta.url)),
'utf8',
)
for (const sel of ['st-blocked', 'st-failed', 'st-unrecorded', 's-answered', 's-failed', 's-unrecorded']) {
assert.match(
css,
new RegExp(`\\.${sel}\\s*[,{]`),
`Insights.css opens no rule for .${sel}`,
)
}
// The two chips that must never share a look: a failure and an unrecorded row
// are drawn from different declarations, not one shared block.
assert.ok(
!/\.s-failed\s*,[^{]*\.s-unrecorded|\.s-unrecorded\s*,[^{]*\.s-failed/.test(css),
'the failed and not-recorded chips share one rule — they would look identical',
)
})
+102 -19
View File
@@ -4,9 +4,11 @@ import type { Egress } from './api.ts'
import {
DPI_TYPES,
EGRESS_TYPES,
RETIRED_EGRESS_TYPES,
UNKNOWN_EGRESS_TYPE_HINT,
isKnownEgressType,
nextEgress,
retiredEgressType,
} from './egressEdit.ts'
// The egress editor's save merge. The defect these pin: the submit handler
@@ -26,7 +28,6 @@ test('an unknown type keeps the fields the editor never showed', () => {
name: 'vpn-renamed',
type: 'wireguard',
iface: 'wg0',
port: '',
dpi: 'off',
})
assert.equal(out.Name, 'vpn-renamed')
@@ -43,15 +44,15 @@ test('an unknown type with a blank form state still keeps what was stored', () =
// The stricter version: the editor's `iface` state is seeded from `initial`,
// so a test that passes the same value back could pass on a broken merge too.
// Blank the form and the stored value must still survive.
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0', Port: 9050 }
const out = nextEgress(initial, { name: 'vpn', type: 'wireguard', iface: '', port: '', dpi: '' })
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0', DPI: 'spoof' }
const out = nextEgress(initial, { name: 'vpn', type: 'wireguard', iface: '', dpi: '' })
assert.equal(out.Interface, 'wg0')
assert.equal(out.Port, 9050)
assert.equal(out.DPI, 'spoof')
})
test('the inputs are not mutated — the caller keeps a usable `initial`', () => {
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0' }
nextEgress(initial, { name: 'other', type: 'interface', iface: 'wan2', port: '', dpi: 'off' })
nextEgress(initial, { name: 'other', type: 'interface', iface: 'wan2', dpi: 'off' })
assert.deepEqual(initial, { Name: 'vpn', Type: 'wireguard', Interface: 'wg0' })
})
@@ -60,42 +61,35 @@ test('a known type still clears the fields it does not use', () => {
// settings from the previous type must go, or the config keeps a value the new
// type ignores and the panel shows a setting that does nothing.
const initial: Egress = { Name: 'e', Type: 'interface', Interface: 'wan2', DPI: 'fragment' }
const out = nextEgress(initial, { name: 'e', type: 'byedpi', iface: 'wan2', port: '1081', dpi: 'fragment' })
assert.equal(out.Type, 'byedpi')
assert.equal(out.Interface, undefined, 'a byedpi egress has no interface')
assert.equal(out.Port, 1081)
assert.equal(out.DPI, undefined, 'byedpi desyncs itself; the native preset is not applied')
const out = nextEgress(initial, { name: 'e', type: 'direct', iface: 'wan2', dpi: 'record' })
assert.equal(out.Type, 'direct')
assert.equal(out.Interface, undefined, 'a direct egress binds no interface')
assert.equal(out.DPI, 'record', 'but it does carry the native preset')
})
test('an interface egress carries its interface and DPI, and no port', () => {
test('an interface egress carries its interface and its DPI preset', () => {
const out = nextEgress(undefined, {
name: ' wan-direct ',
type: 'interface',
iface: ' wan2 ',
port: '1080',
dpi: 'record',
})
assert.equal(out.Name, 'wan-direct', 'the name is trimmed')
assert.equal(out.Interface, 'wan2', 'the interface is trimmed')
assert.equal(out.Port, undefined, 'only a byedpi egress dials a port')
assert.equal(out.DPI, 'record')
})
test('a byedpi egress with no port falls back to the ciadpi default', () => {
const out = nextEgress(undefined, { name: 'b', type: 'byedpi', iface: '', port: ' ', dpi: 'off' })
assert.equal(out.Port, 1080)
})
test('the type list is the closed set the daemon builds outbounds for', () => {
// model.KnownEgressTypes. `tunnel` must NOT be here: the daemon folds it to
// `interface` on read (model.NormalizeEgressTypes), so the panel receives the
// canonical spelling and a second entry would put the split back into the UI.
assert.deepEqual(
EGRESS_TYPES.map((t) => t.id),
['interface', 'direct', 'byedpi'],
['interface', 'direct'],
)
assert.equal(isKnownEgressType('tunnel'), false)
assert.equal(isKnownEgressType('interface'), true)
assert.equal(isKnownEgressType('direct'), true)
assert.deepEqual([...DPI_TYPES].sort(), ['direct', 'interface'])
})
@@ -114,3 +108,92 @@ test('the unknown-type hint describes what actually happens, both halves of it',
'the superseded sentence claimed the engine was the only half involved',
)
})
// ---- the RETIRED type -------------------------------------------------------
//
// `byedpi` was a working egress kind. It handed traffic to a separate ciadpi
// process because the engine's own TLS fragmentation was not getting through
// DPI; the cause turned out to be a defect in that fragmentation — the cut
// always landed inside the FIRST label of the name, so the blocked word
// travelled intact — and with that fixed the external process was weight. The
// type is gone from the Go model.
//
// What these pin is the SECOND half of removing it. Routers still carry
// `option type 'byedpi'` in /etc/config/shater, and the cheap thing to do is let
// the type fall into the generic unknown branch. That branch tells an operator
// the router "does not recognise this type", which reads as a typo — so they go
// looking for a misspelling that is not there, while every rule bound to that
// egress is blocked right now.
test('a retired type is named as removed, not as unrecognised', () => {
const hint = retiredEgressType('byedpi')
assert.ok(hint, 'a stored byedpi egress must get a sentence of its own')
// (a) removed from the product — and explicitly NOT a misspelling, because
// that is the wrong hunt to send somebody on.
assert.match(hint, /REMOVED from this product/)
assert.match(hint, /not a misspelling/)
// (b) what is happening RIGHT NOW: fail-closed, not a quiet WAN leak.
assert.match(hint, /BLOCKED/)
assert.match(hint, /fail-closed/)
assert.match(hint, /never quietly sent out over the plain WAN/)
// (c) the replacement, in the words of the two controls this form has.
assert.match(hint, /Direct/)
assert.match(hint, /Interface/)
assert.match(hint, /record/)
// (d) no promise about the operator's own ISP. The daemon does not make one,
// and a caption that quietly did would be the panel contradicting it.
assert.match(hint, /property of your ISP and is not promised here/)
})
test('the retired sentence promises nothing about getting through', () => {
// The failure mode this guards is a rewrite that "helps" by upgrading the
// replacement instruction into a claim. `record` is a preset, not an outcome.
const hint = retiredEgressType('byedpi') ?? ''
for (const claim of [/will get through/i, /works with your/i, /restores/i, /fixes your/i]) {
assert.doesNotMatch(hint, claim, `the caption must not claim an outcome: ${claim}`)
}
})
test('a retired type is spelled loosely — a hand-edited UCI file is the source', () => {
assert.equal(retiredEgressType(' ByeDPI '), retiredEgressType('byedpi'))
assert.equal(retiredEgressType('BYEDPI'), retiredEgressType('byedpi'))
})
test('only the retired spellings match — no alias, no prototype, no live type', () => {
// The control for the test above: a lookup that says yes to everything would
// pass "byedpi gets a sentence" and be worthless. These must all be undefined.
for (const live of ['interface', 'direct', 'tunnel', 'wireguard', '', ' ', 'byedpi2', 'bye dpi']) {
assert.equal(retiredEgressType(live), undefined, `${JSON.stringify(live)} is not retired`)
}
// `RETIRED_EGRESS_TYPES[k]` answers these off Object.prototype; the lookup
// must not, or a config typo becomes "[object Object]" under the Type select.
for (const proto of ['toString', 'constructor', 'hasOwnProperty', '__proto__']) {
assert.equal(retiredEgressType(proto), undefined, proto)
}
assert.deepEqual(Object.keys(RETIRED_EGRESS_TYPES), ['byedpi'])
})
test('a retired type is retired — the engine list must not take it back', () => {
// The other direction, and the one that would make the sentence above a lie:
// if `byedpi` ever reads as a live type again, the editor renders fields for
// it and nextEgress starts rewriting egresses the daemon builds nothing for.
assert.equal(isKnownEgressType('byedpi'), false)
assert.ok(!EGRESS_TYPES.some((t) => t.id === 'byedpi'), 'the type list must not offer it')
assert.ok(!DPI_TYPES.has('byedpi'), 'and no preset is stamped on it either')
// A type cannot be both, or the editor would show a blurb and a removal notice.
for (const t of EGRESS_TYPES) {
assert.equal(retiredEgressType(t.id), undefined, `${t.id} is offered AND retired`)
}
})
test('a retired egress survives a rename with every stored field intact', () => {
// The whole reason `if (!isKnownEgressType(type)) return base` exists, now that
// a real router carries a type in exactly that position. Blanking the form
// state too, so a merge that echoed the inputs back could not pass this.
const initial: Egress = { Name: 'ciadpi-exit', Type: 'byedpi', Interface: 'wan', DPI: 'fragment' }
const out = nextEgress(initial, { name: 'old-desync', type: 'byedpi', iface: '', dpi: 'off' })
assert.equal(out.Name, 'old-desync')
assert.equal(out.Type, 'byedpi', 'the type is not silently rewritten either')
assert.equal(out.Interface, 'wan', 'renaming a retired egress must not delete its interface')
assert.equal(out.DPI, 'fragment')
})
+77 -20
View File
@@ -12,23 +12,33 @@ import type { Egress } from './api'
*/
/**
* The three egress kinds that produce a real way out, in the order the editor
* The two egress kinds that produce a real way out, in the order the editor
* offers them. This is the panel's copy of `model.KnownEgressTypes` and must
* stay equal to it: the daemon builds no outbound for anything else, and the
* router installs no mark, no `ip rule` and no routing table for it either, so
* every node, group and rule bound to such an egress is blocked.
*
* The list is CLOSED and POSITIVE. There is no fallback entry and no "other":
* a type that is not spelled here has no fields in this editor, and
* {@link nextEgress} therefore refuses to rewrite it.
*
* `tunnel` is deliberately NOT here. It is an accepted spelling in
* `/etc/config/shater`, but the daemon folds it to `interface` on read
* (model.NormalizeEgressTypes), so an egress written that way arrives at this
* panel already saying `interface` — with its Interface field rendered, its
* blurb correct and no "(unknown)" label. Adding a fourth entry here would put
* blurb correct and no "(unknown)" label. Adding a third entry here would put
* the second spelling back into a UI that has to agree with two backend halves.
*
* `proxy` and `block` were removed: neither ever created an outbound, so
* everything bound to them fell through to the plain WAN with the real address.
* Send traffic through a proxy by routing it at a group/node/chain, and drop it
* with the `block` target on a rule.
*
* A third type was RETIRED for the opposite reason — it worked, and then the
* defect it was compensating for got fixed, so it became weight. It is not
* dropped into the generic unknown branch on the way out; it is named, once, in
* {@link RETIRED_EGRESS_TYPES}, which is the only place its spelling and its
* story live.
*/
export const EGRESS_TYPES: ReadonlyArray<{ id: string; label: string; blurb: string }> = [
{
@@ -41,11 +51,6 @@ export const EGRESS_TYPES: ReadonlyArray<{ id: string; label: string; blurb: str
label: 'Direct — straight out, with an optional DPI preset',
blurb: 'Uses the normal route. Its point is the DPI preset below, applied to what you route here.',
},
{
id: 'byedpi',
label: 'ByeDPI — through the local ciadpi desync proxy',
blurb: 'Hands traffic to ciadpi on 127.0.0.1, which desyncs it and goes out direct.',
},
]
/** Lookup by id, or undefined when the stored type is not one this panel knows. */
@@ -59,16 +64,69 @@ export function isKnownEgressType(type: string): boolean {
}
/**
* Types whose native DPI-bypass preset applies. NOT byedpi: the desync happens
* inside the ciadpi process, and the engine's tls_* flags are never stamped on
* top of it — so the control is hidden there rather than accepted and dropped.
* Types whose native DPI-bypass preset applies — which is now every type this
* panel knows. It is kept as its own set rather than folded into
* {@link EGRESS_TYPES} because the two lists answer different questions ("what
* may be created" vs "what carries a preset"), and they have already come apart
* once.
*/
export const DPI_TYPES: ReadonlySet<string> = new Set(['interface', 'direct'])
/**
* What the editor shows under the Type select when the stored type is not one of
* the three. It has to describe what the router actually does with such an
* egress, and what THIS FORM does to it on save — both halves were wrong before.
* Types that WERE built and are not any more, with the sentence the editor shows
* for each. A CLOSED, positive table: nothing lands here by accident, and a type
* absent from it falls to {@link UNKNOWN_EGRESS_TYPE_HINT}.
*
* It exists so a router carrying `option type 'byedpi'` in /etc/config/shater is
* told what happened rather than handed the generic "not recognised" — the
* operator did not mistype anything, the kind was taken out from under them, and
* the difference decides what they do next.
*
* The daemon says the same thing at length (`model.RetiredEgressTypes`); this is
* the caption-length version and must not contradict it.
*/
export const RETIRED_EGRESS_TYPES: Readonly<Record<string, string>> = {
byedpi:
'The type “byedpi” was REMOVED from this product — this is not a misspelling, it is a kind ' +
'that no longer exists. Nothing is built for it: no outbound, no mark, no routing rule or ' +
'table, so every node, group and rule bound to this egress is BLOCKED right now — ' +
'fail-closed, never quietly sent out over the plain WAN. It existed to hand traffic to a ' +
'separate ciadpi process because the engine’s own TLS fragmentation was not getting through; ' +
'that turned out to be a defect in the fragmentation, and it is fixed. Move this egress onto ' +
'the built-in desync: set the type to Direct with the DPI preset “record”, or to Interface ' +
'with the same preset plus the interface this traffic should leave through, then apply. ' +
'Which preset gets through is a property of your ISP and is not promised here — “fragment” ' +
'and “spoof” are the other two.',
}
/**
* The retired-type sentence for a stored type, or undefined when the type is not
* a retired one.
*
* Case and surrounding space are folded, because that is what a hand-edited
* `/etc/config/shater` produces. Nothing else is: there is deliberately no alias
* or normalisation step in front of this lookup, so only the exact retired
* spellings match and no live type can ever be routed into a "this was removed"
* message.
*
* The own-property check is not ceremony: a bare `obj[key]` answers `toString`
* and `constructor` out of the prototype, and a `type` comes off a config file
* this panel does not control.
*/
export function retiredEgressType(type: string): string | undefined {
const key = type.trim().toLowerCase()
return Object.prototype.hasOwnProperty.call(RETIRED_EGRESS_TYPES, key)
? RETIRED_EGRESS_TYPES[key]
: undefined
}
/**
* What the editor shows under the Type select when the stored type is neither a
* known one nor a RETIRED one — the retired table above answers first, because
* "you mistyped something" and "we took this kind away" send an operator to
* different places. It has to describe what the router actually does with such
* an egress, and what THIS FORM does to it on save — both halves were wrong
* before.
*
* It used to read: "This engine builds no outbound for that type, so everything
* routed here is blocked. Pick one above." Two problems. It said "this engine",
@@ -93,7 +151,6 @@ export interface EgressForm {
name: string
type: string
iface: string
port: string
dpi: string
}
@@ -103,8 +160,8 @@ export interface EgressForm {
* # The rule, and why it is the rule
*
* A save may only CLEAR a field the editor was in a position to show. For the
* three known types the editor renders every field that type uses, so clearing
* the others is right: switching `interface` → `byedpi` must drop the stale
* known types the editor renders every field that type uses, so clearing the
* others is right: switching `interface` → `direct` must drop the stale
* interface name, or the config keeps a setting the new type ignores.
*
* For a type this panel has no definition for, the editor renders NONE of those
@@ -122,9 +179,10 @@ export interface EgressForm {
* from one rename, with no message anywhere.
*
* The daemon no longer hands this panel a `tunnel` (it is folded to `interface`
* on read), so that particular type is gone. The rule stays, because the next
* type the backend gains before the panel learns it would repeat the whole
* thing: an editor must not delete what it declines to display.
* on read), so that particular type is gone. The rule stays, and the RETIRED type
* is why it earns its keep today: routers still carry that spelling in their
* config, the editor renders none of its fields, and renaming such an egress must
* leave every stored setting on it exactly as saved.
*
* `initial` is never mutated — the caller keeps a usable object if the save
* fails.
@@ -136,7 +194,6 @@ export function nextEgress(initial: Egress | undefined, form: EgressForm): Egres
// never delivers one and the spread above cannot produce one.
if (!isKnownEgressType(type)) return base
base.Interface = type === 'interface' ? form.iface.trim() : undefined
base.Port = type === 'byedpi' ? Number(form.port.trim()) || 1080 : undefined
base.DPI = DPI_TYPES.has(type) ? form.dpi : undefined
return base
}
+94
View File
@@ -0,0 +1,94 @@
// The geo-data source contract the Settings page renders.
//
// Run with `npm test` (node's built-in test runner + native type stripping).
//
// THE CASE THIS FILE WAS WRITTEN FOR: five real Globals fields — GeoProvider and
// the four URLs — were consumed by generate.SetGeoProvider and had NO panel
// control at all, while the panel happily used the geo data those settings pick
// (the geosite/geoip pickers, the category suggestions). The only way to change
// where a `geoip:us` list came from was to edit /etc/config/shater over SSH.
//
// The trap in exposing them is that three of the five are conditional, and the
// daemon never errors: an unknown provider and a broken template both degrade to
// the built-in Auto chain with a warning and the router comes up. So a control
// that looked applied while the router used something else would be exactly the
// kind of lie the panel is not allowed to tell.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
GEO_CATEGORY_PLACEHOLDER,
GEO_PROVIDERS,
GEO_PROVIDER_DEFAULT,
checkGeoTemplate,
isKnownGeoProvider,
normGeoProvider,
usesCustomTemplates,
} from './geoProvider.ts'
test('the provider list is the closed five the daemon knows', () => {
assert.deepEqual(
GEO_PROVIDERS.map((p) => p.id),
['auto', 'sagernet', 'loyalsoldier', 'metacubex', 'custom'],
'generate/geosource.go — a sixth here would read as configured and run as auto',
)
})
test('an unset provider reads as auto, because that is what the router runs', () => {
assert.equal(normGeoProvider(undefined), GEO_PROVIDER_DEFAULT)
assert.equal(normGeoProvider(''), 'auto')
assert.equal(normGeoProvider(' '), 'auto')
assert.equal(normGeoProvider('AUTO'), 'auto', 'the daemon lower-cases before matching')
assert.equal(normGeoProvider(' MetaCubeX '), 'metacubex')
})
test('an unknown provider is preserved, not silently retargeted to auto', () => {
// Opening the page must not be an edit. The select marks the value instead, so
// the operator sees what is stored and chooses whether to replace it.
assert.equal(normGeoProvider('mirror-42'), 'mirror-42')
assert.equal(isKnownGeoProvider('mirror-42'), false)
assert.equal(isKnownGeoProvider('sagernet'), true)
assert.equal(isKnownGeoProvider(''), true, 'empty is auto, which is known')
})
test('only custom reads the URL templates', () => {
// On any other provider they are stored and ignored, so the editor hides them
// rather than offering a control the router does not consult.
assert.equal(usesCustomTemplates('custom'), true)
assert.equal(usesCustomTemplates(''), false)
assert.equal(usesCustomTemplates('auto'), false)
assert.equal(usesCustomTemplates('metacubex'), false)
})
test('a template without {category} is refused — the daemon will not append it', () => {
// Appending would build a plausible-looking wrong URL that 404s at fetch time,
// rather than failing where someone can read it.
const bad = checkGeoTemplate('https://mirror.test/geosite.srs')
assert.equal(bad.ok, false)
if (!bad.ok) assert.match(bad.reason, /\{category\}/)
})
test('an empty template falls that source back to the built-in chain, and says so', () => {
const empty = checkGeoTemplate(' ')
assert.equal(empty.ok, false)
if (!empty.ok) assert.match(empty.reason, /Auto chain/)
})
test('a template carrying the placeholder passes', () => {
assert.deepEqual(
checkGeoTemplate(`https://mirror.test/geosite/${GEO_CATEGORY_PLACEHOLDER}.srs`),
{ ok: true },
)
assert.deepEqual(checkGeoTemplate(' https://mirror.test/{category}.srs '), { ok: true })
})
test('every provider carries a blurb — the select is where the cost gap is said', () => {
// SagerNet's geoip is country codes only; Loyalsoldier's country lists are about
// twice the size. Picking a provider without that in front of you is picking
// blind, and `us` is ~159 000 prefixes against netflix's ~108.
for (const p of GEO_PROVIDERS) {
assert.ok(p.label.length > 0, `${p.id} needs a label`)
assert.ok(p.blurb.length > 40, `${p.id} needs a blurb that says something`)
}
})
+130
View File
@@ -0,0 +1,130 @@
/**
* The geo-data source settings' data half — the closed provider list, which of
* the four URL fields a given provider actually reads, and the validator for the
* two templates.
*
* It lives outside `pages/Settings.tsx` for the same reason `egressEdit.ts` lives
* outside `pages/Targets.tsx`: this is the part that must be TESTED, and the
* panel's runner is `node --test src/*.test.ts` — plain modules, no JSX, no DOM.
*
* Everything here mirrors `shater/generate/geosource.go`. Where the two could
* drift, this file states the daemon's behaviour rather than the panel's wish:
* the panel does not decide any of it, it only has to stop describing it wrongly.
*/
/**
* The five providers, in the order the Settings select offers them.
*
* A CLOSED, POSITIVE list on purpose. The daemon degrades an unknown value to
* `auto` with a warning and keeps running, so a free-text field here would let a
* typo look configured while the router quietly used something else.
*
* `blurb` is what the operator reads under the select, and every one of them is a
* claim about coverage and cost that the Go side backs:
* - SagerNet's geoip is COUNTRY CODES ONLY — 238 two-letter files. There is no
* `netflix`, no `google`, no `telegram` in it.
* - which is why `auto` is a split and not a single upstream: a country code
* stays on SagerNet (small, official, what every existing config already
* resolves to) and everything else goes to Loyalsoldier.
* - and why `auto` is NOT "Loyalsoldier for everything": their country lists are
* roughly twice SagerNet's size, so a blanket switch inflates the single most
* expensive case. `netflix` is ~108 prefixes; `us` is ~159 000.
*/
export const GEO_PROVIDERS: ReadonlyArray<{ id: string; label: string; blurb: string }> = [
{
id: 'auto',
label: 'Auto — country codes from SagerNet, the rest from Loyalsoldier',
blurb:
'The default, and a per-category choice rather than one upstream: a two-letter country code comes from SagerNet, every other geoip category from Loyalsoldier, and geosite from SagerNet. Existing lists resolve to exactly the same files as before.',
},
{
id: 'sagernet',
label: 'SagerNet — official, country codes only',
blurb:
'One upstream for both planes. Its geoip publishes country codes and nothing else, so a named category such as netflix or telegram does not exist here — express it as a country list, or pick another provider.',
},
{
id: 'loyalsoldier',
label: 'Loyalsoldier — all geoip, one dataset',
blurb:
'Sends every geoip category here, country codes included: one consistent dataset, at roughly twice SagerNet’s size for a country. Geosite still comes from SagerNet — Loyalsoldier publishes no .srs domain lists.',
},
{
id: 'metacubex',
label: 'MetaCubeX — the widest catalogue',
blurb:
'MetaCubeX/meta-rules-dat republishes both planes as .srs: about 1900 geosite categories and 260 geoip ones. Its geoip is Loyalsoldier’s data, so the sizes match.',
},
{
id: 'custom',
label: 'Custom — your own mirror',
blurb:
'Takes the URL templates below instead of a built-in list. For a private mirror, or a repo the built-ins do not cover.',
},
]
/** The value an empty/unset `GeoProvider` means. */
export const GEO_PROVIDER_DEFAULT = 'auto'
/**
* Normalise a stored provider for DISPLAY. Empty ⇒ `auto`, because that is what
* the daemon runs. Anything else is returned verbatim — including a value this
* list does not know, so the select can mark it rather than silently retarget the
* setting to `auto` the moment the page is opened.
*/
export function normGeoProvider(raw: string | undefined): string {
const v = (raw ?? '').trim().toLowerCase()
return v === '' ? GEO_PROVIDER_DEFAULT : v
}
/** Whether this panel has a definition — and a blurb — for the provider. */
export function isKnownGeoProvider(provider: string): boolean {
return GEO_PROVIDERS.some((p) => p.id === normGeoProvider(provider))
}
/**
* Whether the two URL TEMPLATES are read at all. Only `custom` reads them; on
* every other provider they are stored and ignored, so the editor hides them
* instead of offering a control the router does not consult.
*/
export function usesCustomTemplates(provider: string): boolean {
return normGeoProvider(provider) === 'custom'
}
/** The placeholder the daemon splices a category into. */
export const GEO_CATEGORY_PLACEHOLDER = '{category}'
export type GeoTemplateVerdict =
| { ok: true }
/** The template is unusable and that SOURCE falls back to the built-in `auto`
* chain. The daemon warns and carries on — it never fails the config. */
| { ok: false; reason: string }
/**
* Validate one `{category}` URL template the way `ValidateGeoProviderConfig`
* does, and say what the daemon will DO about a bad one.
*
* Two rules, both from the Go side:
* - empty ⇒ that source keeps using the built-in `auto` chain;
* - no `{category}` ⇒ likewise, because the daemon refuses to append the
* category itself: appending would build a plausible-looking wrong URL that
* 404s at fetch time instead of failing here where it can be read.
*
* Note the verdict is never fatal. `SetGeoProvider` returns warnings and never an
* error, so the panel must not tell the operator their router will refuse to
* start — it will start, using different data than they asked for.
*/
export function checkGeoTemplate(template: string): GeoTemplateVerdict {
const t = template.trim()
if (t === '') {
return { ok: false, reason: 'Empty — this source keeps using the built-in Auto chain.' }
}
if (!t.includes(GEO_CATEGORY_PLACEHOLDER)) {
return {
ok: false,
reason:
'No {category} placeholder, so the category cannot be spliced in — this source keeps using the built-in Auto chain.',
}
}
return { ok: true }
}
+53
View File
@@ -0,0 +1,53 @@
// Is the interception the config describes actually happening?
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// What these protect: the Networks coverage board used to light `lan` GREEN and
// say "Through the tunnel via lan" on a fresh install with the service switched
// off — because coverage was computed from /etc/config/shater and never once
// looked at the running daemon. Nothing inactive may look healthy, and nothing
// unknown may look healthy either.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Status } from './api'
import { interceptLive } from './intercept.ts'
const st = (over: Partial<Status>): Status => ({ running: true, ...over }) as Status
test('green needs BOTH: the engine up and the full plane installed', () => {
assert.equal(interceptLive(st({ engine_running: true, plane: 'full' })), 'running')
})
test('a stopped engine is never green, whatever the config says', () => {
// The case that shipped: fresh install, service off.
assert.equal(interceptLive(st({ running: false })), 'stopped')
assert.equal(interceptLive(st({ engine_running: false, plane: 'full' })), 'stopped')
})
test('no plane, or the fail-closed hold plane, is not interception', () => {
// `none` — nothing is marked into the listener, so nothing reaches it.
assert.equal(interceptLive(st({ engine_running: true, plane: 'none' })), 'stopped')
// `hold` — the backstop is in the path and BLOCKS this traffic. Protection
// engaged is not the same as traffic carried, and the board must not say it is.
assert.equal(interceptLive(st({ engine_running: true, plane: 'hold' })), 'stopped')
})
test('unknown is its own answer and is never green', () => {
// No status at all yet.
assert.equal(interceptLive(null), 'unknown')
// A daemon that predates `plane`: running, engine up, plane unreported. This is
// the branch where `undefined !== 'none'` has burned this panel before.
assert.equal(interceptLive(st({ engine_running: true })), 'unknown')
// Daemon alive, engine liveness not reported.
assert.equal(interceptLive(st({ plane: 'full' })), 'unknown')
})
test('a negative wins without needing the other half to agree', () => {
// Engine down but plane still reported full (a stale or half-applied reading):
// the proof that nothing is intercepted is complete on its own.
assert.equal(interceptLive(st({ running: true, engine_running: false, plane: 'full' })), 'stopped')
// Plane gone but engine reported up.
assert.equal(interceptLive(st({ running: true, engine_running: true, plane: 'none' })), 'stopped')
})
+48
View File
@@ -0,0 +1,48 @@
import type { Status } from './api'
// Explicit `.ts` because this is a RUNTIME import and `npm test` runs the file
// through node's type-stripping loader, which does no extension resolution.
// tsconfig sets allowImportingTsExtensions and Vite resolves it unchanged.
import { engineState } from './planeState.ts'
/**
* Is the interception a config DESCRIBES actually happening right now?
*
* The Networks page's coverage board answered a different question than the one it
* appeared to answer. It is computed from `/etc/config/shater` alone — an enabled
* tproxy inbound naming this network — and never looked at the running daemon, so
* on a fresh install, with the service switched off and no data plane installed at
* all, it lit `lan` GREEN and said "Through the tunnel via lan". Nothing was going
* through anything.
*
* That is the failure this project keeps paying for: something inactive drawn as
* healthy, on the very page an operator opens to find out why their traffic is not
* being carried. Same rule as planeState.engineState, whose three-answer shape
* this mirrors — unknown is an unlit socket, never green.
*
* Testable on its own because it is the whole judgement: `node --test src/*.test.ts`
* cannot load a page component (JSX, CSS imports), and a truth table nothing can
* call is how the green lamp survived this long.
*/
export type InterceptLive = 'running' | 'stopped' | 'unknown'
/**
* running — the engine is up AND the full data plane is installed. Only this
* combination diverts a packet: a listener with nothing marked into it
* captures nothing, and an engine that never started has no listener.
* stopped — the engine is down, or the plane is `none` (nothing installed) or
* `hold` (the fail-closed backstop is in the path, which BLOCKS this
* traffic rather than carrying it). Configured, not happening.
* unknown — no status yet, or a daemon that reports no `plane`. Not a claim.
*
* A NEGATIVE ALWAYS WINS, and the negatives are checked first: a stopped engine or
* an absent plane each prove on their own that nothing is being intercepted, and
* neither needs the other's agreement. "Running" is the only verdict that needs
* both, because it is the only one that asserts something good.
*/
export function interceptLive(status: Status | null): InterceptLive {
const engine = engineState(status)
if (engine === 'down') return 'stopped'
if (status?.plane === 'none' || status?.plane === 'hold') return 'stopped'
if (engine === 'up' && status?.plane === 'full') return 'running'
return 'unknown'
}
+135
View File
@@ -0,0 +1,135 @@
// Rule.Kill — the per-rule policy for a target that cannot be built.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// What these protect, in the order they matter:
//
// 1. A RULE THAT FAILS OPEN IS VISIBLE. `open` sends the rule's traffic out
// direct — around the kill-switch, with the real address — when its target
// does not resolve. It was typed, round-tripped and drawn nowhere, so such a
// rule was indistinguishable from one that fails closed. killMark is what
// the row draws; a null here is an invisible bypass.
// 2. THE FAIL-CLOSED DEFAULT DRAWS NOTHING. If every row wore a badge the two
// states would be priced the same, which is the reading the mark exists to
// prevent.
// 3. AN UNREADABLE VALUE IS ITS OWN STATE. It blocks (the daemon's `default:`
// branch), but silently folding it into `closed` would hide a typo AND let
// the editor rewrite a value it never showed.
// 4. THE POLICY SURVIVES AN EDIT. carryKill is what the edit form writes back;
// it must not lose `open` and must not churn `''`/`default` into `closed`.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
carryKill,
killFallbackTarget,
killMark,
killPolicy,
killSelectValue,
KILL_OPTIONS,
} from './killPolicy.ts'
// --- 1. classification mirrors generate/route.go ruleKillFallback -------------
test('every spelling the daemon treats as fail-closed reads as closed here', () => {
for (const raw of ['', ' ', 'default', 'closed', 'CLOSED', ' Default ']) {
assert.equal(killPolicy(raw), 'closed', `${JSON.stringify(raw)} must be closed`)
assert.equal(killFallbackTarget(raw), 'block')
}
assert.equal(killPolicy(undefined), 'closed')
assert.equal(killPolicy(null), 'closed')
})
test('open is open in any casing, and it is the only value that routes direct', () => {
for (const raw of ['open', 'OPEN', ' Open ']) {
assert.equal(killPolicy(raw), 'open')
assert.equal(killFallbackTarget(raw), 'direct')
}
})
test('an unrecognised value is its own state and still blocks', () => {
assert.equal(killPolicy('opne'), 'unknown')
assert.equal(killPolicy('allow'), 'unknown')
// The daemon: "an unreadable policy must not open a bypass".
assert.equal(killFallbackTarget('opne'), 'block')
})
// --- 2. the row mark ---------------------------------------------------------
test('a rule that fails OPEN is marked on the row, and the mark says it leaves direct', () => {
const mark = killMark({ Kill: 'open' })
assert.notEqual(mark, null, 'kill=open must draw a mark — an unmarked bypass is invisible')
assert.match(mark!.badge, /open/i)
assert.match(mark!.badge, /direct/i)
// The note has to price it, not just name it: around the tunnel, real address.
assert.match(mark!.note, /direct/i)
assert.match(mark!.note, /real IP/i)
assert.match(mark!.note, /kill-switch/i)
})
test('the fail-closed default draws NO mark', () => {
for (const raw of ['', 'default', 'closed', undefined]) {
assert.equal(killMark({ Kill: raw }), null, `${JSON.stringify(raw)} must draw nothing`)
}
})
test('an unreadable policy is marked, quotes the value, and says it blocks', () => {
const mark = killMark({ Kill: 'opne' })
assert.notEqual(mark, null)
assert.match(mark!.badge, /closed/i)
assert.ok(mark!.note.includes('opne'), 'the note must quote what was actually written')
assert.match(mark!.note, /blocked/i)
})
// --- 3. the editor can show it, and offers only what the daemon reads ---------
test('the picker offers exactly the two outcomes the daemon has', () => {
assert.deepEqual(
KILL_OPTIONS.map((o) => o.value),
['closed', 'open'],
)
// The dangerous one must say so in its own label, not only in a warning below.
const open = KILL_OPTIONS.find((o) => o.value === 'open')!
assert.match(open.label, /kill-switch/i)
})
test('the editor preselects the stored policy, and re-surfaces an unknown one verbatim', () => {
assert.equal(killSelectValue(''), 'closed')
assert.equal(killSelectValue('default'), 'closed')
assert.equal(killSelectValue('CLOSED'), 'closed')
assert.equal(killSelectValue('OPEN'), 'open')
// Verbatim, so the <option> the form renders carries the operator's own text.
assert.equal(killSelectValue(' opne '), 'opne')
})
// --- 4. a save must not lose it ----------------------------------------------
test('editing a rule that fails open keeps it failing open', () => {
assert.equal(carryKill('open', 'open'), 'open')
})
test('a no-op edit does not rewrite the stored spelling', () => {
// model/uci.go hands the panel "default" for a rule with no `option kill` at
// all. Rewriting that into "closed" would put an explicit option on every rule
// anyone ever opened the form for, and show a no-op edit as a config change.
assert.equal(carryKill('default', 'closed'), 'default')
assert.equal(carryKill('', 'closed'), '')
assert.equal(carryKill(undefined, 'closed'), '')
assert.equal(carryKill('closed', 'closed'), 'closed')
})
test('a real change of policy is written, in both directions', () => {
assert.equal(carryKill('default', 'open'), 'open')
assert.equal(carryKill('open', 'closed'), 'closed')
})
test('an unknown policy is not silently normalised by a save that did not touch it', () => {
// The form shows "opne" as its own option, so leaving it alone must leave it
// alone. Folding it to `closed` here would be the editor rewriting a value it
// did show, on a save the operator made about something else entirely.
assert.equal(carryKill('opne', 'opne'), 'opne')
// …but picking a real option out of that state does change it.
assert.equal(carryKill('opne', 'closed'), 'closed')
assert.equal(carryKill('opne', 'open'), 'open')
})
+131
View File
@@ -0,0 +1,131 @@
import type { Rule } from './api'
/**
* `Rule.Kill` — what a rule does when its target cannot be built.
*
* WHY THIS MODULE EXISTS. The field was typed, round-tripped and completely
* invisible: no editor, and no mark on the rule row. A rule that fails OPEN —
* i.e. sends its traffic out with the real address when its group/chain/node
* cannot be resolved, deliberately around the kill-switch — was drawn exactly
* like one that fails closed. The single most consequential per-rule setting on
* the page was the one thing the page did not draw.
*
* The behaviour mirrored here is `generate/route.go ruleKillFallback`, which is
* consulted only when the target does not resolve (a dead group, a chain that
* would not assemble, a missing egress/node). The rule is ALWAYS still emitted —
* its traffic never falls through to the default route — so the only question is
* which of two outbounds it gets:
*
* "" | "default" | "closed" → block (fail-closed)
* "open" → direct (a warned, deliberate kill-switch bypass)
* anything else → block, and the daemon warns that the policy was
* unreadable ("an unreadable policy must not open
* a bypass")
*
* It lives outside `pages/Routing.tsx` because it is the part that must be
* TESTED, and the panel's runner is `node --test src/*.test.ts`: plain modules
* only, no JSX, no DOM (same reason as ruleset.ts and egressEdit.ts).
*/
/**
* The three states the panel draws. Positive and closed on purpose: `unknown` is
* a state of its own rather than folded into `closed`, because those two look
* identical to the router and completely different to the operator — one is a
* choice, the other is a typo that happens to land on the safe side.
*/
export type KillPolicy = 'closed' | 'open' | 'unknown'
/** Classify a stored `Kill` exactly as ruleKillFallback switches on it. */
export function killPolicy(raw: string | undefined | null): KillPolicy {
const v = (raw ?? '').trim().toLowerCase()
if (v === '' || v === 'default' || v === 'closed') return 'closed'
if (v === 'open') return 'open'
return 'unknown'
}
/** Where this policy actually sends the traffic. `unknown` blocks, like the daemon. */
export function killFallbackTarget(raw: string | undefined | null): 'block' | 'direct' {
return killPolicy(raw) === 'open' ? 'direct' : 'block'
}
/** The two values the editor offers. `unknown` is surfaced separately (see killSelectValue). */
export const KILL_OPTIONS: ReadonlyArray<{ value: 'closed' | 'open'; label: string }> = [
{ value: 'closed', label: 'Block it — fail closed (default)' },
{ value: 'open', label: 'Send it direct — bypasses the kill-switch' },
]
/**
* The `<select>` value that represents this stored policy.
*
* An unrecognised value comes back VERBATIM so the editor can offer it as its own
* option (the way TargetOptions re-surfaces a target pointing at a since-removed
* node). Folding it to `closed` here would mean the picker silently rewrote a
* value it never showed — the one thing a save is not allowed to do — and would
* also erase the evidence of the typo the daemon is warning about.
*/
export function killSelectValue(raw: string | undefined | null): string {
const p = killPolicy(raw)
if (p === 'unknown') return (raw ?? '').trim()
return p
}
/**
* What a save writes back, given what was stored and what the operator picked.
*
* `""`, `"default"` and `"closed"` are the SAME policy, and the picker shows them
* as one option, so choosing that option on a rule that already had one of them
* must leave the stored spelling alone. Rewriting `default` (what model/uci.go
* hands the panel for a rule with no `option kill` at all) into `closed` would
* put an explicit option on every rule anyone ever opened the editor for, and
* make a no-op edit show up as a config change.
*
* Every other transition writes the picked value: it is a real change of policy.
*/
export function carryKill(stored: string | undefined | null, picked: string): string {
if (picked === 'closed' && killPolicy(stored) === 'closed') return (stored ?? '').trim()
return picked
}
/** One mark on a rule row: the pill, and the sentence under it. */
export interface KillMark {
/** Pill text. Uppercased by the stylesheet — keep it short. */
badge: string
/** The sentence that says what happens and why it matters. */
note: string
}
/**
* The row mark for a rule's kill policy, or `null` when there is nothing to say.
*
* `closed` draws NOTHING. It is the default, it is the safe side, and a badge on
* every row would price the two states the same — which is exactly the reading
* this mark exists to prevent. Only a rule that has been moved off the safe side,
* or one whose policy cannot be read, earns a mark.
*/
export function killMark(rule: Pick<Rule, 'Kill'>): KillMark | null {
const raw = (rule.Kill ?? '').trim()
switch (killPolicy(raw)) {
case 'closed':
return null
case 'open':
return {
badge: 'fails open · direct',
note:
'If this rule’s target can’t be built — a dead group, a missing node, a chain that won’t ' +
'assemble — its traffic leaves direct instead of being blocked: around the tunnel, with ' +
'your real IP address. That is a deliberate kill-switch bypass for this rule alone.',
}
case 'unknown':
return {
badge: 'fails closed · unreadable',
note:
`“${raw}” is not a policy this router reads, so if this rule’s target can’t be built its ` +
'traffic is blocked — the safe side, but not a setting anyone chose. Edit the rule and ' +
'pick Block or Direct.',
}
}
}
/** The flag shown in the rule form while `open` is selected. */
export const KILL_OPEN_FORM_WARN =
'fails open — if this target breaks, the traffic leaves direct with your real IP'
+319
View File
@@ -0,0 +1,319 @@
// Tests for logRoute.ts — the readings that keep the two logs from lying.
//
// Three things are being defended here, and each has an explicit CONTROL:
//
// 1. the empty string is "not recorded", never "no rule" / never "local";
// 2. the search finds what it should AND does not find what it must not
// (a filter that matched everything would pass a one-directional test);
// 3. "the scan stopped" and "the data ran out" never render the same.
//
// Run: npm test (node --test src/*.test.ts)
import test from 'node:test'
import assert from 'node:assert/strict'
import {
CONN_SEARCH_HINT,
LOG_SEARCH_HINT,
chainPath,
connRule,
connSearchFields,
dnsOutbound,
historyExhausted,
logCountLabel,
logEndNote,
logSearchFields,
resumeCursor,
rowMatches,
} from './logRoute.ts'
import type { ConnLogEntry, QueryLogEntry } from './api.ts'
// ---- fixtures ---------------------------------------------------------------
function conn(over: Partial<ConnLogEntry> = {}): ConnLogEntry {
return {
unix: 1_700_000_000,
src_ip: '192.168.1.50',
src_name: 'laptop',
dest: 'youtube.com',
dest_ip: '142.250.74.238',
port: 443,
network: 'tcp',
proto: 'tls',
outbound: 'nl-reality-1',
seq: 7,
rule_kind: 'matched',
rule: 'protocol=tls domain_suffix=youtube.com',
chain: ['nl-reality-1', 'auto'],
...over,
}
}
function query(over: Partial<QueryLogEntry> = {}): QueryLogEntry {
return {
time: '12:00:00',
unix: 1_700_000_000,
domain: 'youtube.com',
qtype: 'A',
rcode: 0,
blocked: false,
server: 'cloudflare-doh',
action: 'proxy',
device: 'laptop',
seq: 7,
status: 'answered',
outbound_kind: 'detour',
outbound: 'nl-reality-1',
...over,
}
}
// ---- 1 · rule_kind: three states, three readings -----------------------------
test('connRule: a matched rule shows the engine match condition', () => {
const r = connRule(conn())
assert.equal(r.kind, 'matched')
assert.equal(r.text, 'protocol=tls domain_suffix=youtube.com')
})
test('connRule: "no rule matched" is a recorded fact with its own words', () => {
const r = connRule(conn({ rule_kind: 'default', rule: '' }))
assert.equal(r.kind, 'default')
assert.match(r.text, /default route/)
assert.match(r.title, /recorded fact/)
})
test('connRule: an empty rule_kind is NOT RECORDED, not "no rule"', () => {
const r = connRule(conn({ rule_kind: '', rule: '' }))
assert.equal(r.kind, 'unrecorded')
assert.match(r.label, /not recorded/)
assert.match(r.title, /does NOT mean no rule matched/)
})
// CONTROL for the three above: it is not enough that each reads sensibly on its
// own — the two rule-less states must be DISTINGUISHABLE. A panel that printed
// an empty cell for both would pass every assertion above.
test('CONTROL connRule: unrecorded and default never render alike', () => {
const unrecorded = connRule(conn({ rule_kind: '', rule: '' }))
const noRule = connRule(conn({ rule_kind: 'default', rule: '' }))
const matched = connRule(conn())
assert.notEqual(unrecorded.kind, noRule.kind)
assert.notEqual(unrecorded.label, noRule.label)
assert.notEqual(unrecorded.title, noRule.title)
const kinds = new Set([unrecorded.kind, noRule.kind, matched.kind])
assert.equal(kinds.size, 3, 'rule_kind must produce three distinct readings')
})
test('connRule: an unrecognised rule_kind falls to unrecorded, the recoverable side', () => {
assert.equal(connRule(conn({ rule_kind: 'tomorrows-word' })).kind, 'unrecorded')
})
test('chainPath: reads rule-named tag first, dialling outbound last', () => {
assert.equal(chainPath(['nl-reality-1', 'auto']), 'auto → nl-reality-1')
assert.equal(chainPath([]), '')
assert.equal(chainPath(undefined), '')
})
// ---- 2 · outbound_kind: four states, four readings ---------------------------
test('dnsOutbound: detour names the tag the lookup left through', () => {
const r = dnsOutbound(query())
assert.equal(r.kind, 'detour')
assert.equal(r.tag, 'nl-reality-1')
})
test('dnsOutbound: default says the lookup went out past the tunnel', () => {
const r = dnsOutbound(query({ outbound_kind: 'default', outbound: '' }))
assert.equal(r.kind, 'default')
assert.match(r.label, /default WAN/)
assert.match(r.title, /outside the tunnel/)
})
test('dnsOutbound: local means nothing egressed at all', () => {
const r = dnsOutbound(query({ outbound_kind: 'local', outbound: '' }))
assert.equal(r.kind, 'local')
assert.match(r.title, /Nothing left the box/)
})
test('dnsOutbound: an empty outbound_kind is NOT RECORDED, not "local"', () => {
const r = dnsOutbound(query({ outbound_kind: '', outbound: '' }))
assert.equal(r.kind, 'unrecorded')
assert.match(r.title, /does NOT mean it stayed on the router/)
})
// CONTROL: all four must be mutually distinguishable. The dangerous collapses are
// default↔unrecorded (both have no tag) and local↔unrecorded (both "went nowhere"
// if you squint), so assert the whole set at once rather than one pair.
test('CONTROL dnsOutbound: four states, four distinct labels', () => {
const rs = ['detour', 'default', 'local', ''].map((k) =>
dnsOutbound(query({ outbound_kind: k, outbound: k === 'detour' ? 'nl-reality-1' : '' })),
)
assert.equal(new Set(rs.map((r) => r.kind)).size, 4)
assert.equal(new Set(rs.map((r) => r.label)).size, 4)
assert.equal(new Set(rs.map((r) => r.title)).size, 4)
})
test('dnsOutbound: an unrecognised outbound_kind falls to unrecorded', () => {
assert.equal(dnsOutbound(query({ outbound_kind: 'tomorrows-word' })).kind, 'unrecorded')
})
// ---- 3 · the search: finds, and does not find --------------------------------
test('search FINDS: a domain in the query log', () => {
assert.equal(rowMatches(logSearchFields(query()), 'YOUTUBE'), true)
})
test('search FINDS: a device, a resolver and an exit tag', () => {
const f = logSearchFields(query())
assert.equal(rowMatches(f, 'laptop'), true)
assert.equal(rowMatches(f, 'cloudflare'), true)
assert.equal(rowMatches(f, 'reality'), true)
})
test('search FINDS: a domain through the rule that routed it', () => {
const f = connSearchFields(conn({ dest: '142.250.74.238', dest_ip: '142.250.74.238' }))
assert.equal(rowMatches(f, 'youtube.com'), true, 'the rule text carries the domain')
})
test('search FINDS: a chain hop', () => {
assert.equal(rowMatches(connSearchFields(conn()), 'auto'), true)
})
// CONTROL for every assertion above: a matcher that returned true unconditionally
// passes all of them. These are the searches that MUST come back empty — the two
// vocabulary words and the port, excluded by the daemon on purpose.
test('CONTROL search DOES NOT FIND: outbound_kind is not searched', () => {
const row = query({ outbound_kind: 'default', outbound: '', server: 'router-local', action: 'pass', device: 'tv', domain: 'example.org', qtype: 'A' })
assert.equal(
rowMatches(logSearchFields(row), 'default'),
false,
'q=default must not match every default-egress row',
)
})
test('CONTROL search DOES NOT FIND: rule_kind is not searched', () => {
const row = conn({ rule_kind: 'matched', rule: 'port=443', dest: 'a.example', dest_ip: '1.2.3.4', outbound: 'wan', chain: undefined, src_name: 'tv', network: 'tcp', proto: 'tls' })
assert.equal(rowMatches(connSearchFields(row), 'matched'), false)
})
test('CONTROL search DOES NOT FIND: the port number is not searched', () => {
const row = conn({ port: 8443, dest: 'a.example', dest_ip: '10.0.0.1', rule: 'domain=a.example', chain: undefined, outbound: 'wan', src_ip: '192.168.1.9', src_name: 'tv' })
assert.equal(rowMatches(connSearchFields(row), '8443'), false)
})
test('CONTROL search DOES NOT FIND: a string that is in no searched field', () => {
assert.equal(rowMatches(logSearchFields(query()), 'facebook'), false)
})
test('search: the field lists are exactly the daemon\'s (filter.go matchLog/matchConn)', () => {
assert.deepEqual(logSearchFields(query({ status: 'failed', error: 'i/o timeout' })), [
'youtube.com',
'A',
'cloudflare-doh',
'proxy',
'laptop',
'nl-reality-1',
'i/o timeout',
])
assert.deepEqual(connSearchFields(conn()), [
'192.168.1.50',
'laptop',
'youtube.com',
'142.250.74.238',
'tcp',
'tls',
'nl-reality-1',
'protocol=tls domain_suffix=youtube.com',
'nl-reality-1',
'auto',
])
})
test('search hints name what is searched', () => {
assert.match(LOG_SEARCH_HINT, /domain/)
assert.match(CONN_SEARCH_HINT, /rule/)
})
// ---- 4 · a truncated page is not the end of the log --------------------------
test('historyExhausted: a short unfiltered page really is the end', () => {
assert.equal(historyExhausted({ returned: 3, page: 100, truncated: false }), true)
})
test('historyExhausted: a short TRUNCATED page is NOT the end', () => {
assert.equal(
historyExhausted({ returned: 3, page: 100, truncated: true }),
false,
'the scan budget ran out — there is more log behind it',
)
})
test('historyExhausted: an empty truncated page is still not the end', () => {
assert.equal(historyExhausted({ returned: 0, page: 100, truncated: true }), false)
})
// CONTROL: the two situations must reach DIFFERENT verdicts from the same page
// shape. A hook that ignored `truncated` would answer "exhausted" to both, hide
// the paging control and call a partial search complete.
test('CONTROL historyExhausted: budget-ended and data-ended differ on identical pages', () => {
const shape = { returned: 3, page: 100 }
assert.notEqual(
historyExhausted({ ...shape, truncated: true }),
historyExhausted({ ...shape, truncated: false }),
)
})
test('resumeCursor: the daemon scan cursor wins over the oldest row', () => {
assert.equal(resumeCursor({ cursor: 41, rows: [{ seq: 90 }, { seq: 88 }] }), 41)
})
test('resumeCursor: an empty truncated page can still be continued', () => {
assert.equal(resumeCursor({ cursor: 41, rows: [] }), 41, 'no row seq exists — only the cursor can carry on')
})
test('resumeCursor: falls back to the oldest row, then to nowhere', () => {
assert.equal(resumeCursor({ cursor: 0, rows: [{ seq: 90 }, { seq: 88 }] }), 88)
assert.equal(resumeCursor({ cursor: 0, rows: [] }), null)
})
test('logEndNote: a completed empty search says nothing matched', () => {
const n = logEndNote({ filter: 'vk.com', truncated: false, matches: 0 })
assert.equal(n.complete, true)
assert.match(n.text, /Nothing in the log matches/)
})
test('logEndNote: a truncated empty search says it STOPPED, not that nothing exists', () => {
const n = logEndNote({ filter: 'vk.com', truncated: true, matches: 0 })
assert.equal(n.complete, false)
assert.match(n.text, /stopped/)
assert.match(n.text, /not the end of the log/)
assert.doesNotMatch(n.text, /Nothing in the log matches/)
})
// CONTROL: same filter, same zero rows, one bit of difference — the two must not
// produce the same sentence or the same `complete` verdict. This is the assertion
// that fails if the panel draws "budget ran out" and "data ran out" identically.
test('CONTROL logEndNote: zero matches reads differently when the scan stopped early', () => {
const ended = logEndNote({ filter: 'vk.com', truncated: false, matches: 0 })
const stopped = logEndNote({ filter: 'vk.com', truncated: true, matches: 0 })
assert.notEqual(ended.text, stopped.text)
assert.notEqual(ended.complete, stopped.complete)
})
test('logCountLabel: never claims "all loaded" on a truncated scan', () => {
const t = logCountLabel({ rows: 12, showMore: false, truncated: true, filtered: true })
assert.doesNotMatch(t, /all loaded/)
assert.match(t, /scan incomplete/)
})
// CONTROL: the un-truncated version of the very same page DOES say "all loaded",
// so the assertion above is about truncation and not about the wording never
// appearing at all.
test('CONTROL logCountLabel: the same page says "all loaded" when the scan completed', () => {
const shape = { rows: 12, showMore: false, filtered: true }
assert.match(logCountLabel({ ...shape, truncated: false }), /all loaded/)
assert.notEqual(
logCountLabel({ ...shape, truncated: false }),
logCountLabel({ ...shape, truncated: true }),
)
})
+560
View File
@@ -0,0 +1,560 @@
// Reading the two log streams honestly: WHY a connection went where it went,
// WHERE a DNS lookup left the box, what the search box does and does not cover,
// and — the one that matters most — whether a short page is the end of the log
// or a scan that stopped early.
//
// Everything here is pure so it can be tested without a DOM. The page renders
// these readings; it never re-derives them, because each of the three closed
// vocabularies below has the same trap in it: the EMPTY STRING IS RESERVED FOR
// "NOT RECORDED", and a reader that folds it into the neighbouring value turns
// "we don't know" into a confident wrong answer.
//
// Backend contract: shater/stats/stats.go (ConnLogEntry.RuleKind,
// LogEntry.OutboundKind), shater/stats/filter.go (matchLog/matchConn),
// shater/stats/store.go (LogPage.Truncated/ScanCursor).
import type { ConnLogEntry, QueryLogEntry } from './api'
// ---- why a connection went where it went -----------------------------------
/** The closed vocabulary of `ConnLogEntry.rule_kind`, normalised. */
export type ConnRuleKind = 'matched' | 'default' | 'unrecorded'
export interface ConnRuleReading {
kind: ConnRuleKind
/** Short chip label — what KIND of answer this is. */
label: string
/** The detail line: the engine's match condition, or the statement itself. */
text: string
/** Tooltip: the full sentence, including what the state does NOT mean. */
title: string
}
/**
* How to render one connection's routing record.
*
* The list is POSITIVE and CLOSED: anything the daemon might send that is not
* one of the two recorded words lands on `unrecorded`, the recoverable side. An
* open `default:` here would quietly file an unknown future value under "no rule
* matched" — a sentence about the config that nobody wrote.
*/
export function connRule(
e: Pick<ConnLogEntry, 'rule_kind' | 'rule'>,
): ConnRuleReading {
const rule = (e.rule ?? '').trim()
switch (e.rule_kind) {
case 'matched':
return {
kind: 'matched',
label: 'rule',
// A matched row without condition text is degenerate, but saying so beats
// printing an empty cell that reads as "no rule".
text: rule || 'condition not recorded',
title: rule
? `A routing rule matched this connection: ${rule}. This is the engine's match condition, not the rule name from the config.`
: 'A routing rule matched this connection, but its match condition was not recorded.',
}
case 'default':
return {
kind: 'default',
label: 'no rule',
text: 'default route',
title:
'No routing rule matched — the connection took the default route. A recorded fact, not missing data.',
}
default:
return {
kind: 'unrecorded',
label: 'not recorded',
text: '',
title:
'Nothing was recorded about routing for this row. It does NOT mean no rule matched — that state says so by name.',
}
}
}
/**
* The outbound path a connection took, written the way a person reads it: the
* tag the rule named first, the outbound that actually dialled last.
*
* The wire order is the opposite (`chain[0]` is the dialler), so this reverses
* it. '' when there is no path to show — a single-hop route has none, and an
* absent key is not an empty path to draw.
*/
export function chainPath(chain?: string[] | null): string {
if (!chain || chain.length === 0) return ''
return [...chain].reverse().join(' → ')
}
// ---- did the connection go anywhere at all? ---------------------------------
/**
* The closed vocabulary of a connection's FATE — a second axis, orthogonal to
* {@link connRule}, and the one the operator is actually sorting rows by.
*
* killed — the route sent this connection to the engine's `block`
* outbound. Nothing left the router for it.
* carried — it left through some named outbound. Says which WAY it went,
* never that it worked; the log records the route decision, not
* the result of the flow.
* unrecorded — no outbound tag on the row. NOT a claim that it was carried,
* and NOT a claim that it was killed.
*/
export type ConnFate = 'killed' | 'carried' | 'unrecorded'
export interface ConnFateReading {
fate: ConnFate
/** Short chip label. The three states must not share one. */
label: string
title: string
}
/**
* `block` is the engine's own reserved outbound tag (generate/generate.go
* tagBlock), and generate/outbound.go + generate/group.go SKIP any node or group
* whose name collides with it — so the tag on a connection row can only be the
* block outbound. That reservation is an EXACT string comparison in the
* generator, so the comparison here is exact too: a node someone named `Block`
* is emitted under that tag and is a real destination, not a kill.
*
* WHY this axis exists. `outbound:"block"` is a legitimate rule target and the
* value of route.Final on a fail-closed box, so it is what a kill-switch drop
* looks like in the log — and it used to be drawn as plain mono text,
* indistinguishable from `outbound:"nl-reality-1"`, on the same page where a DNS
* row for the same host gets a crit rail and a BLOCK mark. The connection log is
* the first place a "this site does not open" report is taken to, and the row
* that IS the answer must not read as traffic that was carried.
*
* It deliberately does NOT say WHICH decision killed it — a rule with target
* Block, or the default route on a box with no matching rule. That is the
* {@link connRule} chip beside it, and duplicating it here could only disagree
* with it.
*
* POSITIVE and CLOSED, like {@link connRule}: anything that is not the reserved
* tag and not empty is `carried` — a named outbound this build does not have to
* recognise to report honestly.
*/
export function connFate(e: Pick<ConnLogEntry, 'outbound'>): ConnFateReading {
const tag = (e.outbound ?? '').trim()
if (tag === 'block') {
return {
fate: 'killed',
label: 'killed',
title:
'The router did NOT carry this connection: the route sent it to the block outbound, so nothing left the box for it. The chip beside this says which decision did it — a routing rule, or the default route.',
}
}
if (tag === '') {
return {
fate: 'unrecorded',
label: 'no exit recorded',
title:
'No outbound was recorded for this connection. It is NOT a claim that it was carried, and NOT a claim that it was blocked — nothing about its exit is known.',
}
}
return {
fate: 'carried',
label: 'carried',
title: `The route sent this connection out through ${tag}. That is which WAY it went — the log records the routing decision, not whether the flow then succeeded.`,
}
}
// ---- where a DNS lookup left the box ---------------------------------------
/** The closed vocabulary of `QueryLogEntry.outbound_kind`, normalised. */
export type DnsOutKind = 'detour' | 'default' | 'local' | 'unrecorded'
export interface DnsOutReading {
kind: DnsOutKind
/** Short chip label — the four states must not share one. */
label: string
/** The tag, when there is one to name; '' otherwise. */
tag: string
title: string
}
/**
* How to render where one DNS lookup went out.
*
* Four states, four labels. `default` is the one an operator has to be able to
* see at a glance: it means the resolver names no detour, so the lookup left
* over the router's own WAN — past the tunnel — which is exactly the leak an
* anti-leak `detour` is configured to prevent. It is still not an ERROR (a box
* with no anti-leak intent reads `default` all day), so it is marked, not alarmed.
*
* POSITIVE and CLOSED, same as {@link connRule}: an unrecognised value is
* `unrecorded`.
*/
export function dnsOutbound(
e: Pick<QueryLogEntry, 'outbound_kind' | 'outbound'>,
): DnsOutReading {
const tag = (e.outbound ?? '').trim()
switch (e.outbound_kind) {
case 'detour':
return {
kind: 'detour',
label: 'via',
tag,
title: tag
? `The resolver that answered is bound to a detour: this lookup's own packets left through ${tag}. It is the configured binding, so a group tag names the group, not the member that was live.`
: 'The resolver that answered is bound to a detour, but the outbound tag was not recorded.',
}
case 'default':
return {
kind: 'default',
label: 'default WAN',
tag: '',
title:
"The resolver that answered names no detour, so this lookup left over the router's own WAN — outside the tunnel. A recorded fact: on a box configured for DNS anti-leak it is the one to look at.",
}
case 'local':
return {
kind: 'local',
label: 'local',
tag: '',
title:
'Answered on the router — a cache hit, an optimistic answer, or a filter block. Nothing left the box for this query.',
}
default:
return {
kind: 'unrecorded',
label: 'not recorded',
tag: '',
title:
'Where this lookup went out was not recorded. It does NOT mean it stayed on the router — that state says so by name.',
}
}
}
// ---- what came of a DNS lookup, and which way it went ----------------------
/** The OUTCOME axis — the closed vocabulary of `QueryLogEntry.status`. */
export type DnsOutcomeKind = 'answered' | 'failed' | 'unrecorded'
/** The PATH axis — the closed vocabulary of `QueryLogEntry.action`, plus the
* recoverable side for anything this build does not recognise. */
export type DnsPathKind = 'block' | 'proxy' | 'pass' | 'unknown'
/**
* The FOUR SITUATIONS one DNS row can be in, as one word for the row itself.
* They are what the reader is actually sorting rows into, and no two of them may
* ever be drawn the same way:
*
* answered — an answer came back and the filter did not make it.
* blocked — the filter answered it on purpose. Intended, not a fault.
* failed — no usable answer. A fault, whichever path it took.
* unrecorded — the outcome is not known. Neither of the three above.
*/
export type DnsRowState = 'answered' | 'blocked' | 'failed' | 'unrecorded'
/**
* Everything one DNS row draws about outcome and path. The page renders this and
* derives nothing of its own, so the four situations cannot quietly collapse into
* three in one component while the tests check another.
*/
export interface DnsRowMark {
/** The row's own state — drives the row-level marking (`st-<state>`). */
state: DnsRowState
/** The outcome chip: what came of the lookup (`s-<outcome>`). */
outcome: DnsOutcomeKind
label: string
title: string
/** The path chip: which way it went (`.tag <path>`). Kept VISIBLE in every
* state — "it failed" and "it failed in the tunnel" are different reports. */
path: DnsPathKind
pathLabel: string
pathTitle: string
/** The failure cause, already phrased. '' unless the outcome is `failed`, and
* never empty when it is: a failure with no recorded cause says so. */
cause: string
/** What the rcode adds to a failure: whether the server answered at all.
* '' unless the outcome is `failed`. */
rcodeNote: string
}
/** The outcome axis alone. POSITIVE and CLOSED: an unrecognised value — including
* a future one — lands on `unrecorded`, which is the side you can recover from.
* The old reading had an open `return 'pass'` here, so every unknown value came
* out green. */
function dnsOutcomeKind(status: string | undefined): DnsOutcomeKind {
switch (status) {
case 'answered':
return 'answered'
case 'failed':
return 'failed'
default:
return 'unrecorded'
}
}
/** The path axis alone. Same discipline, same reason. */
function dnsPathKind(action: string | undefined): DnsPathKind {
switch (action) {
case 'block':
return 'block'
case 'proxy':
return 'proxy'
case 'pass':
return 'pass'
default:
return 'unknown'
}
}
/**
* How to render one DNS row's outcome and path.
*
* The two are separate axes on purpose. `action` says WHICH WAY the lookup went;
* `status` says WHAT CAME OF IT. A lookup that left through a detour and then
* timed out is `proxy` AND `failed`, and that pair is the single most useful row
* in the log — it names the tunnel as the thing that broke. When the outcome axis
* did not exist, that row was drawn from `action` alone: an accent-coloured
* `proxy` tag, no other marking, the healthiest-looking row on the page.
*
* So the OUTCOME leads and the PATH stays beside it, never the other way round.
*/
export function dnsRowMark(
e: Pick<QueryLogEntry, 'status' | 'error' | 'rcode' | 'action' | 'blocked'>,
): DnsRowMark {
const outcome = dnsOutcomeKind(e.status)
const path = dnsPathKind(e.action)
const rawAction = (e.action ?? '').trim()
const pathTitle =
path === 'unknown'
? rawAction
? `This build does not recognise the recorded path “${rawAction}”, so nothing is claimed about which way this lookup went.`
: 'Which way this lookup went was not recorded.'
: path === 'block'
? 'The DNS filter answered this lookup itself — nothing was carried out of the box for it.'
: path === 'proxy'
? 'This lookup took the proxied path. It says which WAY it went, not whether it worked — read the outcome beside it.'
: 'This lookup took the direct path. It says which WAY it went, not whether it worked — read the outcome beside it.'
// The row state folds in the one fact the outcome axis deliberately does not
// carry: a blocked lookup IS answered, but "the filter answered it" and "the
// resolver answered it" are not the same report and must not look alike.
// `blocked` is only trusted to REFINE `answered` — on an unrecorded row the
// outcome stays unrecorded, because a build that never wrote the outcome is not
// a build whose other fields get to stand in for it.
const state: DnsRowState =
outcome === 'answered' ? (e.blocked ? 'blocked' : 'answered') : outcome
if (outcome === 'failed') {
const err = (e.error ?? '').trim()
return {
state,
outcome,
label: 'failed',
title:
'No usable answer was produced: a timeout or network error, a resolver refusal, a loopback, or a cached rejection. The path beside it says which way the lookup went before it failed.',
path,
pathLabel: dnsPathLabel(path),
pathTitle,
// A failure whose cause was not recorded is still a failure, and saying so
// is the whole point — an empty cell here would read as "nothing wrong".
cause: err ? `cause ${err}` : 'cause not recorded',
// -1 is the daemon's "no response at all"; anything else is a code the
// server really sent back (2 SERVFAIL, 5 REFUSED).
rcodeNote: e.rcode === -1 ? 'no response' : `rcode ${e.rcode}`,
}
}
return {
state,
outcome,
label: outcome === 'answered' ? 'answered' : 'not recorded',
title:
outcome === 'answered'
? 'An answer was produced — by the resolver, from the cache, or synthesized by the filter when it blocked. It does not claim the answer was useful: an upstream NXDOMAIN is answered too.'
: 'What came of this lookup was not recorded — an older row, or a producer this build does not recognise. It is NOT a claim that the lookup succeeded.',
path,
pathLabel: dnsPathLabel(path),
pathTitle,
cause: '',
rcodeNote: '',
}
}
/** The path chip's text. `unknown` is named, not blanked: a path nobody recorded
* and a path this build cannot read are both "we do not know", and an empty chip
* would read as "direct". */
function dnsPathLabel(k: DnsPathKind): string {
return k === 'unknown' ? 'unknown' : k
}
// ---- what the search box covers --------------------------------------------
/**
* The fields the daemon searches in a QUERY-log row — the mirror of
* stats/filter.go matchLog, and the same POSITIVE, CLOSED list.
*
* It exists so the panel can say what it searches (and the `?mock` backend can
* behave like the real one) without either of them drifting into searching
* something the daemon doesn't. `error` is in because a failure cause is how the
* failures are found at all — `q=timeout` is the search somebody actually runs.
* Deliberately absent: seq/unix/time/rcode (numbers, where a substring is noise),
* `blocked` (a boolean), and the two vocabulary words `outbound_kind` and
* `status` — `q=default` would match every default-egress row while the operator
* was looking for a tag by that name, and `q=failed` would match every failure
* while they were looking for a domain with "failed" in it.
*/
export function logSearchFields(e: QueryLogEntry): string[] {
return [e.domain, e.qtype, e.server, e.action, e.device, e.outbound, e.error ?? '']
}
/**
* The fields the daemon searches in a CONNECTION-log row — the mirror of
* stats/filter.go matchConn. `rule` is in because the engine's match condition
* is how a domain is found through the rule that routed it. Deliberately absent:
* seq/unix, `port` (a bare "443" would also match any address containing 443,
* reading as a port filter while being something else) and `rule_kind`.
*/
export function connSearchFields(e: ConnLogEntry): string[] {
const out = [e.src_ip, e.src_name, e.dest, e.dest_ip, e.network, e.proto, e.outbound, e.rule]
return e.chain ? out.concat(e.chain) : out
}
/** Human-readable naming of {@link logSearchFields}, for the search box hint. */
export const LOG_SEARCH_HINT = 'domain · type · resolver · action · device · exit · error'
/**
* Human-readable naming of {@link connSearchFields}.
*
* It must name EVERY field in that list. A hint that stops short is a promise the
* search over-keeps: `proto` and the chain hops WERE searched (stats/filter.go
* matchConn walks e.Proto and every e.Chain element) while this line said they
* were not, so a hit on either read as the filter misbehaving.
*/
export const CONN_SEARCH_HINT =
'device · destination · network · proto · exit · rule · path'
/**
* ASCII-only lowercase — A-Z and nothing else, the exact fold stats/filter.go
* performs (lowerASCII on the needle, matchAtFold on the haystack byte).
*
* `String.prototype.toLowerCase` is Unicode-aware and therefore WRONG here: it
* case-folds non-ASCII letters too, which the daemon does not do — pinned on that
* side by the non-ASCII case of stats/logfilter_test.go TestContainsFold, where an
* upper-case non-ASCII needle is asserted NOT to match its lower-case haystack.
* The panel folding wider than the daemon is not a harmless nicety: the `?mock`
* backend exists so a search behaves in mock mode exactly as it does on hardware,
* and a needle that finds rows in the panel and none on the router is the one
* outcome that makes the filter untrustworthy.
*/
function lowerAscii(s: string): string {
let out = ''
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : s[i]
}
return out
}
/**
* Case-insensitive substring match over a row's searched fields — the client-side
* twin of stats/filter.go containsFold, used by the `?mock` backend so a search
* in mock mode finds exactly what a search on hardware finds.
*
* ASCII folding only, on BOTH sides, exactly like the daemon: non-ASCII compares
* code unit for code unit, so no Unicode case pair matches. See {@link lowerAscii}.
*/
export function rowMatches(fields: readonly string[], needle: string): boolean {
const q = lowerAscii(needle.trim())
if (!q) return true
return fields.some((f) => lowerAscii(f ?? '').includes(q))
}
// ---- a short page is not always the end ------------------------------------
/**
* Is the history behind this page exhausted?
*
* A short page normally means the log ran out — but NOT when the daemon reported
* that its scan budget ended the walk. Reading a truncated page as exhausted is
* how the panel would hide the rest of the log behind a "nothing found": the
* paging control disappears and the operator is told the search is complete when
* it stopped early.
*/
export function historyExhausted(p: {
returned: number
page: number
truncated: boolean
}): boolean {
if (p.truncated) return false
return p.returned < p.page
}
/**
* Where to resume paging from. The daemon's scan cursor is authoritative — on a
* complete page it already equals the last row's seq, and on a truncated page it
* is the ONLY thing that can carry the search on, because a page with no matches
* has no row seq to page from. 0/absent ⇒ fall back to the oldest row we hold;
* null ⇒ there is nowhere to resume.
*/
export function resumeCursor(p: { cursor: number; rows: readonly { seq: number }[] }): number | null {
if (p.cursor > 0) return p.cursor
const last = p.rows[p.rows.length - 1]
return last ? last.seq : null
}
/**
* The sentence under a log that has nothing (more) to show.
*
* `complete` is the load-bearing part: it says whether the panel is claiming to
* have reached the end of the data. It is false whenever the scan stopped early,
* and the text says so in those words — "stopped" and "not the end", never
* "nothing found".
*/
export interface LogEndNote {
/** True only when the panel can honestly claim the search reached the end. */
complete: boolean
text: string
}
export function logEndNote(p: {
filter: string
truncated: boolean
matches: number
}): LogEndNote {
const q = p.filter.trim()
if (p.truncated) {
return {
complete: false,
text:
p.matches === 0
? `The search stopped at the daemon's scan limit before it matched anything. This is not the end of the log — there may be matches further back.`
: `The search stopped at the daemon's scan limit. These are the matches found so far, not all of them.`,
}
}
if (q) {
return {
complete: true,
text:
p.matches === 0
? `Nothing in the log matches “${q}”.`
: `All ${p.matches === 1 ? 'match' : `${p.matches} matches`} for “${q}” are loaded.`,
}
}
return {
complete: true,
text: p.matches === 0 ? 'Nothing logged yet.' : 'The whole log is loaded.',
}
}
/**
* The count read-out under a log. It exists as a function because the string it
* must NEVER produce is "all loaded" on a truncated scan — the panel would be
* signing for data it never looked at.
*/
export function logCountLabel(p: {
rows: number
showMore: boolean
truncated: boolean
filtered: boolean
}): string {
const n = p.rows.toLocaleString('en-US')
const noun = p.filtered ? (p.rows === 1 ? 'match' : 'matches') : 'loaded'
if (p.truncated) return `${n} ${p.filtered ? noun : 'rows'} · scan incomplete`
if (p.showMore) return p.filtered ? `${n} ${noun}` : `${n} loaded`
return p.filtered ? `${n} ${noun} · all loaded` : `${n} · all loaded`
}
+503 -52
View File
@@ -7,7 +7,10 @@
//
// Type-only imports from api.ts (erased at build) keep this free of a runtime cycle.
import { killSwitchClosed } from './planeState'
import type { ApplyResult, ChainHealth, ChainHopHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, Profile, QueryLogEntry, RuleReach, RulesReachability, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning, Traffic } from './api'
// The SAME field lists and matcher the page documents and the daemon implements —
// so a search in `?mock` cannot quietly be more (or less) generous than the real one.
import { connSearchFields, logSearchFields, rowMatches } from './logRoute'
import type { ApplyResult, ChainHealth, ChainHopHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, Profile, QueryLogEntry, RuleReach, RulesReachability, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning, TestKind, Traffic } from './api'
/** One URL knob, safe to read before `location` exists (SSR-less builds/tests). */
function mockParam(name: string): string | null {
@@ -15,6 +18,28 @@ function mockParam(name: string): string | null {
return new URLSearchParams(location.search).get(name)
}
/**
* `?subleak=draft|applied|divergent` — which half of the subscription
* fetch-route reconciliation to show (see subFetch.ts). Anything else, or
* absent, leaves the fixture's subscription fetching directly, which is the
* state that must show NO badge at all.
*/
type SubLeakMode = '' | 'draft' | 'applied' | 'divergent' | 'paused' | 'pausedapplied' | 'nourl'
const SUB_LEAK_MODES: readonly SubLeakMode[] = [
'draft',
'applied',
'divergent',
'paused',
'pausedapplied',
'nourl',
]
function mockSubLeak(): SubLeakMode {
const want = (mockParam('subleak') ?? '').trim()
return (SUB_LEAK_MODES as readonly string[]).includes(want) ? (want as SubLeakMode) : ''
}
let armed = false // a pending commit-confirm auto-rollback
let hasLastGood = false // a predecessor config exists to roll back to (post-apply)
let hash = 'sha256:9f7c07e8d8ac4ae1'
@@ -47,6 +72,12 @@ const CONFIG: Model = {
// routes ESP/GRE/SCTP out that interface). Both off by default, as shipped.
L3Tunnel: mockParam('l3') === '1',
UntunnelableEgress: mockParam('uegress') ?? '',
// Network-wide DNS filter, OFF by default here. That is the interesting
// state: the devices below attach lists, which keep running for them with
// this switch off, so the master switch has to say so instead of calling the
// lists "configured but inactive". ?mock&dnsfilter=1 turns it on to see the
// other sentence.
DNSFilter: mockParam('dnsfilter') === '1',
DNSIntercept: true, // force ALL LAN plaintext DNS (:53) through the engine
BlockDoH: false, // block known public DoH resolvers so clients fall back to plaintext :53
GroupHealth: true, // observatory: background probing of used groups/chains + Targets health stats (default on)
@@ -89,10 +120,32 @@ const CONFIG: Model = {
// of a 200 GB plan used, expiring in ~24 days. Drives the quota bar + countdown.
{
Name: 'primary',
Enabled: true,
URL: 'https://sub.example.net/link',
// SWITCHED OFF in the `paused` mode, and that mode exists because the
// switch does not do what the badge used to claim: apply.go fetches a
// subscription BY NAME without reading Enabled, and the row's own
// "Fetch now" is not gated on it either. Off + proxy + no route was drawn
// quiet, with "nothing is disclosed yet" — over a button that discloses.
Enabled: mockSubLeak() !== 'paused' && mockSubLeak() !== 'pausedapplied',
// `nourl` is the other half of the same split, and the only genuinely quiet
// one: subscribe/fetch.go refuses an empty URL before it builds a request.
URL: mockSubLeak() === 'nourl' ? '' : 'https://sub.example.net/link',
Format: 'auto',
UpdateInterval: '12h',
// The fetch route, driven from the URL so the ONE badge can be seen in each
// of the states it has to reconcile (see subFetch.ts and mockSubLeak):
// ?subleak=draft — proxy, no route: the panel PREDICTS the disclosure
// ?subleak=applied — the same config, and the daemon has now REPORTED it
// ?subleak=divergent — a route IS named, and the daemon reports the leak
// anyway. The dangerous direction: the panel's own
// predicate is content and must not be believed over
// the applied verdict.
// ?subleak=paused — switched off, proxy, no route: amber, because the
// switch stops the SCHEDULED refresh and nothing else
// ?subleak=pausedapplied — the same, and the daemon has REPORTED it, with
// its own disabled-subscription sentence
// ?subleak=nourl — no URL: the one state that really is quiet
FetchVia: mockSubLeak() ? 'proxy' : undefined,
FetchDetour: mockSubLeak() === 'divergent' ? 'group:auto' : undefined,
UserUpload: 8_142_336_512,
UserDownload: 60_293_117_952,
UserTotal: 214_748_364_800, // 200 GiB
@@ -168,8 +221,6 @@ const CONFIG: Model = {
// Goes straight out the default WAN, but splits the TLS ClientHello on the way
// — a direct egress exists to carry a native DPI preset.
{ Name: 'tunnel', Type: 'direct', DPI: 'fragment' },
// The local ciadpi desync proxy (the only type that dials a port).
{ Name: 'ciadpi', Type: 'byedpi', Port: 1080 },
],
Rules: [
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
@@ -209,6 +260,19 @@ const CONFIG: Model = {
{ Name: 'StevenBlack', Enabled: true, Source: 'url', URL: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', Response: 'nxdomain', UpdateInterval: '24h' },
{ Name: 'oisd-basic', Enabled: true, Source: 'url', URL: 'https://big.oisd.nl/domainswild', Response: 'nxdomain', UpdateInterval: '24h' },
{ Name: 'telegram-block', Enabled: false, Source: 'geosite', Categories: ['telegram'], Response: 'nxdomain', UpdateInterval: '24h' },
// OFF for the network and ATTACHED to a device — the state the panel used to
// draw as dead. It is inline, so its content is the config and it loads at
// apply; the status record below reports it so the chip can be honestly green.
{ Name: 'family-extra', Enabled: false, Source: 'inline', Entries: ['roblox.com', 'discord.com', 'twitch.tv'], Response: 'zero' },
],
// Allow lists a device can attach. Three readings, all reachable offline:
// school-allow is loaded, work-allow has never been fetched (crit), and
// holidays-allow has NO status record at all — "load unknown", which must not
// be drawn as healthy.
Allowlists: [
{ Name: 'school-allow', Enabled: true, Source: 'inline', Entries: ['school.example.edu', 'classroom.google.com'] },
{ Name: 'work-allow', Enabled: false, Source: 'url', URL: 'https://lists.example.net/work-allow.txt?key=secret', UpdateInterval: '6h' },
{ Name: 'holidays-allow', Enabled: true, Source: 'geosite', Categories: ['github'], UpdateInterval: '24h' },
],
Resolvers: [
{ Name: 'cloudflare-doh', Type: 'doh', Address: 'https://1.1.1.1/dns-query', Detour: 'tunnel' },
@@ -229,22 +293,37 @@ const CONFIG: Model = {
// getDevices() below — without these the Devices page showed every client as
// unmanaged while discovery claimed configured:true, which is not a state the
// real backend can produce (it derives `configured` from exactly this list).
//
// Between them the three rows reach every chip state the picker can draw:
// loaded (oisd-basic), not loaded (StevenBlack — never fetched), load unknown
// (holidays-allow — no status record), no such list (`old-adblock`, a name the
// config no longer has), and a list that is OFF for the network but attached
// here and working (family-extra).
Devices: [
{ Name: 'Max laptop', MAC: 'a4:83:e7:11:22:33', Enabled: true },
{
Name: 'Max laptop',
MAC: 'a4:83:e7:11:22:33',
Enabled: true,
Blocklists: ['oisd-basic'],
},
{
Name: "Lena's phone",
MAC: 'f0:18:98:aa:bb:cc',
Enabled: true,
Block: ['tiktok.com', 'ads.doubleclick.net', 'telemetry.example'],
Block: ['tiktok.com', 'ads.doubleclick.net', 'keyword:telemetry'],
Blocklists: ['StevenBlack'],
Allowlists: ['school-allow'],
},
// Paused policy + an allow that overrides a network blocklist — exercises the
// "paused" badge and the allowlist chips in one row.
// "paused" badge, both chip lanes, and the two failure readings in one row.
{
Name: 'Kids iPad',
MAC: '3c:22:fb:44:55:66',
Enabled: false,
Block: ['youtube.com', 'roblox.com', 'discord.com'],
Block: ['youtube.com', 'roblox.com', 'full:discord.com'],
Allow: ['school.example.edu'],
Blocklists: ['family-extra', 'old-adblock'],
Allowlists: ['holidays-allow', 'work-allow'],
},
],
Alerts: [
@@ -312,7 +391,13 @@ const RULESET_STATUS: RulesetStatus[] = [
rule_count: 14_203,
},
{ tag: 'rs-ad-hosts', name: 'ad-hosts', category: '', kind: 'ruleset', remote: true, last_updated: '', interval_seconds: 43_200, rule_count: 0 },
{ tag: 'rs-private-nets', name: 'private-nets', category: '', kind: 'ruleset', remote: false, last_updated: '', interval_seconds: 0, rule_count: 3 },
// LOCAL (inline) — and its rule_count is 0 because the daemon has no other
// answer to give: engine.go fills RuleSetStat.RuleCount from
// (*rule.RemoteRuleSet).RuleCount(), and there is no LocalRuleSet.RuleCount in
// the tree at all. This fixture used to say 3, a number no router can produce,
// which is how the panel's "empty — nothing matches" verdict over a working
// inline list went unnoticed: the test data disagreed with the daemon.
{ tag: 'rs-private-nets', name: 'private-nets', category: '', kind: 'ruleset', remote: false, last_updated: '', interval_seconds: 0, rule_count: 0 },
// Live geosite/geoip rule-sets — remote, so they show freshness + Update-now.
// yt-geosite has TWO category entries sharing name 'yt-geosite': the row groups
// them, shows the OLDEST last_updated (5h, from google), and sums the counts.
@@ -355,6 +440,19 @@ const RULESET_STATUS: RulesetStatus[] = [
// Disabled in CONFIG, so the row reads "off" whatever this says — it exists to
// prove the row does not start claiming things the moment a status appears.
{ tag: 'bl-telegram-block-telegram', name: 'telegram-block', category: 'telegram', kind: 'blocklist', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
// Inline: nothing to fetch, so remote:false, no timestamp — and NO COUNT, which
// is the whole point. The daemon publishes an entry count only for the sets it
// fetches, so an inline list that holds three entries and one that holds none
// are the same bytes on the wire. The device chip therefore reads "size
// unknown", not "empty": see deviceLists.listLoad.
{ tag: 'bl-family-extra', name: 'family-extra', category: '', kind: 'blocklist', remote: false, last_updated: '', interval_seconds: 0, rule_count: 0 },
// Allowlists report the same way under `al-<name>`. school-allow is INLINE, so
// it is present in the engine with no count to publish ("size unknown");
// work-allow is remote and has NEVER been fetched, so a device that attached it
// is not getting the exception it thinks it is. holidays-allow is deliberately
// ABSENT from this array — that is the third reading, "load unknown".
{ tag: 'al-school-allow', name: 'school-allow', category: '', kind: 'allowlist', remote: false, last_updated: '', interval_seconds: 0, rule_count: 0 },
{ tag: 'al-work-allow', name: 'work-allow', category: '', kind: 'allowlist', remote: true, last_updated: '', interval_seconds: 21_600, rule_count: 0 },
]
/** GET /api/rules/reachability. Mirrors the daemon's analysis over CONFIG.Rules:
@@ -457,7 +555,11 @@ export async function updateRuleset(tag: string): Promise<RulesetStatus> {
const entry = RULESET_STATUS.find((r) => r.tag === tag)
if (!entry) throw new Error(`unknown ruleset ${tag}`)
entry.last_updated = new Date().toISOString()
entry.rule_count = entry.rule_count > 0 ? entry.rule_count + 7 : 15_734
// Only a REMOTE set gains a count: the daemon reads it off RemoteRuleSet and a
// local set has none to read. Fabricating one here would put a number on screen
// that no router can produce — the same fixture lie that hid the inline-list
// "empty" verdict.
if (entry.remote) entry.rule_count = entry.rule_count > 0 ? entry.rule_count + 7 : 15_734
return { ...entry }
}
@@ -716,11 +818,51 @@ function untunnelableNote(mode: string, killSwitch: string): StatusWarning[] {
return []
}
/**
* The daemon's own critical finding for a proxy fetch that travels through
* nothing (apply/warnings.go subFetchDirectMessage), abridged to its first
* sentences. It is published only for `?subleak=applied|divergent` — i.e. only
* when the LAST APPLY saw the state — because that is exactly what separates it
* from the panel's prediction about a draft.
*/
const SUB_FETCH_LEAK: StatusWarning = {
severity: 'critical',
section: 'subscription',
name: 'primary',
message:
'`fetch_via` is "proxy" and no detour is set, which reads as configured and is not: an empty detour resolves to the `direct` outbound. So this subscription\'s feed is fetched over your ordinary WAN: the provider that serves it sees your router\'s real IP address, and everyone on the path there — your ISP included — sees that this router talks to them. The fetch itself gives no sign of it: it SUCCEEDS, the node list updates, and it repeats on every scheduled refresh.',
}
/**
* The SAME finding for a subscription that is switched OFF — the daemon now
* files it (apply/warnings.go stopped skipping disabled subscriptions, because
* the premise that they are never fetched was false) and varies its own WHEN.
*
* It exists here so `?subleak=paused` can be applied as well as drafted: an
* `enabled=0` row wearing the enabled sentence would send the reader looking for
* a scheduled refresh that is not running, which is the same lie inverted.
*/
const SUB_FETCH_LEAK_OFF: StatusWarning = {
...SUB_FETCH_LEAK,
message:
'`fetch_via` is "proxy" and no detour is set, which reads as configured and is not: an empty detour resolves to the `direct` outbound. So this subscription\'s feed is fetched over your ordinary WAN: the provider that serves it sees your router\'s real IP address, and everyone on the path there — your ISP included — sees that this router talks to them. The fetch itself gives no sign of it: it SUCCEEDS and the node list updates. This subscription is switched OFF, so no scheduled refresh touches it — but `enabled=0` does NOT block a fetch you start yourself: the Fetch-now button on this row and `shaterd sub update <name>` both go through, over the plain WAN, exactly as described here.',
}
function mockWarnings(killSwitch: string): StatusWarning[] {
const q = typeof location === 'undefined' ? '' : location.search
const params = new URLSearchParams(q)
const mode = (CONFIG.Globals as { Untunnelable?: string }).Untunnelable ?? 'block'
const notes = untunnelableNote(mode, killSwitch)
const leak = mockSubLeak()
// Published unconditionally for those two knobs — a finding that only appears
// with `?warn` could not be paired with the config state it is about.
if (leak === 'applied' || leak === 'divergent' || leak === 'pausedapplied') {
// A switched-off subscription gets the daemon's OWN disabled sentence, not
// the enabled one — the two differ in WHEN, and the badge above them differs
// the same way.
const finding = leak === 'pausedapplied' ? SUB_FETCH_LEAK_OFF : SUB_FETCH_LEAK
return [{ ...finding }, ...MOCK_WARNINGS.map((w) => ({ ...w })), ...notes]
}
// `?trunc` adds the daemon's "the published list is capped" disclosure, which
// it appends IN PLACE OF the last entry it had room for.
const trunc = params.has('trunc') ? [{ ...MOCK_TRUNCATION }] : []
@@ -760,7 +902,6 @@ export async function getStatus(): Promise<Status> {
warnings: [CONFIG_UNREADABLE_WARNING, ...mockWarnings(killSwitch)],
started_unix: MOCK_STARTED_UNIX,
uptime_seconds: Math.floor(Date.now() / 1000) - MOCK_STARTED_UNIX,
byedpi_installed: true,
}
}
return {
@@ -784,11 +925,6 @@ export async function getStatus(): Promise<Status> {
// the reading ticks forward across polls exactly like the real daemon's does.
started_unix: MOCK_STARTED_UNIX,
uptime_seconds: Math.floor(Date.now() / 1000) - MOCK_STARTED_UNIX,
// ?nobyedpi flips the ciadpi-missing state so the gated Targets editor is
// exercisable in mock mode.
byedpi_installed: !new URLSearchParams(
typeof location === 'undefined' ? '' : location.search,
).has('nobyedpi'),
}
}
@@ -867,14 +1003,47 @@ export async function rollback(): Promise<ApplyResult> {
}
// A small rotating fixture query log so `?mock` renders a live-looking stream.
const MOCK_DOMAINS: Array<{ domain: string; action: string; server: string; device: string }> = [
{ domain: 'graph.facebook.com', action: 'proxy', server: 'cloudflare-doh', device: '192.168.1.77' },
{ domain: 'blocked-ad.example', action: 'block', server: 'router-local', device: 'anna-laptop' },
{ domain: 'www.gstatic.com', action: 'pass', server: 'router-local', device: '192.168.1.42' },
{ domain: 'telemetry.example', action: 'block', server: 'cloudflare-doh', device: 'iphone-anna' },
{ domain: 'github.com', action: 'proxy', server: 'cloudflare-doh', device: '192.168.1.77' },
// router's own resolutions (urltest probe / sub fetch) carry the literal "router"
{ domain: 'ntp.openwrt.org', action: 'pass', server: 'router-local', device: 'router' },
//
// TWO closed vocabularies are covered here on purpose, because they are what the
// page has to draw apart and a fixture set that only ever showed one value would
// let a collapse ship unnoticed:
//
// `outbound_kind` — all four: detour / default / local / '' (not recorded).
// `status` — all three: answered / failed / '' (not recorded), and both
// shapes of failure. The marquee row is `failed` on the
// `proxy` path: the lookup left through the tunnel and died
// there, which is the row that used to render as the
// healthiest-looking line in the whole log. There is also a
// failure with NO cause text, because "failed, cause not
// recorded" is a state the daemon can really produce.
const MOCK_DOMAINS: Array<{
domain: string
action: string
server: string
device: string
outbound_kind: string
outbound: string
status: string
error?: string
/** Overrides the derived rcode. -1 = the server never answered at all. */
rcode?: number
}> = [
{ domain: 'graph.facebook.com', action: 'proxy', server: 'cloudflare-doh', device: '192.168.1.77', outbound_kind: 'detour', outbound: 'nl-reality-1', status: 'answered' },
{ domain: 'blocked-ad.example', action: 'block', server: 'router-local', device: 'anna-laptop', outbound_kind: 'local', outbound: '', status: 'answered' },
{ domain: 'www.gstatic.com', action: 'pass', server: 'router-local', device: '192.168.1.42', outbound_kind: 'default', outbound: '', status: 'answered' },
// The tunnel is what broke: action says HOW it went, status says it never came
// back. Drawn from `action` alone this row was an accent-coloured "proxy".
{ domain: 'api.telegram.org', action: 'proxy', server: 'cloudflare-doh', device: '192.168.1.77', outbound_kind: 'detour', outbound: 'de-hysteria', status: 'failed', error: 'dial udp 1.1.1.1:53: i/o timeout', rcode: -1 },
{ domain: 'telemetry.example', action: 'block', server: 'cloudflare-doh', device: 'iphone-anna', outbound_kind: 'local', outbound: '', status: 'answered' },
// The same failure on the DIRECT path, and the resolver DID answer — with a
// refusal. A different report from the one above, and it has to read as one.
{ domain: 'broken.example', action: 'pass', server: 'router-local', device: '192.168.1.42', outbound_kind: 'default', outbound: '', status: 'failed', error: 'rejected', rcode: 2 },
{ domain: 'github.com', action: 'proxy', server: 'cloudflare-doh', device: '192.168.1.77', outbound_kind: 'detour', outbound: 'de-hysteria', status: 'answered' },
// A failure the producer recorded WITHOUT a cause — the panel must say so.
{ domain: 'nocause.example', action: 'pass', server: 'router-local', device: 'anna-laptop', outbound_kind: 'local', outbound: '', status: 'failed', error: '', rcode: -1 },
// router's own resolutions (urltest probe / sub fetch) carry the literal "router".
// Left UNRECORDED on BOTH axes so the not-recorded state is on screen too.
{ domain: 'ntp.openwrt.org', action: 'pass', server: 'router-local', device: 'router', outbound_kind: '', outbound: '', status: '' },
]
// --- seeded, seq-ordered fixture stores ------------------------------------
@@ -891,12 +1060,20 @@ function makeLogRow(seq: number, unix: number): QueryLogEntry {
unix,
domain: src.domain,
qtype: seq % 3 === 0 ? 'AAAA' : 'A',
rcode: src.action === 'block' ? 3 : 0,
rcode: src.rcode ?? (src.action === 'block' ? 3 : 0),
// A failure is never a filter block — the two are different facts and the
// daemon never sets both (stats.go: Blocked is the filter's own verdict).
blocked: src.action === 'block',
server: src.server,
action: src.action,
device: src.device,
seq,
status: src.status,
// Absent, not empty, when there is nothing to say — the wire field is
// `omitempty`, so a row with no cause must not carry an empty string either.
...(src.error ? { error: src.error } : {}),
outbound_kind: src.outbound_kind,
outbound: src.outbound,
}
}
@@ -931,32 +1108,69 @@ function mockLog(n: number): QueryLogEntry[] {
* - neither ⇒ the newest `limit` rows (the head).
*
* Only `after=` reports a backlog; the other two always report 0 / false.
*
* # The text filter and its scan budget
*
* `q=` is matched over the SAME closed field list the daemon searches
* (logRoute.logSearchFields / connSearchFields, mirroring stats/filter.go), so a
* search in `?mock` finds and misses exactly what a search on hardware does —
* including the deliberate misses: no ports, no `rule_kind`, no `outbound_kind`.
*
* A filtered walk is BUDGETED, as on the daemon, and reports `truncated` +
* `cursor` when the budget rather than the data ended it. The mock budget is
* tiny (MOCK_FILTER_SCAN) where the daemon's is 20000: the fixture store is a few
* hundred rows, and a state the fixtures cannot reach is a state nobody can look
* at before it ships.
*/
const MOCK_FILTER_SCAN = 60
function queryStorePage<T extends { seq: number }>(
store: T[],
q: StatsLogQuery,
grow: () => void,
): { rows: T[]; pending: number; more: boolean } {
fields?: (r: T) => string[],
): { rows: T[]; pending: number; more: boolean; truncated: boolean; cursor: number } {
const limit = Math.min(Math.max(1, q.limit ?? 50), 5000)
const clone = (rows: T[]) => rows.map((r) => ({ ...r }))
const needle = q.q?.trim() ?? ''
const filtered = needle !== '' && fields != null
const hit = (r: T) => !filtered || rowMatches(fields!(r), needle)
/** Walk `candidates` in selection order, spending the budget on rows EXAMINED. */
const walk = (candidates: T[]) => {
const out: T[] = []
let budget = MOCK_FILTER_SCAN
let cursor = 0
let stopped = false
for (const r of candidates) {
if (out.length >= limit) break
if (filtered) {
if (budget <= 0) {
stopped = true
break
}
budget--
}
cursor = r.seq
if (hit(r)) out.push(r)
}
return { out, cursor, stopped }
}
if (q.after != null) {
grow()
const after = q.after
// Ascending by seq, so we take the OLDEST rows above the cursor first.
const above = store.filter((r) => r.seq > after).sort((a, b) => a.seq - b.seq)
const chunk = above.slice(0, limit)
const pending = above.length - chunk.length
const { out, cursor, stopped } = walk(above)
const pending = above.filter((r) => r.seq > (out[out.length - 1]?.seq ?? after) && hit(r)).length
// Hand it back newest-first, exactly as the wire format does.
return { rows: clone(chunk).reverse(), pending, more: pending > 0 }
return { rows: clone(out).reverse(), pending, more: pending > 0, truncated: stopped, cursor }
}
if (q.before != null) {
const before = q.before
return { rows: clone(store.filter((r) => r.seq < before).slice(0, limit)), pending: 0, more: false }
}
return { rows: clone(store.slice(0, limit)), pending: 0, more: false }
const candidates = q.before != null ? store.filter((r) => r.seq < q.before!) : store
const { out, cursor, stopped } = walk(candidates)
return { rows: clone(out), pending: 0, more: false, truncated: stopped, cursor }
}
/** Rows-only view, for the callers that don't tail the stream. */
@@ -1000,7 +1214,11 @@ export async function getStats(): Promise<Stats> {
engine_up: true,
updated_at: new Date().toISOString(),
backend,
totals: { queries: 1842, blocked: 317, allowed: 1525 },
// The three outcome counters are mutually exclusive and sum to `queries`.
// `failed` is non-zero on purpose: the fixture log carries failed rows, and an
// aggregate that said "0 failed" over a log full of failures would be exactly
// the disagreement between row and total that the status field exists to end.
totals: { queries: 1842, blocked: 317, failed: 46, allowed: 1479 },
top_domains: [
{ domain: 'graph.facebook.com', count: 210, blocked: 0 },
{ domain: 'blocked-ad.example', count: 143, blocked: 143 },
@@ -1116,20 +1334,26 @@ export async function getStatsLog(q: StatsLogQuery = {}): Promise<QueryLogEntry[
export async function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> {
await wait(60)
return queryStorePage(LOG_STORE, q, growLog)
return queryStorePage(LOG_STORE, q, growLog, logSearchFields)
}
// Connection events (feedback #2a "which device, where"): each row is a LAN
// client → destination flow the query log can't show. Mix of named + unnamed
// devices, tcp/tls + udp/quic, and a raw-IP/udp flow with no sniffed proto.
//
// `rule_kind` covers all THREE states — matched / default / '' (not recorded) —
// for the same reason MOCK_DOMAINS covers four: the two rule-less states must be
// visibly different, and only a fixture that carries both can show it.
const MOCK_CONNS: Array<Omit<ConnLogEntry, 'unix' | 'seq'>> = [
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'graph.facebook.com', dest_ip: '157.240.1.35', port: 443, network: 'tcp', proto: 'tls', outbound: 'nl-reality-1' },
{ src_ip: '192.168.1.51', src_name: 'phone', dest: 'instagram.com', dest_ip: '157.240.1.174', port: 443, network: 'tcp', proto: 'tls', outbound: 'de-hysteria' },
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'discord-media.example', dest_ip: '162.159.130.234', port: 443, network: 'udp', proto: 'quic', outbound: 'nl-reality-2' },
{ src_ip: '192.168.1.77', src_name: '192.168.1.77', dest: '5.9.100.200', dest_ip: '5.9.100.200', port: 51820, network: 'udp', proto: '', outbound: 'direct' },
{ src_ip: '192.168.1.51', src_name: 'phone', dest: 'gateway.icloud.com', dest_ip: '17.253.55.201', port: 443, network: 'tcp', proto: 'tls', outbound: 'direct' },
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'github.com', dest_ip: '140.82.112.3', port: 443, network: 'tcp', proto: 'tls', outbound: 'nl-reality-1' },
{ src_ip: '192.168.1.42', src_name: 'ipad-kids', dest: 'blocked-ad.example', dest_ip: '203.0.113.77', port: 80, network: 'tcp', proto: 'http', outbound: 'block' },
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'graph.facebook.com', dest_ip: '157.240.1.35', port: 443, network: 'tcp', proto: 'tls', outbound: 'nl-reality-1', rule_kind: 'matched', rule: 'protocol=tls rule_set=rs-social', chain: ['nl-reality-1', 'auto'] },
{ src_ip: '192.168.1.51', src_name: 'phone', dest: 'instagram.com', dest_ip: '157.240.1.174', port: 443, network: 'tcp', proto: 'tls', outbound: 'de-hysteria', rule_kind: 'matched', rule: 'domain_suffix=instagram.com', chain: ['de-hysteria', 'auto'] },
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'discord-media.example', dest_ip: '162.159.130.234', port: 443, network: 'udp', proto: 'quic', outbound: 'nl-reality-2', rule_kind: 'matched', rule: 'network=udp rule_set=rs-voice' },
{ src_ip: '192.168.1.77', src_name: '192.168.1.77', dest: '5.9.100.200', dest_ip: '5.9.100.200', port: 51820, network: 'udp', proto: '', outbound: 'direct', rule_kind: 'default', rule: '' },
{ src_ip: '192.168.1.51', src_name: 'phone', dest: 'gateway.icloud.com', dest_ip: '17.253.55.201', port: 443, network: 'tcp', proto: 'tls', outbound: 'direct', rule_kind: 'default', rule: '' },
// A row from before rule capture: NOT RECORDED, which is a different fact from
// the two `default` rows above and has to look different on screen.
{ src_ip: '192.168.1.50', src_name: 'laptop', dest: 'github.com', dest_ip: '140.82.112.3', port: 443, network: 'tcp', proto: 'tls', outbound: 'nl-reality-1', rule_kind: '', rule: '' },
{ src_ip: '192.168.1.42', src_name: 'ipad-kids', dest: 'blocked-ad.example', dest_ip: '203.0.113.77', port: 80, network: 'tcp', proto: 'http', outbound: 'block', rule_kind: 'matched', rule: 'rule_set=rs-ads', chain: ['block'] },
]
function makeConnRow(seq: number, unix: number): ConnLogEntry {
@@ -1156,7 +1380,7 @@ export async function getStatsConns(q: StatsLogQuery = {}): Promise<ConnLogEntry
export async function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> {
await wait(60)
return queryStorePage(CONN_STORE, q, growConns)
return queryStorePage(CONN_STORE, q, growConns, connSearchFields)
}
// LAN discovery. `configured` / `name` / `blockCount` are DERIVED from CONFIG.Devices
@@ -1476,9 +1700,16 @@ class ApiErrorLike extends Error {
// The last three are absence of measurement, not a broken target, and the copy
// has to keep them apart. Results land one per GET poll, so the running/progress
// state is visible too.
// Every row carries `kind` and `source`, because the panel classifies on
// `source` now and a fixture that omitted it would exercise only the fallback
// path — the one that reads the error prose. Note which rows carry
// `source: ''`: every "no measurement exists" state, including the blocked
// chain, exactly as engine/grouptest.go files them.
const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = {
auto: {
selected: 'nl-reality-2',
kind: 'group',
source: 'observatory',
delay_ms: 42,
exit_ip: '185.12.34.56',
exit_country: 'NL',
@@ -1487,6 +1718,8 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
},
stealth: {
selected: 'nl-reality-1',
kind: 'group',
source: 'observatory',
delay_ms: 137,
exit_ip: '',
exit_country: '',
@@ -1502,6 +1735,18 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
// has a separate message for it.
'ewan-wg-subs': {
selected: '',
kind: 'chain',
// No end-to-end measurement of the chain exists — the walk stopped at hop 3
// — so the daemon files source:''. It is the one `source:''` that stays LOUD
// (testResult.blockedHop): a probe did run, at the hop, and failed.
source: '',
// THE SAME FACT AS THE SENTENCE BELOW, as a number. The panel reads this and
// nothing else: it used to keep the row red by matching a fragment of that
// sentence, which made a reworded daemon message a silent downgrade from red
// to grey. Note it sits beside `source:''` on purpose — nothing measured this
// chain's own exit, and stamping an instrument on it would be the lie the
// `source` field exists to prevent.
blocked_by: 3,
delay_ms: 0,
exit_ip: '',
exit_country: '',
@@ -1513,6 +1758,10 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
// this path and did not come back.
'via-tunnel': {
selected: '',
kind: 'group',
// A measurement was taken and it failed — the only shape in this fixture
// that is a health verdict rather than an absence of one.
source: 'observatory',
delay_ms: 0,
exit_ip: '',
exit_country: '',
@@ -1522,6 +1771,8 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
// Not a health verdict — nothing routes here, so no measurement of it exists.
fallback: {
selected: '',
kind: 'group',
source: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
@@ -1531,6 +1782,8 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
},
relay: {
selected: '',
kind: 'chain',
source: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
@@ -1541,6 +1794,8 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
// Routed, materialised, simply not reached yet. Untested is not dead.
'sub-fresh': {
selected: '',
kind: 'chain',
source: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
@@ -1560,13 +1815,119 @@ function shapeFor(group: string, i: number): GroupTestResult {
return { ...base, group, tested_unix: Math.floor(Date.now() / 1000) }
}
/** engine.groupTestCarryMax — the cap on rows an earlier run may leave behind. */
const CARRY_MAX = 64
/**
* engine.carryForward, in the fixture: the rows this run will NOT produce an
* answer for, newest first, capped.
*
* Identity is (name, KIND) and not name alone, exactly as the daemon has it —
* testing the node `nl-reality-1` must not drop a group of the same name, which
* is the whole reason `kind` is on the result. An empty kind on either side
* matches everything with that name (a name the box resolved to nothing could
* have been any of them).
*
* The fixture used to skip this entirely: every run set `results: []` and a
* comment claimed the daemon did the same. It no longer does, and a mock that
* lags the daemon here would hide the exact thing the panel now has to draw —
* a carried reading sitting beside a fresh one.
*/
function carryForward(
prev: GroupTestResult[],
covered: { group: string; kind: string }[],
): GroupTestResult[] {
const supersedes = (r: GroupTestResult) =>
covered.some((c) => c.group === r.group && (!c.kind || !r.kind || c.kind === r.kind))
const kept = prev.filter((r) => r.group && !supersedes(r))
if (kept.length <= CARRY_MAX) return kept
return [...kept].sort((a, b) => b.tested_unix - a.tested_unix).slice(0, CARRY_MAX)
}
let groupTest: GroupTestStatus = { running: false, done: 0, total: 0, scope: [], results: [] }
let groupTestQueue: string[] = []
/** A URL-seeded run stays running instead of draining (see the seed block below). */
let groupTestPinned = false
/** The in-flight run is a NODE run, so the row it lands is a node's. One board
* carries both kinds and only `kind` tells them apart — see GroupTestResult. */
let groupTestNodeRun = false
export async function postGroupsTest(name = ''): Promise<GroupTestStart> {
/**
* The single-NODE branch, `?mock&nodetest=<mode>`.
*
* All THREE refusals are reachable, because they are three different facts and
* a panel that draws one "error" for all of them is untestable in the way that
* matters:
*
* nodetest=400 → no name was sent (there is no "test every node")
* nodetest=404 → the daemon's configuration has no node by that name
* nodetest=503 → the configuration could not be read: an UNKNOWN
*
* 404 gets a knob rather than being left to "type a name that does not exist",
* because there is no way to type one: every button on the page carries a name
* the fixture's own config holds. On a real router it is the row that was
* renamed or removed under the open page — rare, and precisely the reason it
* needs to be exercisable at all.
*
* And all three READINGS:
*
* (default) → measured on demand, and it answered
* nodetest=dead → measured on demand, and it did not. A health verdict.
* nodetest=unmeasured → nothing measured it (source:''), which sits next to
* ok:false and is NOT a death. This is the pair the UI
* has to draw differently or the instrument is useless.
*/
const NODE_TEST_SHAPES: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = {
ok: {
selected: '',
kind: 'node',
source: 'on-demand',
delay_ms: 61,
exit_ip: '185.12.34.56',
exit_country: 'NL',
ok: true,
error: '',
},
dead: {
selected: '',
kind: 'node',
source: 'on-demand',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error: 'checked now over this node’s own configured path: the probe did not get through',
},
unmeasured: {
selected: '',
kind: 'node',
source: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error:
'no such node in the running engine — it is not in the applied configuration (not applied yet, or dropped as unusable)',
},
}
/** The node-test outcome this session is pinned to. Unrecognised ⇒ the measured
* success, never a silent failure mode. */
function nodeTestMode(): string {
const want = (mockParam('nodetest') ?? '').trim()
const refusal = want === '400' || want === '404' || want === '503'
return want in NODE_TEST_SHAPES || refusal ? want : 'ok'
}
function nodeShapeFor(name: string): GroupTestResult {
const mode = nodeTestMode()
const base = NODE_TEST_SHAPES[mode] ?? NODE_TEST_SHAPES.ok
return { ...base, group: name, tested_unix: Math.floor(Date.now() / 1000) }
}
export async function postGroupsTest(name = '', kind: TestKind = ''): Promise<GroupTestStart> {
await wait(60)
if (kind === 'node') return postNodeTest(name)
if (groupTest.running) return { started: false, reason: 'already running' }
// Empty name = every group AND every chain, exactly like the daemon.
const all = [
@@ -1577,16 +1938,67 @@ export async function postGroupsTest(name = ''): Promise<GroupTestStart> {
if (targets.length === 0) return { started: false, reason: `no group or chain named “${name}”` }
groupTestQueue = [...targets]
groupTestPinned = false
groupTestNodeRun = false
groupTest = {
running: true,
done: 0,
total: targets.length,
// The names this run covers — exactly what the panel tests card membership
// against. One name for a per-group run, every name for a run-all.
// against, both for the in-progress badge and for telling a CARRIED row from
// one this run produced. One name for a per-group run, every name for a
// run-all.
scope: [...targets],
// A re-test of ONE group replaces just that group's row and keeps the rest,
// exactly as a per-group daemon run would.
results: groupTest.results.filter((r) => !targets.includes(r.group)),
// CARRIED FORWARD, because engine.startTestRun carries: the board is no
// longer wiped per run, so a reading this run is not about survives with its
// own tested_unix. A group/chain run marks its targets with an EMPTY kind —
// the recoverable direction, matching any row of that name — because the
// fixture does not resolve which of the two each name is.
results: carryForward(
groupTest.results,
targets.map((group) => ({ group, kind: '' })),
),
}
return { started: true }
}
/**
* POST {"name":"<node>","kind":"node"} — the same singleton, the same run, the
* same GET below. The three refusals are thrown as ApiErrorLike so the page's
* error path sees the status codes the daemon really sends.
*/
async function postNodeTest(name: string): Promise<GroupTestStart> {
const mode = nodeTestMode()
if (mode === '400' || !name) {
throw new ApiErrorLike(
400,
'a node test needs a name: POST {"name":"<node>","kind":"node"}. There is deliberately no "test every node" — that sweep was removed',
)
}
if (mode === '503') {
throw new ApiErrorLike(
503,
'could not read the configuration to check that this node exists, so the test was not started — this is an unknown, not a verdict about the node: uci show shater: exit status 1',
)
}
if (mode === '404' || !(CONFIG.Nodes ?? []).some((n) => n.Name === name)) {
throw new ApiErrorLike(404, 'no node with that name in the configuration')
}
if (groupTest.running) return { started: false, reason: 'already running' }
// The case the carry-forward was built for: testing ONE node used to blank
// every group and chain card on the Targets screen, and nothing on screen said
// why. Now the other rows survive — identified by (name, kind), so a group
// that happens to share this node's name is NOT dropped — and they arrive on
// the board as carried readings the panel has to date rather than pass off as
// this run's.
groupTestQueue = [name]
groupTestPinned = false
groupTestNodeRun = true
groupTest = {
running: true,
done: 0,
total: 1,
scope: [name],
results: carryForward(groupTest.results, [{ group: name, kind: 'node' }]),
}
return { started: true }
}
@@ -1599,7 +2011,10 @@ export async function getGroupsTest(): Promise<GroupTestStatus> {
groupTest = {
...groupTest,
done: groupTest.done + 1,
results: [...groupTest.results, shapeFor(next, groupTest.done)],
results: [
...groupTest.results,
groupTestNodeRun ? nodeShapeFor(next) : shapeFor(next, groupTest.done),
],
}
}
if (groupTestQueue.length === 0) groupTest = { ...groupTest, running: false }
@@ -1636,5 +2051,41 @@ if (typeof location !== 'undefined') {
}
}
/**
* Land on a FINISHED board that holds both kinds of row, `?mock&board=carried`.
*
* The carried row is reachable by hand — test one target, then test another —
* but only after two runs and a wait, which makes the one rendering that must
* never be got wrong the hardest one to look at. This seeds it directly: a
* single group in scope with a reading taken just now, and every other target
* carrying a reading from ELEVEN MINUTES AGO.
*
* Eleven minutes, not eleven seconds, because the point is a row that is still
* true and no longer current. A fixture where every row is seconds old would let
* "shows when it was taken" pass while showing the same time twice.
*/
if (typeof location !== 'undefined') {
if (new URLSearchParams(location.search).get('board') === 'carried') {
const now = Math.floor(Date.now() / 1000)
const all = [
...(CONFIG.Groups ?? []).map((g) => g.Name),
...(CONFIG.Chains ?? []).map((c) => c.Name),
]
const fresh = all.slice(0, 1)
groupTestPinned = false
groupTestQueue = []
groupTest = {
running: false,
done: fresh.length,
total: fresh.length,
scope: [...fresh],
results: all.map((group, i) => ({
...shapeFor(group, i),
tested_unix: fresh.includes(group) ? now : now - 11 * 60,
})),
}
}
}
/** Exposed for potential UI hints; not part of the wire contract. */
export const isArmed = () => armed
+55
View File
@@ -78,6 +78,51 @@
flex: 0 1 14rem;
}
/* A caption under a control, in the panel's quiet voice — used for the rule that
* an empty token box keeps the stored token, and for what a type switch does with
* the other type's settings. Not --amber: neither is a warning, both are the
* plain behaviour of the form, and colouring them would train the eye to skip the
* amber that does mean caution (.alr-note). */
.alr-hint {
margin: -4px 2px 0;
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.5;
color: var(--dim);
max-width: 56ch;
}
/* The same plate as the add form, inline in the list and accented so it reads as
* the row currently being edited (mirrors .rt-add.rt-edit-form on Routing). */
.alr-edit-item {
list-style: none;
}
.alr-add.alr-edit-form {
margin-top: 0;
padding: 12px 14px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--raised);
box-shadow: 0 1px 0 var(--edge) inset;
}
.alr-cancel {
padding: 6px 10px;
border: 0;
background: none;
color: var(--dim);
font-family: var(--font-sans);
font-size: 12px;
text-decoration: underline;
cursor: pointer;
}
.alr-cancel:hover:not(:disabled) {
color: var(--ink);
}
.alr-cancel:disabled {
opacity: 0.55;
cursor: default;
}
/* segmented type picker */
.alr-seg {
display: inline-flex;
@@ -247,6 +292,16 @@
cursor: help;
}
.alr-row-actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
}
.alr-row-actions button {
padding: 6px 12px;
font-size: 10.5px;
}
.alr-del {
flex: none;
padding: 6px 12px;
+176 -76
View File
@@ -2,6 +2,16 @@ import './Alerts.css'
import { useCallback, useMemo, useState } from 'react'
import { Button, Toggle, useConfirm } from '../components'
import type { Alert, Model } from '../api'
import {
buildAlert,
draftFromAlert,
hasStoredToken,
validateAlertDraft,
TOKEN_KEEP_HINT,
TOKEN_NEW_HINT,
TYPE_SWITCH_NOTE,
} from '../alertEdit'
import type { AlertDraft, AlertType } from '../alertEdit'
// The Alerts section — out-of-band notifications (Telegram bot / webhook) for
// kill-switch trips, apply failures, new devices and subscription expiry. It
@@ -44,8 +54,6 @@ const VIA_NO_FALLBACK_NOTE =
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
const HTTP_RE = /^https?:\/\//i
/** A remote URL often carries a token in its query/path — show host only. */
function maskUrl(url: string): { host: string; masked: boolean } {
try {
@@ -158,6 +166,11 @@ export function AlertsSection({
const alertNames = useMemo(() => new Set(alerts.map((a) => a.Name)), [alerts])
const alertsOn = alerts.filter((a) => a.Enabled).length
// Which row is open for editing — one at a time, like the rule bus. Keyed by
// INDEX and not by name: names are the thing an edit can change, and a config
// is free to carry two channels with the same one.
const [editing, setEditing] = useState<number | null>(null)
// ---- mutations — all writes go through onSave -----------------------------
const addAlert = useCallback(
(draft: Alert): Promise<boolean> => {
@@ -169,6 +182,27 @@ export function AlertsSection({
[model, alerts, onSave],
)
/**
* Save an edited channel in place.
*
* The next Alert is built by alertEdit.buildAlert, which is where the rule about
* unshown fields lives — an empty token box keeps the stored token, and the
* other type's settings survive a type switch. Nothing about that decision is
* repeated here, so there is one place it can be got wrong.
*/
const editAlert = useCallback(
async (idx: number, next: Alert): Promise<boolean> => {
if (!model) return false
const ok = await onSave(
{ ...model, Alerts: alerts.map((a, i) => (i === idx ? next : a)) },
`Updated ${next.Name}`,
)
if (ok) setEditing(null)
return ok
},
[model, alerts, onSave],
)
const toggleAlert = useCallback(
(idx: number, on: boolean) => {
if (!model) return
@@ -233,13 +267,14 @@ export function AlertsSection({
through a group, node or egress instead, with a direct fallback if that detour fails.
</p>
<AddAlertForm
<AlertForm
stored={null}
busy={busy}
disabled={!config}
taken={alertNames}
catalog={alertCatalog}
valid={alertValid}
onAdd={addAlert}
onSubmit={addAlert}
/>
{loading ? (
@@ -253,19 +288,36 @@ export function AlertsSection({
/>
) : (
<ul className="alr-rows">
{alerts.map((a, i) => (
<AlertRow
key={`${a.Name}-${i}`}
alert={a}
busy={busy}
catalog={alertCatalog}
valid={alertValid}
onToggle={(on) => toggleAlert(i, on)}
onVia={(v) => setAlertVia(i, v)}
onFallback={(on) => setAlertFallback(i, on)}
onDelete={() => removeAlert(i)}
/>
))}
{alerts.map((a, i) =>
editing === i ? (
<li className="alr-edit-item" key={`edit-${i}`}>
<AlertForm
stored={a}
busy={busy}
disabled={!config}
taken={alertNames}
catalog={alertCatalog}
valid={alertValid}
onSubmit={(next) => editAlert(i, next)}
onCancel={() => setEditing(null)}
/>
</li>
) : (
<AlertRow
key={`${a.Name}-${i}`}
alert={a}
busy={busy}
editingOther={editing !== null}
catalog={alertCatalog}
valid={alertValid}
onEdit={() => setEditing(i)}
onToggle={(on) => toggleAlert(i, on)}
onVia={(v) => setAlertVia(i, v)}
onFallback={(on) => setAlertFallback(i, on)}
onDelete={() => removeAlert(i)}
/>
),
)}
</ul>
)}
</div>
@@ -274,84 +326,108 @@ export function AlertsSection({
// ---- alert add form + row ----------------------------------------------------
function AddAlertForm({
/**
* ONE form for adding a channel and for editing one, chosen by `stored`.
*
* They were never going to be two forms for long. Everything the add form asks —
* type, token, chat ID, URL, events, delivery — is a thing an existing channel
* must be able to change, and the reason it could not was simply that no editor
* existed: the row offered Enabled/Via/Fallback and nothing else, so a typo in a
* chat ID meant deleting the channel and going back to BotFather for a token you
* already owned. Sharing the component is what keeps the two paths from drifting
* into different validation rules, which is exactly how the first one grew a
* "Telegram needs a bot token" check that an edit could not satisfy.
*
* The one asymmetry is the secret, and it is stated in the interface rather than
* implied: the token box starts empty when editing and the hint under it says
* that empty means keep. The rule itself lives in alertEdit.buildAlert.
*/
function AlertForm({
stored,
busy,
disabled,
taken,
catalog,
valid,
onAdd,
onSubmit,
onCancel,
}: {
/** The channel being edited, or null to add a new one. */
stored: Alert | null
busy: boolean
disabled: boolean
taken: Set<string>
catalog: DetourCatalog
valid: Set<string>
onAdd: (a: Alert) => Promise<boolean>
onSubmit: (a: Alert) => Promise<boolean>
onCancel?: () => void
}) {
const [name, setName] = useState('')
const [type, setType] = useState<'telegram' | 'webhook'>('telegram')
const editing = stored !== null
const initial = useMemo<AlertDraft>(
() =>
stored
? draftFromAlert(stored, canonDetour(stored.Via, catalog))
: {
name: '',
type: 'telegram',
token: '',
chatId: '',
url: '',
events: ['killswitch'],
via: 'direct',
fallback: false,
},
// Built once per mounted form: re-deriving it as the catalog polls would
// throw away half-typed input. The list keys each form by row, so opening a
// different one mounts a fresh component with a fresh prefill.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
const [name, setName] = useState(initial.name)
const [type, setType] = useState<AlertType>(initial.type)
const [token, setToken] = useState('')
const [chatId, setChatId] = useState('')
const [url, setUrl] = useState('')
const [events, setEvents] = useState<string[]>(['killswitch'])
const [via, setVia] = useState('direct')
const [fallback, setFallback] = useState(false)
const [chatId, setChatId] = useState(initial.chatId)
const [url, setUrl] = useState(initial.url)
const [events, setEvents] = useState<string[]>(initial.events)
const [via, setVia] = useState(initial.via)
const [fallback, setFallback] = useState(initial.fallback)
const [err, setErr] = useState<string | null>(null)
const reset = () => {
setName('')
setType('telegram')
setName(initial.name)
setType(initial.type)
setToken('')
setChatId('')
setUrl('')
setEvents(['killswitch'])
setVia('direct')
setFallback(false)
setChatId(initial.chatId)
setUrl(initial.url)
setEvents(initial.events)
setVia(initial.via)
setFallback(initial.fallback)
}
const toggleEvent = (id: string) =>
setEvents((prev) => (prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id]))
const submit = async () => {
const nm = name.trim()
if (!nm) {
setErr('Give the alert a name.')
return
}
if (taken.has(nm)) {
setErr(`An alert named “${nm}” already exists.`)
return
}
if (type === 'telegram') {
if (!token.trim() || !chatId.trim()) {
setErr('Telegram needs a bot token and a chat ID.')
return
}
} else if (!HTTP_RE.test(url.trim())) {
setErr('Enter an http(s):// webhook URL.')
return
}
if (events.length === 0) {
setErr('Pick at least one event to notify on.')
const draft: AlertDraft = { name, type, token, chatId, url, events, via, fallback }
const problem = validateAlertDraft(draft, taken, stored)
if (problem) {
setErr(problem)
return
}
setErr(null)
const routed = via !== 'direct'
const routing = { Via: routed ? via : undefined, Fallback: routed && fallback ? true : undefined }
const draft: Alert =
type === 'telegram'
? { Name: nm, Enabled: true, Type: 'telegram', Token: token.trim(), ChatID: chatId.trim(), Events: events, ...routing }
: { Name: nm, Enabled: true, Type: 'webhook', URL: url.trim(), Events: events, ...routing }
const ok = await onAdd(draft)
if (ok) reset()
const ok = await onSubmit(buildAlert(stored, draft))
if (ok && !editing) reset()
}
const routed = via !== 'direct'
const keepsToken = editing && hasStoredToken(stored)
const typeSwitched = editing && stored!.Type !== type
return (
<form
className="alr-add"
className={editing ? 'alr-add alr-edit-form' : 'alr-add'}
aria-label={editing ? `Edit alert ${stored!.Name}` : 'Add an alert channel'}
onSubmit={(e) => {
e.preventDefault()
void submit()
@@ -394,6 +470,8 @@ function AddAlertForm({
</div>
</div>
{typeSwitched && <p className="alr-hint">{TYPE_SWITCH_NOTE}</p>}
{type === 'telegram' ? (
<>
<input
@@ -401,8 +479,8 @@ function AddAlertForm({
type="password"
spellCheck={false}
autoComplete="off"
placeholder="Bot token (kept secret)"
aria-label="Telegram bot token"
placeholder={keepsToken ? 'Bot token — leave empty to keep' : 'Bot token (kept secret)'}
aria-label={keepsToken ? 'Telegram bot token — leave empty to keep the stored one' : 'Telegram bot token'}
value={token}
onChange={(e) => {
setToken(e.target.value)
@@ -410,6 +488,9 @@ function AddAlertForm({
}}
disabled={busy || disabled}
/>
{/* The rule, in the interface rather than in someone's head. A masked
field with no caption means "type it again"; this one does not. */}
<p className="alr-hint">{keepsToken ? TOKEN_KEEP_HINT : TOKEN_NEW_HINT}</p>
<input
className="alr-input"
type="text"
@@ -493,8 +574,13 @@ function AddAlertForm({
{err}
</p>
)}
{editing && (
<button type="button" className="alr-cancel" onClick={onCancel} disabled={busy}>
Cancel
</button>
)}
<Button type="submit" variant="primary" disabled={busy || disabled}>
{busy ? 'Saving…' : 'Add alert'}
{busy ? 'Saving…' : editing ? 'Save alert' : 'Add alert'}
</Button>
</div>
</form>
@@ -504,8 +590,10 @@ function AddAlertForm({
function AlertRow({
alert,
busy,
editingOther,
catalog,
valid,
onEdit,
onToggle,
onVia,
onFallback,
@@ -513,8 +601,11 @@ function AlertRow({
}: {
alert: Alert
busy: boolean
/** Another row is open for editing — freeze this one's controls. */
editingOther: boolean
catalog: DetourCatalog
valid: Set<string>
onEdit: () => void
onToggle: (on: boolean) => void
onVia: (v: string) => void
onFallback: (on: boolean) => void
@@ -534,6 +625,10 @@ function AlertRow({
const route = useMemo(() => describeDetour(canon, catalog, valid), [canon, catalog, valid])
const routed = canon !== 'direct'
const fallback = alert.Fallback ?? false
// While a row is open for editing, every other row's writes are frozen: an
// Alerts save PUTs the whole list, so a toggle landing under an open form would
// save one edit over the other. Edit itself stays live — it just moves the form.
const frozen = busy || editingOther
return (
<li className="alr-row">
@@ -541,7 +636,7 @@ function AlertRow({
pressed={alert.Enabled}
onChange={onToggle}
label={`${alert.Enabled ? 'Disable' : 'Enable'} alert ${alert.Name}`}
disabled={busy}
disabled={frozen}
/>
<div className="alr-row-main">
<div className="alr-row-l1">
@@ -591,19 +686,24 @@ function AlertRow({
pressed={fallback}
onChange={onFallback}
label={`${fallback ? 'Disable' : 'Enable'} direct fallback for ${alert.Name}`}
disabled={busy || !routed}
disabled={frozen || !routed}
/>
<span className="alr-fallback-label mono">Fallback to direct</span>
</label>
</div>
<Button
className="alr-del"
onClick={onDelete}
disabled={busy}
aria-label={`Delete alert ${alert.Name}`}
>
Delete
</Button>
<div className="alr-row-actions">
<Button onClick={onEdit} disabled={busy} aria-label={`Edit alert ${alert.Name}`}>
Edit
</Button>
<Button
className="alr-del"
onClick={onDelete}
disabled={frozen}
aria-label={`Delete alert ${alert.Name}`}
>
Delete
</Button>
</div>
</li>
)
}
+86 -18
View File
@@ -10,8 +10,9 @@ import {
getStatus,
ApiError,
} from '../api'
import type { Globals, Status } from '../api'
import { engineReadout, killSwitchReadout } from '../planeState'
import type { Model, Status } from '../api'
import { applyRisk, engineReadout, killSwitchReadout, serviceIntent } from '../planeState'
import type { ApplyRisk } from '../planeState'
import { onPendingConfirmExpire, usePendingConfirm } from '../pendingConfirm'
// Short, readable config hash — drops the "sha256:" prefix like the footer does.
@@ -48,7 +49,11 @@ interface ActionResult {
export default function Apply() {
const [status, setStatus] = useState<Status | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [globals, setGlobals] = useState<Globals | null>(null)
// The WHOLE model, not just Globals: the pre-apply warning below is decided
// from the rules, the inbounds and the resolvers as well as the globals, and
// splitting the read would have let those two drift a poll apart.
const [config, setConfig] = useState<Model | null>(null)
const globals = config?.Globals ?? null
const [configError, setConfigError] = useState<string | null>(null)
const [busy, setBusy] = useState<Busy>(null)
@@ -93,8 +98,7 @@ export default function Apply() {
const loadConfig = useCallback(async () => {
try {
setConfigError(null)
const cfg = await getConfig()
setGlobals(cfg.Globals)
setConfig(await getConfig())
} catch (e) {
setConfigError(msg(e))
}
@@ -266,8 +270,11 @@ export default function Apply() {
flash('Rollback failed')
} finally {
setBusy(null)
// A rollback restores the PREVIOUS config, so the desired state this page
// reads — and the pre-apply warning derived from it — is now stale.
void loadConfig()
}
}, [status, flash, refreshStatus])
}, [status, flash, loadConfig, refreshStatus])
// ---- derived display state (mirrors Overview's LED semantics) ----
//
@@ -279,13 +286,23 @@ export default function Apply() {
// no separate `killArmed` here any more: it compared the raw string (so "Closed"
// read as fail-OPEN) and, being a boolean, could not express "the configuration
// could not be read". Both facts come off this one readout now.
// THE ONE FACT THAT RE-READS THE OTHER THREE PIPS. With the service switched
// off, "engine stopped" / "no table" / "kill-switch not in effect" are all true
// and none of them is a fault — this row showed three crit lamps on a router
// that had installed correctly and been touched by nobody. Under `on`, and
// under an unreadable config (`unknown`), every one of them keeps its crit.
const serviceOff = serviceIntent(status) === 'off'
const kill = killSwitchReadout(status, globals?.KillSwitch)
const killWord =
kill.state === 'open'
? 'open'
: kill.state === 'armed'
? 'fail-closed'
: kill.state === 'inert'
: kill.state === 'standby'
? // Configured, nothing to guard yet. Reads as pending, not as failed —
// which is the whole difference between this and `inert` below.
'closed · standing by'
: kill.state === 'inert'
? 'closed · not in effect'
: // The unknown branch splits: "closed · not reported" asserts the policy
// and doubts only the install, which is wrong when the policy itself is
@@ -303,8 +320,20 @@ export default function Apply() {
// that is a leak (crit); under an open one it is the documented choice (amber).
// It used to go amber whenever `running` was true — i.e. always — and unlit
// otherwise, so the one state worth shouting about had no colour of its own.
// The service being off is checked BEFORE the kill-switch, because the
// fail-closed reading is the one that turned "there is no plane, and none was
// asked for" into a leak alarm.
const dataVariant: LedVariant =
status?.table ? 'on' : !status ? 'off' : kill.state !== 'open' ? 'crit' : 'amber'
status?.table
? 'on'
: !status || serviceOff
? 'off'
: kill.state !== 'open'
? 'crit'
: 'amber'
// "no table" is the installed truth; on a switched-off service it needs the
// reason attached or it reads as the thing that failed.
const dataWord = status?.table ? 'nft installed' : serviceOff ? 'none — service off' : 'no table'
// `enabled` is sourced from the configuration, so it means nothing when that
// could not be read (Status.config_readable): unlit, not amber, and the pip
// beside it says so rather than printing "disabled".
@@ -320,6 +349,11 @@ export default function Apply() {
// is nothing to roll back to, so the idle control is hidden entirely (no dead-end).
const canRollback = status?.can_rollback ?? false
// Predicted from the config this apply would install — never from `status`,
// which describes the config already running. Null on every config that does
// not have this outcome, which is nearly all of them.
const risk = applyRisk(config)
return (
<section className="page apply-page" aria-label="Apply and rollback">
{/* What's live now — engine + data-plane state in LED form. */}
@@ -335,11 +369,7 @@ export default function Apply() {
variant={configVariant}
value={configWord}
/>
<StatusPip
label="Data plane"
variant={dataVariant}
value={status?.table ? 'nft installed' : 'no table'}
/>
<StatusPip label="Data plane" variant={dataVariant} value={dataWord} />
<StatusPip label="Kill-switch" variant={kill.variant} value={killWord} />
</div>
@@ -369,11 +399,7 @@ export default function Apply() {
led={{ variant: configVariant }}
rows={[
{ k: 'engine', v: engine.word, hot: engineVariant === 'crit' },
{
k: 'data plane',
v: status?.table ? 'nft installed' : 'no table',
hot: dataVariant === 'crit',
},
{ k: 'data plane', v: dataWord, hot: dataVariant === 'crit' },
{ k: 'kill-switch', v: killWord, hot: kill.variant === 'crit' || kill.settingHot },
]}
/>
@@ -408,6 +434,13 @@ export default function Apply() {
/>
</div>
{/* Predicted outcome of pressing the button below. Sits directly above the
control room rather than at the top of the page: this is a statement
about the action, and it belongs where the action is. Suppressed while a
confirm window is armed — that config is already live, so the thing to
read there is the countdown, not a forecast of an apply that happened. */}
{risk && !armed && <ApplyRiskBand risk={risk} />}
{/* Control room — the signature: idle actions, or the armed countdown. */}
{armed ? (
<div className="cc" role="group" aria-label="Commit-confirm window">
@@ -541,6 +574,41 @@ export default function Apply() {
)
}
/**
* The one thing said BEFORE the most dangerous apply this router can do.
*
* Shared by Apply and Overview because both carry an "Apply config" button, and a
* warning that appears beside one of them is a warning half the operators never
* see. The wording is entirely {@link applyRisk}'s, for the same reason
* protectionState owns the wording of the observed states: two screens describing
* one router in two ways is how they come to disagree.
*
* It uses the crit vocabulary — the outcome is a network with no way out, which
* is exactly crit-severity — but it must not read as a fault that has already
* happened, so it leads with the tense ("BEFORE YOU APPLY") and ends in the
* accent colour: crit says how bad this is, the accent says which control fixes
* it. Nothing here disables the Apply button. It is the operator's router, the
* config is legal, and a warning that blocks the action just gets worked around.
*/
export function ApplyRiskBand({ risk }: { risk: ApplyRisk }) {
return (
<section className="risk-band" role="alert" aria-label="Before you apply">
<div className="risk-band-hd">
<Led variant="crit" />
<span className="risk-eyebrow">Before you apply</span>
</div>
<p className="risk-headline">{risk.headline}</p>
<p className="risk-detail">{risk.detail}</p>
<p className={risk.noAutoRollback ? 'risk-undo hot' : 'risk-undo'}>{risk.undo}</p>
<ul className="risk-steps">
{risk.steps.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</section>
)
}
function labelFor(kind: ActionKind): string {
switch (kind) {
case 'apply':
+21
View File
@@ -227,6 +227,12 @@
.dns-input--name {
flex: 0 1 14rem;
}
/* Refresh cadence — a duration, so it needs room for `24h`, not a URL. */
.dns-input--interval {
flex: 0 0 5.5rem;
width: 5.5rem;
text-align: center;
}
.dns-textarea {
width: 100%;
resize: vertical;
@@ -732,12 +738,27 @@
.dns-rule-arrow {
color: var(--groove);
}
/* The per-row editor panel. Same column stack as .dns-add so the shared field
sets (ListFields / ResolverFields / DNSRuleFields) lay out identically whether
they are mounted in the add form or inside a row. */
.dns-rule-edit {
flex-basis: 100%;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--groove);
display: flex;
flex-direction: column;
gap: 10px;
}
/* The row's Edit / Delete pair. Pushed to the end so it lines up with the same
pair on a DNS-rule row above it. */
.dns-row-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* A caveat that changes behaviour, not just a note — carries the amber LED. */
+866 -488
View File
File diff suppressed because it is too large Load Diff
+146 -98
View File
@@ -98,7 +98,10 @@
color-mix(in srgb, var(--raised) 82%, var(--panel))
);
box-shadow: 0 1px 0 var(--edge) inset;
overflow: hidden;
/* NOT `overflow: hidden`. The card used to clip its children to the rounded
* corners, which also clipped the policy picker's popover to the bottom of the
* card — the list you were choosing from was cut off mid-row. The two children
* that carry a background round their own corners instead. */
}
.dev-card[data-configured='yes'] {
border-color: color-mix(in srgb, var(--accent) 22%, var(--groove));
@@ -240,73 +243,152 @@
gap: calc(var(--u, 8px) * 2);
padding: calc(var(--u, 8px) * 2) 14px 14px;
border-top: 1px solid var(--groove);
border-radius: 0 0 8px 8px;
background: color-mix(in srgb, var(--sink) 24%, transparent);
}
.dev-controls[data-paused='yes'] {
opacity: 0.72;
}
/* ---- domain (block / allow) editor ---- */
.dev-domains {
border: 1px solid var(--groove);
border-radius: 8px;
padding: 11px 12px;
background: color-mix(in srgb, var(--raised) 50%, transparent);
}
.dev-domains-hd {
/* ---- the order rail ----
*
* Five stops, drawn as a machined strip: this is the order the ENGINE decides a
* name in, and it is the one place on this page where numbering carries real
* information rather than decorating a list. It exists because the two controls
* below it CANNOT show that order by position — the typed lane and the attached
* lane of one control are two steps apart, with the other control's lane in
* between — so the numbers on the rail and the numbers stamped on each lane are
* what tie them together.
*
* A stop is lit only when this device actually has something at that step, which
* makes the rail the card's summary as well as its legend. */
.dev-order {
list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
}
.dev-order-step {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px 3px 5px;
border: 1px solid var(--groove);
border-right-width: 0;
background: color-mix(in srgb, var(--sink) 55%, transparent);
}
.dev-order-step:first-child {
border-radius: 6px 0 0 6px;
}
.dev-order-step:last-child {
border-right-width: 1px;
border-radius: 0 6px 6px 0;
}
/* A lit stop: this device has something at that step. Accent is the panel's one
* emphasis colour; it says "in use", not "good". */
.dev-order-step[data-on='yes'] {
border-color: color-mix(in srgb, var(--accent) 45%, var(--groove));
background: color-mix(in srgb, var(--accent) 11%, var(--raised));
}
.dev-order-step[data-on='yes'] + .dev-order-step {
border-left-color: color-mix(in srgb, var(--accent) 45%, var(--groove));
}
.dev-order-n {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 15px;
height: 15px;
border: 1px solid var(--groove);
border-radius: 3px;
background: var(--sink);
box-shadow: 0 1px 1px var(--shadow) inset;
color: var(--faint);
font-size: 9.5px;
font-weight: 700;
line-height: 1;
}
.dev-order-step[data-on='yes'] .dev-order-n {
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
color: var(--accent);
}
.dev-order-lab {
font-size: 10px;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--faint);
white-space: nowrap;
}
.dev-order-step[data-on='yes'] .dev-order-lab {
color: var(--ink);
}
.dev-order-cap {
margin: 8px 0 0;
max-width: 72ch;
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.5;
color: var(--dim);
}
.dev-controls[data-paused='yes'] .dev-order-step[data-on='yes'] {
border-color: var(--groove);
background: color-mix(in srgb, var(--sink) 55%, transparent);
}
.dev-controls[data-paused='yes'] .dev-order-step[data-on='yes'] .dev-order-n {
border-color: var(--groove);
color: var(--faint);
}
.dev-controls[data-paused='yes'] .dev-order-step[data-on='yes'] .dev-order-lab {
color: var(--faint);
}
/* ---- the two policy controls ---- */
.dev-pol {
display: flex;
flex-direction: column;
gap: 8px;
}
.dev-pol-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.dev-domains-title {
font-size: 11px;
.dev-pol-lab {
padding-top: 10px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--dim);
}
.dev-domains-count {
font-size: 11px;
color: var(--faint);
.dev-pol-row[data-kind='allow'] .dev-pol-lab {
color: color-mix(in srgb, var(--led-on) 70%, var(--dim));
}
.dev-domains-hint {
margin: 6px 0 10px;
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.45;
color: var(--dim);
max-width: 56ch;
.dev-pol-row[data-kind='block'] .dev-pol-lab {
color: color-mix(in srgb, var(--crit) 60%, var(--dim));
}
.dev-add {
/* Said on the card, not only in the picker: attaching a broad allow list is how
* a device quietly loses the network's filtering, and the person who did it
* should not have to reopen a popover to be told. */
.dev-pol-warn {
display: flex;
align-items: flex-start;
gap: 8px;
}
.dev-input {
flex: 1;
min-width: 0;
padding: 8px 11px;
border: 1px solid var(--groove);
border-radius: 7px;
background: var(--sink);
color: var(--ink);
font-family: var(--font-mono);
margin: 0;
max-width: 78ch;
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px var(--shadow) inset;
transition: border-color 0.15s, box-shadow 0.15s;
line-height: 1.5;
color: var(--dim);
}
.dev-input::placeholder {
color: var(--faint);
}
.dev-input:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.dev-input:disabled {
opacity: 0.55;
.dev-pol-warn > .led {
margin-top: 4px;
flex: 0 0 auto;
}
.dev-field-err {
@@ -317,57 +399,6 @@
color: var(--crit);
}
/* chips */
.dev-chips {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.dev-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 4px 3px 9px;
border: 1px solid var(--groove);
border-radius: 6px;
background: var(--sink);
}
.dev-domains[data-kind='allow'] .dev-chip {
border-color: color-mix(in srgb, var(--led-on) 40%, var(--groove));
}
.dev-chip-dom {
font-size: 11.5px;
letter-spacing: 0.01em;
color: var(--ink);
}
.dev-chip-x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--faint);
font-size: 11px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.dev-chip-x:hover:not(:disabled) {
color: var(--crit);
background: color-mix(in srgb, var(--crit) 14%, transparent);
}
.dev-chip-x:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- card footer ---- */
.dev-card-foot {
display: flex;
@@ -432,6 +463,23 @@
.dev-card-hd {
flex-wrap: wrap;
}
/* The rail wraps rather than scrolls: five stops do not fit a phone, and a
horizontally scrolling legend is a legend nobody reads to the end. Each stop
keeps its own rounded shoulders once the strip is broken up. */
.dev-order-step {
border-right-width: 1px;
border-radius: 6px;
}
.dev-order {
gap: 4px;
}
.dev-pol-row {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.dev-pol-lab {
padding-top: 0;
}
.dev-id {
flex-basis: calc(100% - 100px);
}
+250 -158
View File
@@ -1,9 +1,17 @@
import './Devices.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Module, Toggle, useConfirm } from '../components'
import type { LedVariant } from '../components'
import { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api'
import type { Device, DiscoveredDevice, Model } from '../api'
import { Button, Led, ListPicker, Module, Toggle, useConfirm } from '../components'
import type { LedVariant, ListOption } from '../components'
import {
apply as apiApply,
getConfig,
getDevices,
getRulesetStatus,
putConfig,
ApiError,
} from '../api'
import type { Allowlist, Blocklist, Device, DiscoveredDevice, Model, RulesetStatus } from '../api'
import { listLoad } from '../deviceLists'
// The Devices page is a thin editor over the desired-state Model — exactly like
// Nodes.tsx / DNS.tsx. Liveness (who's online right now) is polled separately
@@ -39,17 +47,67 @@ function matchDevice(devs: Device[], mac: string, ip: string): number {
return -1
}
/** Normalise a typed domain for a per-device block/allow list; null if invalid. */
function cleanDomain(raw: string): string | null {
const d = raw
.trim()
.toLowerCase()
.replace(/^\*\./, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
if (!d) return null
if (!/^[a-z0-9.-]+$/.test(d)) return null
return d
// The typed-entry parser lives in ../deviceLists (with its tests): it is the one
// piece of this page that decides whether a parent's Block entry can ever match,
// and `node --test` cannot load a .tsx file.
/** Where a list's content comes from, for the picker row's detail column. */
function listDetail(l: Blocklist | Allowlist): string {
if (l.Source === 'url') {
try {
return `url · ${new URL(l.URL ?? '').host}`
} catch {
return 'url'
}
}
if (l.Source === 'file') return `file · ${l.Path || '—'}`
if (l.Source === 'geosite') {
const cats = (l.Categories ?? []).filter(Boolean)
if (cats.length === 0) return 'geosite'
return cats.length === 1 ? `geosite · ${cats[0]}` : `geosite · ${cats[0]} +${cats.length - 1}`
}
const n = l.Entries?.length ?? 0
return `${n} domain${n === 1 ? '' : 's'}`
}
/** The four list slots on a `config device`, in the order the engine reads them. */
type ListField = 'Allow' | 'Block' | 'Allowlists' | 'Blocklists'
const LIST_VERB: Record<ListField, { add: string; del: string }> = {
Allow: { add: 'Allowed', del: 'Removed allow' },
Block: { add: 'Blocked', del: 'Unblocked' },
Allowlists: { add: 'Attached allowlist', del: 'Detached allowlist' },
Blocklists: { add: 'Attached blocklist', del: 'Detached blocklist' },
}
/** Read one slot. Closed switch — a slot added to the model has to be decided
* about here rather than silently reading `undefined`. */
function readList(d: Device | undefined, field: ListField): string[] {
if (!d) return []
switch (field) {
case 'Allow':
return asArray(d.Allow)
case 'Block':
return asArray(d.Block)
case 'Allowlists':
return asArray(d.Allowlists)
case 'Blocklists':
return asArray(d.Blocklists)
}
}
/** Write one slot, leaving the other three exactly as they were. */
function withList(d: Device, field: ListField, next: string[]): Device {
switch (field) {
case 'Allow':
return { ...d, Allow: next }
case 'Block':
return { ...d, Block: next }
case 'Allowlists':
return { ...d, Allowlists: next }
case 'Blocklists':
return { ...d, Blocklists: next }
}
}
const STATE_LED: Record<string, LedVariant> = {
@@ -118,6 +176,40 @@ export default function Devices() {
return () => window.clearInterval(id)
}, [loadDevices])
// ---- did the attached lists actually LOAD? --------------------------------
//
// The same endpoint and the same tags the DNS page reads (`bl-<name>` /
// `al-<name>`), for the same reason it reads them: attaching a list is not
// evidence that anything is filtered. A url list whose fetch never succeeded, a
// file that is not there, a geosite category that resolved to nothing — each is
// a parental control that does NOT work, and it has to be visible as one on the
// card where the parent attached it, not only on the DNS page. Grouped by NAME
// because a geo list with N categories reports N records. Slow poll: lists
// refresh on a ~24h cadence, so 20s only has to catch a manual Update-now.
const [listStatus, setListStatus] = useState<Map<string, RulesetStatus[]>>(new Map())
const loadListStatus = useCallback(async () => {
try {
const all = await getRulesetStatus()
const m = new Map<string, RulesetStatus[]>()
for (const s of all) {
if (s.kind !== 'blocklist' && s.kind !== 'allowlist') continue
const key = `${s.kind}:${s.name}`
const arr = m.get(key)
if (arr) arr.push(s)
else m.set(key, [s])
}
setListStatus(m)
} catch {
// Engine stopped or an older daemon — keep the last reading. Chips fall back
// to "load unknown", which claims nothing either way.
}
}, [])
useEffect(() => {
void loadListStatus()
const id = window.setInterval(() => void loadListStatus(), 20000)
return () => window.clearInterval(id)
}, [loadListStatus])
// ---- toast + persistent apply banner --------------------------------------
const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<number | undefined>(undefined)
@@ -218,6 +310,35 @@ export default function Devices() {
return out
}, [devices, config])
// ---- what a device can attach, and what the engine says about it ----------
const blockOptions = useMemo<ListOption[]>(
() =>
asArray(config?.Blocklists).map((b) => ({
name: b.Name,
detail: listDetail(b),
networkEnabled: b.Enabled,
response: b.Response,
load: listLoad(listStatus.get(`blocklist:${b.Name}`), true),
})),
[config, listStatus],
)
const allowOptions = useMemo<ListOption[]>(
() =>
asArray(config?.Allowlists).map((a) => ({
name: a.Name,
detail: listDetail(a),
networkEnabled: a.Enabled,
load: listLoad(listStatus.get(`allowlist:${a.Name}`), true),
})),
[config, listStatus],
)
// Step 5 of the ladder. "The network filter is in force" is the master switch
// AND at least one blocklist switched on — the switch alone blocks nothing, and
// drawing the last rung lit with no list behind it would be the same lie the
// rest of this page exists to avoid.
const networkFilterOn =
(config?.Globals?.DNSFilter ?? false) && asArray(config?.Blocklists).some((b) => b.Enabled)
const onlineCount = rows.filter((r) => r.state === 'online').length
const idleCount = rows.filter((r) => r.state === 'idle').length
const offlineCount = rows.filter((r) => r.state === 'offline').length
@@ -251,6 +372,27 @@ export default function Devices() {
const nameOf = (row: DeviceRow) => row.cfg?.Name || row.hostname || row.ip || 'device'
/**
* Write one of the four list slots back whole.
*
* The picker emits the complete next list rather than one add/remove, so the
* toast is derived by diffing — which also keeps it truthful when a change
* touches more than one entry. Slots are a CLOSED switch, not a computed key:
* `{ ...d, [field]: next }` would compile against a string index signature and
* happily write a slot that does not exist.
*/
const setDeviceList = useCallback(
(row: DeviceRow, field: ListField, next: string[]) => {
const cur = readList(row.cfg, field)
const added = next.filter((x) => !cur.includes(x))
const removed = cur.filter((x) => !next.includes(x))
const what = added[0] ?? removed[0] ?? ''
const verb = added.length > 0 ? LIST_VERB[field].add : LIST_VERB[field].del
editDevice(row, (d) => withList(d, field, next), `${verb} ${what} — ${nameOf(row)}`)
},
[editDevice],
)
const removeControl = useCallback(
async (row: DeviceRow) => {
if (!config) return
@@ -310,9 +452,10 @@ export default function Devices() {
]}
/>
<p className="dev-summary-note">
Clients appear here as they join the LAN. Manage a device to block or always-allow domains
just for it, or pause its policy. To route a device through a specific exit, add a Routing
rule with this device as the source.
Clients appear here as they join the LAN. Manage a device to give it its own domain
policy — type entries by hand, attach a named list from the DNS page, or pause the lot.
To route a device through a specific exit, add a Routing rule with this device as the
source.
</p>
</div>
@@ -375,18 +518,10 @@ export default function Devices() {
onToggleEnabled={(on) =>
editDevice(row, (d) => ({ ...d, Enabled: on }), `${nameOf(row)} policy ${on ? 'active' : 'paused'}`)
}
onAddBlock={(dom) =>
editDevice(row, (d) => ({ ...d, Block: [...asArray(d.Block), dom] }), `Blocked ${dom} for ${nameOf(row)}`)
}
onRemoveBlock={(dom) =>
editDevice(row, (d) => ({ ...d, Block: asArray(d.Block).filter((x) => x !== dom) }), `Unblocked ${dom}`)
}
onAddAllow={(dom) =>
editDevice(row, (d) => ({ ...d, Allow: [...asArray(d.Allow), dom] }), `Allowed ${dom} for ${nameOf(row)}`)
}
onRemoveAllow={(dom) =>
editDevice(row, (d) => ({ ...d, Allow: asArray(d.Allow).filter((x) => x !== dom) }), `Removed allow ${dom}`)
}
blockOptions={blockOptions}
allowOptions={allowOptions}
networkFilterOn={networkFilterOn}
onSetList={(field, next) => setDeviceList(row, field, next)}
/>
))}
</ul>
@@ -408,28 +543,30 @@ interface DeviceCardProps {
row: DeviceRow
name: string
busy: boolean
/** Every `config blocklist` / `config allowlist`, with the engine's load reading. */
blockOptions: ListOption[]
allowOptions: ListOption[]
/** Step 5: is the network-wide DNS filter actually blocking anything? */
networkFilterOn: boolean
onAddControl: () => void
onRemoveControl: () => void
onRename: (name: string) => void
onToggleEnabled: (on: boolean) => void
onAddBlock: (d: string) => void
onRemoveBlock: (d: string) => void
onAddAllow: (d: string) => void
onRemoveAllow: (d: string) => void
onSetList: (field: ListField, next: string[]) => void
}
function DeviceCard({
row,
name,
busy,
blockOptions,
allowOptions,
networkFilterOn,
onAddControl,
onRemoveControl,
onRename,
onToggleEnabled,
onAddBlock,
onRemoveBlock,
onAddAllow,
onRemoveAllow,
onSetList,
}: DeviceCardProps) {
const cfg = row.cfg
const configured = !!cfg
@@ -437,6 +574,11 @@ function DeviceCard({
const paused = configured && cfg?.Enabled === false
const netLabel = networkLabel(row)
const allowDomains = asArray(cfg?.Allow)
const blockDomains = asArray(cfg?.Block)
const allowLists = asArray(cfg?.Allowlists)
const blockLists = asArray(cfg?.Blocklists)
// ---- inline rename ---------------------------------------------------------
// Names default to the DHCP hostname; this lets the operator pin a friendly one.
// Renaming an unmanaged device upserts it into the managed set (editDevice does
@@ -561,27 +703,77 @@ function DeviceCard({
<p className="dev-unmanaged">Using network defaults</p>
) : (
<div className="dev-controls" data-paused={paused ? 'yes' : 'no'}>
{/* per-device block list */}
<DomainEditor
kind="block"
title="Blocked for this device"
hint="These domains are refused for this device only, on top of the network blocklists."
values={asArray(cfg?.Block)}
busy={busy}
onAdd={onAddBlock}
onRemove={onRemoveBlock}
/>
{/* The whole point of the card: the order the engine decides a name in.
It is drawn, not described, because the two controls under it cannot
show it by position — the typed lane and the attached lane of ONE
control are two steps apart, with the other control's lane between
them. Each rung lights only when this device actually has something
at that step, so the rail doubles as the card's summary. */}
<ol className="dev-order" aria-label={`How a domain is decided for ${name} — first match wins`}>
{[
{ n: 1, label: 'allow · typed', on: allowDomains.length > 0 },
{ n: 2, label: 'block · typed', on: blockDomains.length > 0 },
{ n: 3, label: 'allow · lists', on: allowLists.length > 0 },
{ n: 4, label: 'block · lists', on: blockLists.length > 0 },
{ n: 5, label: 'network filter', on: networkFilterOn },
].map((s) => (
<li key={s.n} className="dev-order-step" data-on={s.on ? 'yes' : 'no'}>
<span className="dev-order-n mono">{s.n}</span>
<span className="dev-order-lab mono">{s.label}</span>
</li>
))}
</ol>
<p className="dev-order-cap">
{paused
? 'Policy is paused, so steps 1–4 are not applied at all — this device falls straight through to the network filter.'
: 'First match wins. Typed entries beat attached lists, and an allow beats a block at the same step.'}
</p>
{/* per-device allow list */}
<DomainEditor
kind="allow"
title="Always allowed for this device"
hint="These domains always resolve for this device, even if a blocklist would block them."
values={asArray(cfg?.Allow)}
busy={busy}
onAdd={onAddAllow}
onRemove={onRemoveAllow}
/>
<div className="dev-pol">
<div className="dev-pol-row" data-kind="allow">
<span className="dev-pol-lab mono">Allow</span>
<ListPicker
kind="allow"
lists={allowLists}
domains={allowDomains}
options={allowOptions}
typedStep={1}
listStep={3}
disabled={busy}
ariaLabel={`Always allowed for ${name}`}
onListsChange={(v) => onSetList('Allowlists', v)}
onDomainsChange={(v) => onSetList('Allow', v)}
/>
</div>
<div className="dev-pol-row" data-kind="block">
<span className="dev-pol-lab mono">Block</span>
<ListPicker
kind="block"
lists={blockLists}
domains={blockDomains}
options={blockOptions}
typedStep={2}
listStep={4}
disabled={busy}
ariaLabel={`Blocked for ${name}`}
onListsChange={(v) => onSetList('Blocklists', v)}
onDomainsChange={(v) => onSetList('Block', v)}
/>
</div>
</div>
{/* Not shown while the policy is paused: with steps 1-4 out of force the
sentence would describe filtering this device is not losing. */}
{allowLists.length > 0 && !paused && (
<p className="dev-pol-warn" role="note">
<Led variant="amber" />
<span>
An attached allow list is terminal: everything{' '}
{allowLists.length === 1 ? allowLists[0] : `these ${allowLists.length} lists`} covers
also stops being filtered by the network blocklists for {name}.
</span>
</p>
)}
<div className="dev-card-foot">
<Button className="dev-unmanage" onClick={onRemoveControl} disabled={busy}>
@@ -593,103 +785,3 @@ function DeviceCard({
</li>
)
}
// ---- domain (block / allow) editor -----------------------------------------
function DomainEditor({
kind,
title,
hint,
values,
busy,
onAdd,
onRemove,
}: {
kind: 'block' | 'allow'
title: string
hint: string
values: string[]
busy: boolean
onAdd: (d: string) => void
onRemove: (d: string) => void
}) {
const [text, setText] = useState('')
const [err, setErr] = useState<string | null>(null)
const submit = () => {
const dom = cleanDomain(text)
if (!dom) {
setErr('Enter a domain like example.com.')
return
}
if (values.some((v) => v.toLowerCase() === dom)) {
setErr(`${dom} is already ${kind === 'block' ? 'blocked' : 'allowed'}.`)
return
}
setErr(null)
setText('')
onAdd(dom)
}
return (
<div className="dev-domains" data-kind={kind}>
<div className="dev-domains-hd">
<span className="dev-domains-title mono">{title}</span>
<span className="dev-domains-count mono">{values.length}</span>
</div>
<p className="dev-domains-hint">{hint}</p>
<form
className="dev-add"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
>
<input
className="dev-input"
type="text"
inputMode="url"
spellCheck={false}
autoComplete="off"
placeholder={kind === 'block' ? 'example.com' : 'school.example.edu'}
aria-label={`Domain to ${kind === 'block' ? 'block' : 'allow'}`}
value={text}
onChange={(e) => {
setText(e.target.value)
if (err) setErr(null)
}}
disabled={busy}
/>
<Button type="submit" disabled={busy}>
{kind === 'block' ? 'Block' : 'Allow'}
</Button>
</form>
{err && (
<p className="dev-field-err" role="alert">
{err}
</p>
)}
{values.length > 0 && (
<ul className="dev-chips">
{values.map((d) => (
<li key={d} className="dev-chip">
<span className="dev-chip-dom mono">{d}</span>
<button
type="button"
className="dev-chip-x"
onClick={() => onRemove(d)}
disabled={busy}
aria-label={`${kind === 'block' ? 'Unblock' : 'Remove allow for'} ${d}`}
title="Remove"
>
✕
</button>
</li>
))}
</ul>
)}
</div>
)
}
+399 -2
View File
@@ -797,9 +797,186 @@
.ins-conn--dns {
grid-template-columns: 62px minmax(60px, 116px) 12px minmax(0, 1fr) minmax(72px, 160px) auto;
}
.ins-conn--dns .tag {
/* The verdict cell: PATH then OUTCOME, in that order, so the eye lands last on
* what came of the lookup. Wraps rather than squeezes — on a narrow screen the
* outcome drops under the path instead of pushing the domain to nothing. */
.ins-dns-v {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px 6px;
justify-self: end;
}
/* ---- the four situations one DNS row can be in ------------------------------
* answered · blocked · failed · not recorded. No two of them may look alike, and
* the unrecorded one may not look like any of the three answers.
*
* The OUTCOME is what marks the row (a rail down its start edge) because the
* outcome is what the reader is sorting rows into. The PATH keeps its own colour
* beside it — a failure in the tunnel and a failure on the direct path are
* different reports — but it is muted below whenever the outcome is not a plain
* answer, so no failed row can ever again be the healthiest-looking line here. */
.ins-dns-st {
flex: none;
padding: 1px 5px;
border: 1px solid var(--groove);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
color: var(--dim);
cursor: help;
}
/* an answer came back — stated, quietly. It is the common case and must not shout */
.ins-dns-st.s-answered {
color: var(--led-on);
border-color: color-mix(in srgb, var(--led-on) 40%, var(--groove));
}
/* NO usable answer. Amber, filled, and it takes the row's rail with it — crit is
* already spoken for by the filter's deliberate block, and a fault the box did
* not intend is a different thing from a kill it did. */
.ins-dns-st.s-failed {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 60%, var(--groove));
background: color-mix(in srgb, var(--amber) 16%, transparent);
font-weight: 700;
}
/* NOT RECORDED — dashed and faint, the same language the routing chips use for
* the state that is not an answer at all. */
.ins-dns-st.s-unrecorded {
color: var(--faint);
border-style: dashed;
background: none;
}
/* The row rail. `answered` gets none — a clean row is the baseline everything
* else is read against. */
.ins-conn--dns.st-blocked,
.ins-conn--dns.st-failed,
.ins-conn--dns.st-unrecorded {
border-inline-start: 2px solid transparent;
padding-inline-start: 10px;
}
.ins-conn--dns.st-blocked {
border-inline-start-color: color-mix(in srgb, var(--crit) 70%, transparent);
}
.ins-conn--dns.st-failed {
border-inline-start-color: var(--amber);
background: color-mix(in srgb, var(--amber) 7%, transparent);
}
.ins-conn--dns.st-unrecorded {
border-inline-start-style: dashed;
border-inline-start-color: color-mix(in srgb, var(--faint) 60%, transparent);
}
/* ---- the same axis on a CONNECTION row --------------------------------------
* killed · carried · no exit recorded. A connection sent to the engine's `block`
* outbound was not carried at all — a rule with target Block, or the kill-switch
* default — and it has to be as findable here as the DNS block is above it. Same
* rail, same crit, because it is the same statement about the same box.
*
* `carried` takes no rail: it is the baseline the other two are read against, and
* it is deliberately NOT green — the log records the routing decision, not
* whether the flow then worked, so there is no health to claim. */
.ins-conn.st-killed,
.ins-conn.st-unrecorded {
border-inline-start: 2px solid transparent;
padding-inline-start: 10px;
}
.ins-conn.st-killed {
border-inline-start-color: color-mix(in srgb, var(--crit) 70%, transparent);
background: color-mix(in srgb, var(--crit) 6%, transparent);
}
.ins-conn.st-unrecorded {
border-inline-start-style: dashed;
border-inline-start-color: color-mix(in srgb, var(--faint) 60%, transparent);
}
/* The fate mark. Same chrome as the DNS outcome chip (.ins-dns-st) so the two
* logs answer "what came of it" in one visual language. */
.ins-fate {
flex: none;
padding: 1px 5px;
border: 1px solid var(--groove);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
color: var(--dim);
cursor: help;
}
/* nothing left the router for this connection */
.ins-fate.f-killed {
color: var(--crit);
border-color: color-mix(in srgb, var(--crit) 60%, var(--groove));
background: color-mix(in srgb, var(--crit) 15%, transparent);
font-weight: 700;
}
/* it left through a named outbound — stated, quietly. The common case, and it
* must not shout and must not read as "it worked". */
.ins-fate.f-carried {
color: var(--dim);
}
/* nothing was recorded about the exit. Dashed and faint — the same language the
* routing chips use for the state that is not an answer at all. */
.ins-fate.f-unrecorded {
color: var(--faint);
border-style: dashed;
background: none;
}
/* The exit tag itself, in the column that a narrow screen drops. It carries the
* kill's colour so the two readings agree wherever both are visible; it is never
* the only place the kill is said. */
.ins-conn-out.e-killed {
color: var(--crit);
}
.ins-conn-out.e-unrecorded {
color: var(--faint);
}
.ins-conn--dns .tag {
flex: none;
}
/* The path is still the path — but it stops carrying a health signal the moment
* the outcome says the lookup did not produce one. This is the whole defect:
* `action=proxy` + `status=failed` used to render as a bright, healthy tunnel. */
.ins-conn--dns.st-failed .tag,
.ins-conn--dns.st-unrecorded .tag {
color: var(--faint);
border-color: color-mix(in srgb, var(--faint) 45%, transparent);
background: none;
}
/* A path nobody recorded, in either outcome. Dashed, so it cannot pass for one
* of the three real paths. */
.ins-conn--dns .tag.unknown {
color: var(--faint);
border-style: dashed;
border-color: color-mix(in srgb, var(--faint) 55%, transparent);
background: none;
}
/* the failure's own cause, verbatim from the resolver, and what the rcode adds */
.ins-why-cause {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--amber);
cursor: help;
}
.ins-why-rcode {
flex: none;
color: var(--faint);
cursor: help;
}
@media (max-width: 560px) {
/* resolver (rendered via .ins-conn-out) is hidden by the .ins-conn-out rule
* above; collapse to time · device · → · domain · tag so the source device, the
@@ -809,10 +986,30 @@
/* trimmed device/domain mins so time + device + domain + tag fit the narrow
* scroll box (no inner h-scroll); domain keeps a small hard min so the queried
* name never fully collapses. */
grid-template-columns: 48px minmax(34px, 64px) 8px minmax(30px, 1fr) auto;
grid-template-columns: 48px minmax(34px, 64px) 8px minmax(30px, 1fr);
column-gap: 6px;
padding-inline: 8px;
}
/* The verdict is TWO chips now, and a ~86px column for them left the queried
* domain pinned at its 30px minimum — the one field nobody can do without,
* truncated to three letters. So on a narrow screen the verdict takes its own
* full-width line under the query instead of competing with it: the domain gets
* the whole remainder of line one, and outcome + path stay side by side and
* right-aligned, in the same reading order as on a wide screen. */
.ins-dns-v {
grid-column: 1 / -1;
justify-content: flex-end;
margin-top: 2px;
}
/* the outcome rail eats 2px of the 8px inset, so the text still lines up with
* the rows that have no rail */
.ins-conn--dns.st-blocked,
.ins-conn--dns.st-failed,
.ins-conn--dns.st-unrecorded,
.ins-conn.st-killed,
.ins-conn.st-unrecorded {
padding-inline-start: 6px;
}
}
/* ---- logging-off state ---- *
@@ -905,3 +1102,203 @@
animation: none;
}
}
/* ---- why it went there / where it went out ----------------------------------
* A second line under each log row carrying the ROUTING RECORD: for a connection
* the rule that matched, for a DNS query the channel the lookup left through.
* It spans the whole row grid so the columns above stay unchanged (and keep
* working when the narrow breakpoint drops one of them). */
.ins-why {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 7px;
margin-top: 3px;
min-width: 0;
font-size: 10.5px;
line-height: 1.35;
}
.ins-why-kind {
flex: none;
padding: 1px 5px;
border: 1px solid var(--groove);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
background: color-mix(in srgb, var(--raised) 60%, transparent);
cursor: help;
}
.ins-why-txt,
.ins-why-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--faint);
}
.ins-why-path::before {
content: 'path ';
color: var(--groove);
}
/* The three/four vocabularies. Each state gets its OWN look — that is the whole
* point of the fields (stats.go: "" is reserved for NOT RECORDED, so it can never
* be read as "no rule matched" / "nothing egressed"). Do not merge any two. */
/* a rule matched — a plain recorded answer */
.ins-why-kind.k-matched {
color: var(--ink);
border-color: color-mix(in srgb, var(--accent) 40%, var(--groove));
}
/* nothing matched, so the default route carried it — an ANSWER, stated quietly */
.ins-why-kind.k-default {
color: var(--dim);
border-style: solid;
}
/* the resolver names no detour: this lookup left over the plain WAN, past the
* tunnel. Marked, not alarmed — a box with no anti-leak intent reads this all day */
.ins-why-kind.o-default {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
background: color-mix(in srgb, var(--amber) 12%, transparent);
}
/* answered on the box — nothing egressed at all */
.ins-why-kind.o-local {
color: var(--dim);
}
/* the lookup left through a named detour */
.ins-why-kind.o-detour {
color: var(--ink);
border-color: color-mix(in srgb, var(--accent) 40%, var(--groove));
}
/* NOT RECORDED — the only state that is not a fact about routing. Dashed and
* faint so it can never be mistaken for one of the answers above. */
.ins-why-kind.k-unrecorded,
.ins-why-kind.o-unrecorded {
color: var(--faint);
border-style: dashed;
background: none;
}
/* ---- the scan stopped before the log did ------------------------------------
* A filtered walk is bounded by the daemon; a page that ended on that bound is
* short for a reason that has nothing to do with how much data exists. Naming it
* is the whole job — with the resume control right next to the sentence. */
.ins-scan {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 9px;
margin: 0 0 10px;
padding: 9px 11px;
border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--groove));
border-radius: 8px;
background: color-mix(in srgb, var(--amber) 10%, transparent);
font-size: 11.5px;
line-height: 1.45;
color: var(--dim);
}
.ins-scan-lab {
flex: none;
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 700;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--amber);
}
.ins-scan-txt {
flex: 1 1 220px;
min-width: 0;
}
.ins-scan-go {
flex: none;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--groove));
border-radius: 6px;
background: var(--raised);
color: var(--ink);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.04em;
cursor: pointer;
}
.ins-scan-go:hover:not(:disabled) {
border-color: var(--amber);
}
.ins-scan-go:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ins-scan-go:disabled {
opacity: 0.55;
cursor: default;
}
/* ---- log search -------------------------------------------------------------
* The daemon filters, not the panel. The hint under the box names the fields that
* ARE searched, so an empty result reads as "not in these fields" rather than
* "did not happen". */
.ins-search {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px 6px;
min-width: 0;
max-width: 100%;
}
.ins-search-in {
flex: 1 1 150px;
min-width: 0;
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--groove);
border-radius: 6px;
background: var(--sink);
color: var(--ink);
font-family: var(--font-mono);
font-size: 11px;
box-shadow: 0 1px 2px var(--shadow) inset;
}
.ins-search-in::placeholder {
color: var(--faint);
}
.ins-search-in:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ins-search-clr {
flex: none;
padding: 3px 6px;
border: 1px solid var(--groove);
border-radius: 6px;
background: var(--raised);
color: var(--dim);
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.ins-search-clr:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ins-search-fields {
flex: 1 0 100%;
font-size: 9.5px;
letter-spacing: 0.03em;
color: var(--faint);
text-align: right;
}
@media (max-width: 560px) {
.ins-search {
flex: 1 1 100%;
}
.ins-search-fields {
text-align: left;
}
}
+438 -78
View File
@@ -1,11 +1,25 @@
import './Insights.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, SegMeter } from '../components'
import type { LedVariant, QueryTag } from '../components'
import type { LedVariant } from '../components'
import { usePrefersReducedMotion } from '../components/usePrefersReducedMotion'
import { fmtBytes, fmtClock } from '../format'
import { getStats, getStatsConnsPage, getStatsLogPage } from '../api'
import type { StatsLogPage, StatsLogQuery } from '../api'
import { serviceIntent } from '../planeState'
import {
CONN_SEARCH_HINT,
LOG_SEARCH_HINT,
chainPath,
connFate,
connRule,
dnsOutbound,
dnsRowMark,
historyExhausted,
logCountLabel,
logEndNote,
resumeCursor,
} from '../logRoute'
import type {
ConnLogEntry,
DeviceDomains,
@@ -16,6 +30,7 @@ import type {
RuleStat,
ServerStat,
Stats,
Status,
TimelineBucket,
} from '../api'
@@ -32,12 +47,6 @@ function fmtNum(n: number): string {
return (n || 0).toLocaleString('en-US')
}
function actionTag(action?: string): QueryTag {
if (action === 'block') return 'block'
if (action === 'proxy') return 'proxy'
return 'pass'
}
// ---- small presentational pieces -------------------------------------------
type Tone = 'accent' | 'flow' | 'crit' | 'good'
@@ -148,12 +157,31 @@ function HostRow({ h, max }: { h: HostStat; max: number }) {
* One connection event: which LAN device (name, else IP) reached which
* destination (:port), with the transport/proto chip and the exit it took. This
* is the "from which device and where" the DNS query log structurally can't show.
*
* TWO AXES, AS ON THE DNS ROW ABOVE. `rule_kind` answers WHY the connection went
* where it went; it does not answer whether it went anywhere. A row whose exit is
* the engine's `block` outbound was NOT carried — that is a rule with target
* Block, or the kill-switch default — and it used to be drawn as plain mono text,
* indistinguishable from `nl-reality-1`, while a DNS row about the same host on
* this very page got a crit rail and a BLOCK mark. This log is where a "the site
* does not open" report is read first, so the row that IS the answer now carries
* its own mark (logRoute.connFate) and its own rail.
*/
function ConnRow({ c, fresh }: { c: ConnLogEntry; fresh: boolean }) {
const dev = c.src_name || c.src_ip
const dest = c.port ? `${c.dest}:${c.port}` : c.dest
// WHY it went there. The three states of `rule_kind` are decided once, in
// logRoute.connRule, and they must stay visibly different here: "no rule
// matched" is an answer, "not recorded" is the absence of one.
const why = connRule(c)
// WHETHER it went at all. Decided once, in logRoute.connFate — the page derives
// nothing about `block` on its own.
const fate = connFate(c)
const path = chainPath(c.chain)
const cls = ['ins-conn', `st-${fate.fate}`]
if (fresh) cls.push('new')
return (
<li className={fresh ? 'ins-conn new' : 'ins-conn'}>
<li className={cls.join(' ')}>
<span className="ins-conn-t mono">{fmtClock(c.unix)}</span>
<span className="ins-conn-dev mono" title={c.src_ip}>
{dev}
@@ -165,27 +193,64 @@ function ConnRow({ c, fresh }: { c: ConnLogEntry; fresh: boolean }) {
{dest}
</span>
<NetBadge network={c.network} proto={c.proto} />
<span className="ins-conn-out mono" title={`exit · ${c.outbound}`}>
{/* The exit column is the first thing dropped on a narrow screen, so it may
never be the only place the kill is stated — the mark and the rail below
survive at every width. */}
<span className={`ins-conn-out mono e-${fate.fate}`} title={fate.title}>
{c.outbound || '—'}
</span>
<span className="ins-why">
<span className={`ins-fate f-${fate.fate}`} title={fate.title}>
{fate.label}
</span>
<span className={`ins-why-kind k-${why.kind}`} title={why.title}>
{why.label}
</span>
{why.text ? (
<span className="ins-why-txt mono" title={why.title}>
{why.text}
</span>
) : null}
{path ? (
<span className="ins-why-path mono" title={`outbound path · ${path}`}>
{path}
</span>
) : null}
</span>
</li>
)
}
/**
* One DNS decision: time · device → domain · resolver · action tag
* (block/proxy/pass). Mirrors ConnRow's `device → dest` reading (same `.ins-conn`
* chrome + accent arrow) so the DNS log and the Connections log read identically:
* the "who asked" is the LAN device, the resolver that answered is the trailing
* secondary column. The backend drops the router's own resolutions (urltest probes /
* sub fetches) at ingestion, so every row here is a real client; an empty device
* (older data) falls back to a dash.
* One DNS decision: time · device → domain · resolver · path tag · OUTCOME.
* Mirrors ConnRow's `device → dest` reading (same `.ins-conn` chrome + accent
* arrow) so the DNS log and the Connections log read identically: the "who asked"
* is the LAN device, the resolver that answered is the trailing secondary column.
* The backend drops the router's own resolutions (urltest probes / sub fetches) at
* ingestion, so every row here is a real client; an empty device (older data)
* falls back to a dash.
*
* TWO AXES, AND THE OUTCOME LEADS. The row used to be coloured by `action` alone,
* which answers "which way did it go" — so a lookup that left through a detour and
* then TIMED OUT came out as an accent-coloured `proxy`, the single healthiest-
* looking row in the log, describing the exact moment the tunnel failed. The
* outcome now marks the whole row (`st-<state>`, four states, four looks) and the
* path stays right beside it, because "it failed" and "it failed in the tunnel"
* are different reports and the second one is the useful one.
*/
function DnsRow({ r, fresh }: { r: QueryLogEntry; fresh: boolean }) {
const tag = actionTag(r.action)
// Outcome + path in one reading (logRoute.dnsRowMark) — the page derives
// neither on its own, so the four states cannot collapse into three here.
const mark = dnsRowMark(r)
const dev = r.device.trim()
// WHERE the lookup left the box. Four states, four readings (logRoute
// .dnsOutbound) — `default` is the one worth spotting: the resolver names no
// detour, so this query went out past the tunnel.
const out = dnsOutbound(r)
const cls = ['ins-conn', 'ins-conn--dns', `st-${mark.state}`]
if (fresh) cls.push('new')
return (
<li className={fresh ? 'ins-conn ins-conn--dns new' : 'ins-conn ins-conn--dns'}>
<li className={cls.join(' ')}>
<span className="ins-conn-t mono">{fmtClock(r.unix)}</span>
<span className="ins-conn-dev mono" title={dev || 'source device unknown'}>
{dev || '—'}
@@ -199,7 +264,43 @@ function DnsRow({ r, fresh }: { r: QueryLogEntry; fresh: boolean }) {
<span className="ins-conn-out mono" title={`resolver · ${r.server}`}>
{r.server || '—'}
</span>
<span className={`tag ${tag}`}>{tag}</span>
<span className="ins-dns-v">
<span className={`tag ${mark.path}`} title={mark.pathTitle}>
{mark.pathLabel}
</span>
<span className={`ins-dns-st s-${mark.outcome}`} title={mark.title}>
{mark.label}
</span>
</span>
<span className="ins-why">
<span className={`ins-why-kind o-${out.kind}`} title={out.title}>
{out.label}
</span>
{out.tag ? (
<span className="ins-why-txt mono" title={out.title}>
{out.tag}
</span>
) : null}
{/* Only a failure has a cause, and a failure ALWAYS shows one — "cause not
recorded" included, because an empty cell reads as nothing wrong. */}
{mark.cause ? (
<span className="ins-why-cause mono" title={mark.title}>
{mark.cause}
</span>
) : null}
{mark.rcodeNote ? (
<span
className="ins-why-rcode mono"
title={
r.rcode === -1
? 'The server never answered at all — a timeout, a network error, or a lookup that never left.'
: `The server answered with response code ${r.rcode} (2 SERVFAIL, 5 REFUSED).`
}
>
{mark.rcodeNote}
</span>
) : null}
</span>
</li>
)
}
@@ -415,6 +516,7 @@ function LogShell({
pending,
onTogglePause,
onFlush,
truncated,
}: {
ariaLabel: string
children: React.ReactNode
@@ -427,9 +529,12 @@ function LogShell({
pending: number
onTogglePause: () => void
onFlush: () => void
/** The daemon stopped scanning before the data ran out — see ScanNotice. */
truncated: boolean
}) {
return (
<>
{truncated ? <ScanNotice onContinue={onMore} busy={busy} /> : null}
{/* aria-live off while paused so a screen reader isn't nagged by rows the
* user deliberately froze; polite while live so new rows are announced. */}
<div
@@ -481,6 +586,106 @@ function LogShell({
)
}
/**
* THE SEARCH STOPPED BEFORE THE LOG DID.
*
* A filtered walk has to examine rows it will not return, so the daemon bounds
* it and reports X-Stats-Log-Truncated when that bound — not the data — ended
* the walk. Drawing such a page as the end of the log is the exact lie this
* notice exists to prevent: the operator would read "nothing found" and stop,
* with the evidence still sitting further back in the ring.
*
* So the state is NAMED, and it comes with the only thing that can resolve it:
* the daemon's resume cursor, spent by the same "load older" path the button
* below drives. It is a note, not an alarm — nothing is broken.
*/
function ScanNotice({ onContinue, busy }: { onContinue: () => void; busy: boolean }) {
return (
<p className="ins-scan" role="status">
<span className="ins-scan-lab">Scan stopped</span>
<span className="ins-scan-txt">
The daemon limits how many rows one search may scan, and this search hit that limit.
These are the matches found so far — not the end of the log.
</span>
<button type="button" className="ins-scan-go" onClick={onContinue} disabled={busy}>
{busy ? 'Searching…' : 'Keep searching further back'}
</button>
</p>
)
}
/**
* The log search box.
*
* The daemon filters, not the panel: `q=` is applied inside the store on the
* same walk as the seq cursor, so `limit` counts MATCHING rows and paging stays
* correct. It matters because the ring is 200 rows by default — by the time
* anyone opens the panel the evidence is usually gone, and search is what makes
* the log worth keeping.
*
* `fields` names what is searched, and by omission what is not. That is not
* decoration: a person who types a port number and gets nothing deserves to see
* that ports were never in the list, rather than conclude the traffic did not
* happen.
*/
function LogSearch({
id,
value,
onChange,
fields,
placeholder,
}: {
id: string
value: string
onChange: (v: string) => void
fields: string
placeholder: string
}) {
return (
<div className="ins-search">
<input
id={id}
type="search"
className="ins-search-in mono"
value={value}
placeholder={placeholder}
aria-label={`Search this log by ${fields}`}
aria-describedby={`${id}-fields`}
onChange={(e) => onChange(e.target.value)}
/>
{value ? (
<button
type="button"
className="ins-search-clr"
onClick={() => onChange('')}
aria-label="Clear the search"
title="Clear the search"
>
✕
</button>
) : null}
<span id={`${id}-fields`} className="ins-search-fields mono">
{fields}
</span>
</div>
)
}
/**
* Hold a value still for `ms` after the last change. The search text drives a
* server round-trip AND discards the accumulated rows, so sending one per
* keystroke would both hammer the daemon's scan budget and make the list flicker
* through the prefixes of what is being typed.
*/
function useDebounced<T>(value: T, ms: number): T {
const [held, setHeld] = useState(value)
useEffect(() => {
const id = window.setTimeout(() => setHeld(value), ms)
return () => window.clearTimeout(id)
}, [value, ms])
return held
}
// ---- live, cursor-paginated log model --------------------------------------
type HasSeq = { seq: number }
@@ -519,6 +724,13 @@ interface LiveLog<T> {
paused: boolean // live stream paused — new rows buffer instead of prepend
pending: number // rows waiting in the paused buffer ("N new")
freshSeqs: Set<number> // seqs from the most recent live prepend (animate these)
/**
* The last HISTORY page (head or `before=`) ended on the daemon's scan budget
* rather than on the data. Never true without a filter — an unfiltered walk has
* no budget — and it is the one thing that must not be folded into "no more
* rows": see ScanNotice, and logRoute.historyExhausted for the decision it feeds.
*/
truncated: boolean
reload: () => void // re-fetch the newest page (reset to the live head)
loadMore: () => void // append the next older page to the bottom
togglePause: () => void // pause ⇄ resume (resume flushes the buffer)
@@ -543,6 +755,10 @@ function useLiveLog<T extends HasSeq>(
/** Changing this discards the accumulated rows and re-reads from the head.
* Driven by the stats backend id — see the reset effect below (F6). */
resetKey?: string,
/** Server-side substring filter (`q=`). Changing it invalidates every row we
* hold — they were selected under the old needle — so it is folded into the
* reset key below rather than applied to the next page only. */
filter = '',
opts?: { page?: number; livePage?: number; cap?: number; interval?: number },
): LiveLog<T> {
const page = opts?.page ?? 100
@@ -558,6 +774,18 @@ function useLiveLog<T extends HasSeq>(
const [paused, setPaused] = useState(false)
const [buffer, setBuffer] = useState<T[]>([])
const [freshSeqs, setFreshSeqs] = useState<Set<number>>(() => new Set())
const [truncated, setTruncated] = useState(false)
// The daemon's resume point for the HISTORY direction. It is authoritative:
// on a complete page it equals the oldest row's seq, and on a truncated page
// with no matches it is the ONLY way back — there is no row to page from.
const scanCursorRef = useRef(0)
// The FORWARD (live tail) cursor. It used to be derived from rows[0].seq, which
// breaks the moment a filter is on: a filtered `after=` walk that matches
// nothing returns zero rows, rows[0] never moves, and the poller rescans the
// same window every tick forever. The daemon reports the last row it examined,
// so the tail advances on rows examined rather than rows matched.
const liveCursorRef = useRef(0)
// Live values for the stable poll effect, so it never needs to re-subscribe
// (and reset its interval) when rows/paused/buffer change.
@@ -573,12 +801,16 @@ function useLiveLog<T extends HasSeq>(
const reload = useCallback(async () => {
setBusy(true)
try {
const { rows: res } = await fetcher({ limit: page })
const sorted = [...res].sort((a, b) => b.seq - a.seq)
const p = await fetcher({ limit: page, q: filter })
const sorted = [...p.rows].sort((a, b) => b.seq - a.seq)
setRows(sorted)
setBuffer([])
setFreshSeqs(new Set())
setExhausted(res.length < page)
setTruncated(p.truncated)
scanCursorRef.current = p.cursor
liveCursorRef.current = sorted[0]?.seq ?? 0
// A short page is the end of the log ONLY if the scan reached it.
setExhausted(historyExhausted({ returned: p.rows.length, page, truncated: p.truncated }))
setErr(false)
setLoaded(true)
} catch {
@@ -586,18 +818,24 @@ function useLiveLog<T extends HasSeq>(
} finally {
setBusy(false)
}
}, [fetcher, page])
}, [fetcher, page, filter])
const loadMore = useCallback(async () => {
const cur = rowsRef.current
if (busyRef.current || cur.length === 0) return
if (busyRef.current) return
// Not `rows[last].seq`: a truncated page can be EMPTY and still have more log
// behind it, and only the daemon's cursor knows where that is.
const from = resumeCursor({ cursor: scanCursorRef.current, rows: rowsRef.current })
if (from == null) return
setBusy(true)
try {
const { rows: res } = await fetcher({ before: cur[cur.length - 1].seq, limit: page })
if (res.length < page) setExhausted(true)
if (res.length > 0) {
const p = await fetcher({ before: from, limit: page, q: filter })
setTruncated(p.truncated)
if (p.cursor > 0) scanCursorRef.current = p.cursor
else if (p.rows.length > 0) scanCursorRef.current = p.rows[p.rows.length - 1].seq
setExhausted(historyExhausted({ returned: p.rows.length, page, truncated: p.truncated }))
if (p.rows.length > 0) {
setFreshSeqs(new Set()) // an older page never animates
setRows((prev) => mergeSeqDesc(prev, res, cap))
setRows((prev) => mergeSeqDesc(prev, p.rows, cap))
}
setErr(false)
} catch {
@@ -605,7 +843,7 @@ function useLiveLog<T extends HasSeq>(
} finally {
setBusy(false)
}
}, [fetcher, page, cap])
}, [fetcher, page, cap, filter])
const flushPending = useCallback(() => {
const buf = bufferRef.current
@@ -663,48 +901,60 @@ function useLiveLog<T extends HasSeq>(
const poll = async () => {
if (cancelled || document.hidden || busyRef.current) return
const cold = rowsRef.current.length === 0
const cold = rowsRef.current.length === 0 && liveCursorRef.current === 0
try {
if (cold) {
const { rows: res } = await fetcher({ limit: page })
const p = await fetcher({ limit: page, q: filter })
if (cancelled) return
setExhausted(res.length < page)
if (res.length === 0) return
setTruncated(p.truncated)
scanCursorRef.current = p.cursor
setExhausted(historyExhausted({ returned: p.rows.length, page, truncated: p.truncated }))
if (p.rows.length === 0) return
liveCursorRef.current = p.rows[0].seq
// A cold fill is a whole first page, not an arrival — land it quietly,
// exactly as reload() does, instead of animating 100 rows at once.
setFreshSeqs(new Set())
setRows((prev) => mergeSeqDesc(prev, res, cap))
setRows((prev) => mergeSeqDesc(prev, p.rows, cap))
return
}
for (let i = 0; i < MAX_CATCHUP_PAGES; i++) {
const cursor = rowsRef.current[0]?.seq
if (cursor == null) return
const { rows: res, pending, more } = await fetcher({ after: cursor, limit: livePage })
const cursor = liveCursorRef.current || rowsRef.current[0]?.seq
if (!cursor) return
const p = await fetcher({ after: cursor, limit: livePage, q: filter })
if (cancelled) return
if (res.length === 0) return
// Advance on rows EXAMINED, not rows matched: with a filter on, a walk
// that matched nothing still consumed the window, and a cursor that only
// moved on matches would rescan it every tick for as long as the box is up.
liveCursorRef.current = Math.max(cursor, p.cursor, p.rows[0]?.seq ?? 0)
if (p.rows.length === 0) {
if (!p.more) return
continue
}
// Backlog beyond what we can even hold: stop walking it and re-head.
if (pending > cap) {
const { rows: head } = await fetcher({ limit: page })
if (p.pending > cap) {
const head = await fetcher({ limit: page, q: filter })
if (cancelled) return
setBuffer([])
setFreshSeqs(new Set())
setRows(head)
setExhausted(head.length < page)
setRows(head.rows)
setTruncated(head.truncated)
scanCursorRef.current = head.cursor
liveCursorRef.current = head.rows[0]?.seq ?? liveCursorRef.current
setExhausted(
historyExhausted({ returned: head.rows.length, page, truncated: head.truncated }),
)
return
}
if (pausedRef.current) {
setBuffer((prev) => mergeSeqDesc(prev, res, cap))
setBuffer((prev) => mergeSeqDesc(prev, p.rows, cap))
} else {
setFreshSeqs(new Set(res.map((r) => r.seq)))
setRows((prev) => mergeSeqDesc(prev, res, cap))
setFreshSeqs(new Set(p.rows.map((r) => r.seq)))
setRows((prev) => mergeSeqDesc(prev, p.rows, cap))
}
if (!more) return
// Paused: rows land in the buffer, so rowsRef never advances and the
// cursor would repeat. Let the next tick continue instead.
if (pausedRef.current) return
if (!p.more) return
}
} catch {
// transient — keep the last good list and retry on the next tick
@@ -716,7 +966,7 @@ function useLiveLog<T extends HasSeq>(
cancelled = true
window.clearInterval(id)
}
}, [enabled, fetcher, page, livePage, cap, interval])
}, [enabled, fetcher, page, livePage, cap, interval, filter])
// F6 — a stats-backend switch invalidates every cursor we hold. `seq` is only
// monotonic across a sqlite→sqlite restart: memory→* restarts the counter at 1,
@@ -725,25 +975,43 @@ function useLiveLog<T extends HasSeq>(
// `after=<unreachable>` forever and the stream would sit silently frozen. Drop
// everything and re-read from the head instead — one visible reload beats a dead
// feed. Skipped on the first observation (there is nothing to invalidate yet).
const backendRef = useRef<string | undefined>(resetKey)
//
// The FILTER is folded into the same key for the same reason: every row we hold
// was selected under the old needle, so keeping them while the new one is in
// force would show matches for a search nobody ran.
//
// The separator is NUL because no reset key and no needle can contain one, so
// ("a","b") and ("ab","") cannot collide. It is written as the ESCAPE and never
// as a raw byte: one literal NUL in this source makes `file` call it binary, and
// ripgrep, `git grep` and every tree-wide search then SKIP the file in silence.
// On a codebase reviewed by grepping the tree, a file that answers no search is
// worse than the collision this separator guards against.
const gen = `${resetKey ?? ''}\u0000${filter}`
const backendRef = useRef<string>(gen)
useEffect(() => {
if (backendRef.current === resetKey) return
backendRef.current = resetKey
if (backendRef.current === gen) return
backendRef.current = gen
setRows([])
setBuffer([])
setFreshSeqs(new Set())
setExhausted(false)
setTruncated(false)
scanCursorRef.current = 0
liveCursorRef.current = 0
setLoaded(false) // re-arms the initial load effect above
}, [resetKey])
}, [gen])
return {
rows,
busy,
err,
showMore: rows.length > 0 && !exhausted,
// A truncated page has more log behind it even with zero rows on screen, so
// the way onward must stay reachable — that is the whole recovery path.
showMore: (rows.length > 0 || truncated) && !exhausted,
paused,
pending: buffer.length,
freshSeqs,
truncated,
reload: () => void reload(),
loadMore: () => void loadMore(),
togglePause,
@@ -753,7 +1021,7 @@ function useLiveLog<T extends HasSeq>(
// ---- page ------------------------------------------------------------------
export default function Insights() {
export default function Insights({ status }: { status: Status | null }) {
// Live aggregate snapshot — poll on Overview's 3s cadence, degrade to an honest
// "unavailable" state rather than freezing on stale numbers.
const [stats, setStats] = useState<Stats | null>(null)
@@ -803,13 +1071,36 @@ export default function Insights() {
// so no fetch fires in the off state. Cursors are derived from the rows, so
// Load more appends the next OLDER page and the live poll prepends NEW rows.
const loggingOff = stats?.backend === 'off'
// THE SERVICE ITSELF IS OFF — the reason this page is empty on a router nobody
// has switched on yet, and the reason it said nothing about it. The `ins-off`
// short-circuit below was written for one cause (logging switched off) and the
// shipped default is `memory`, not `off`, so it never fired: ten sections drew
// ten well-mannered "nothing yet" states and not one of them named the switch.
//
// Read through serviceIntent so `off` means the operator's switch and never an
// unreadable configuration — that one keeps the alarms it already has, and an
// unreachable stats endpoint is still reported by `statsOk` below.
const serviceOff = serviceIntent(status) === 'off'
// Wait for the first snapshot before touching the log endpoints: firing a fetch
// while `stats` is still null would hit a backend that may turn out to be "off".
const logsEnabled = stats !== null && !loggingOff
// Nothing is recorded with the service down either, so the pollers stay parked.
const logsEnabled = stats !== null && !loggingOff && !serviceOff
// The backend id doubles as the cursor-generation key: when it changes, `seq`
// may have restarted or jumped, so both logs reset and re-read from the head.
const conns = useLiveLog<ConnLogEntry>(getStatsConnsPage, logsEnabled, stats?.backend)
const dns = useLiveLog<QueryLogEntry>(getStatsLogPage, logsEnabled, stats?.backend)
// Search text: what the box holds vs what the daemon is actually filtering on.
// The debounced value is the one that goes on the wire, so a half-typed domain
// does not spend a scan budget or discard the rows already on screen.
const [connQ, setConnQ] = useState('')
const [dnsQ, setDnsQ] = useState('')
const connFilter = useDebounced(connQ, 350)
const dnsFilter = useDebounced(dnsQ, 350)
const conns = useLiveLog<ConnLogEntry>(
getStatsConnsPage,
logsEnabled,
stats?.backend,
connFilter,
)
const dns = useLiveLog<QueryLogEntry>(getStatsLogPage, logsEnabled, stats?.backend, dnsFilter)
// True from mount until the first log page has actually been fetched, so the
// panels read "loading…" instead of flashing "nothing logged yet".
const logsPending = !logsEnabled && statsOk
@@ -914,6 +1205,29 @@ export default function Insights() {
// effective backend is "off", nothing is recorded, so every list is empty — show
// an honest off state instead of a wall of "no data yet" cards. Absent backend on
// older daemons ⇒ treated as collecting, so this never trips on legacy snapshots.
// Said ONCE, before the grid, and it comes first: with the service off there is
// no engine to resolve a name or carry a packet, so "logging is on" is true and
// irrelevant and every section below is empty for this reason and no other.
// Ten honest empty states are still ten guesses to make about which one broke.
if (serviceOff) {
return (
<section className="page insights" aria-label="Insights">
<section className="ins-off" aria-label="The service is off">
<Led variant="off" />
<h2 className="ins-off-title">The service is off</h2>
<p className="ins-off-body">
Nothing is being routed, filtered, or recorded, so there is nothing to show here yet.
Turn the service on and apply the config — traffic starts appearing on this page within
seconds of the first lookup.
</p>
<a className="ins-off-cta" href="#/settings">
Turn it on in Settings
</a>
</section>
</section>
)
}
if (loggingOff) {
return (
<section className="page insights" aria-label="Insights">
@@ -1194,18 +1508,36 @@ export default function Insights() {
? 'log unavailable'
: (conns.busy || logsPending) && conns.rows.length === 0
? 'loading…'
: `${fmtNum(conns.rows.length)} shown · newest first`
: connFilter
? `${fmtNum(conns.rows.length)} ${conns.rows.length === 1 ? 'match' : 'matches'}`
: `${fmtNum(conns.rows.length)} shown · newest first`
}
right={
<LogSearch
id="ins-conn-q"
value={connQ}
onChange={setConnQ}
fields={CONN_SEARCH_HINT}
placeholder="find a device, host or rule"
/>
}
wide
>
{conns.rows.length === 0 ? (
<Empty>
{conns.err
? 'Connections log unreachable — the endpoint retries on the next refresh.'
: conns.busy || logsPending
? 'Reading the connection log…'
: 'No connections logged yet — once LAN clients open flows, device → destination events stream in here.'}
</Empty>
<>
{/* Named BEFORE the empty state, because the empty state is only true
when the scan reached the end — see ScanNotice. */}
{conns.truncated ? <ScanNotice onContinue={conns.loadMore} busy={conns.busy} /> : null}
<Empty>
{conns.err
? 'Connections log unreachable — the endpoint retries on the next refresh.'
: conns.busy || logsPending
? 'Reading the connection log…'
: connFilter || conns.truncated
? logEndNote({ filter: connFilter, truncated: conns.truncated, matches: 0 }).text
: 'No connections logged yet — once LAN clients open flows, device → destination events stream in here.'}
</Empty>
</>
) : (
<LogShell
ariaLabel="Connection events, newest first"
@@ -1213,7 +1545,13 @@ export default function Insights() {
onMore={conns.loadMore}
busy={conns.busy}
showMore={conns.showMore}
count={conns.showMore ? `${fmtNum(conns.rows.length)} loaded` : `${fmtNum(conns.rows.length)} · all loaded`}
truncated={conns.truncated}
count={logCountLabel({
rows: conns.rows.length,
showMore: conns.showMore,
truncated: conns.truncated,
filtered: !!connFilter,
})}
paused={conns.paused}
pending={conns.pending}
onTogglePause={conns.togglePause}
@@ -1238,18 +1576,34 @@ export default function Insights() {
? 'log unavailable'
: (dns.busy || logsPending) && dns.rows.length === 0
? 'loading…'
: `${fmtNum(dns.rows.length)} shown · newest first`
: dnsFilter
? `${fmtNum(dns.rows.length)} ${dns.rows.length === 1 ? 'match' : 'matches'}`
: `${fmtNum(dns.rows.length)} shown · newest first`
}
right={
<LogSearch
id="ins-dns-q"
value={dnsQ}
onChange={setDnsQ}
fields={LOG_SEARCH_HINT}
placeholder="find a domain, device or exit"
/>
}
wide
>
{dns.rows.length === 0 ? (
<Empty>
{dns.err
? 'DNS log unreachable — the endpoint retries on the next refresh.'
: dns.busy || logsPending
? 'Reading the DNS log…'
: 'No DNS decisions logged yet — resolve some DNS and they stream in here.'}
</Empty>
<>
{dns.truncated ? <ScanNotice onContinue={dns.loadMore} busy={dns.busy} /> : null}
<Empty>
{dns.err
? 'DNS log unreachable — the endpoint retries on the next refresh.'
: dns.busy || logsPending
? 'Reading the DNS log…'
: dnsFilter || dns.truncated
? logEndNote({ filter: dnsFilter, truncated: dns.truncated, matches: 0 }).text
: 'No DNS decisions logged yet — resolve some DNS and they stream in here.'}
</Empty>
</>
) : (
<LogShell
ariaLabel="DNS decisions, newest first"
@@ -1257,7 +1611,13 @@ export default function Insights() {
onMore={dns.loadMore}
busy={dns.busy}
showMore={dns.showMore}
count={dns.showMore ? `${fmtNum(dns.rows.length)} loaded` : `${fmtNum(dns.rows.length)} · all loaded`}
truncated={dns.truncated}
count={logCountLabel({
rows: dns.rows.length,
showMore: dns.showMore,
truncated: dns.truncated,
filtered: !!dnsFilter,
})}
paused={dns.paused}
pending={dns.pending}
onTogglePause={dns.togglePause}
+82 -11
View File
@@ -6,6 +6,7 @@ import type { Complete, Inbound, Interface, Model, Status } from '../api'
import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions'
import { sectionNotes } from '../findings'
import { killSwitchClosed } from '../planeState'
import { interceptLive } from '../intercept'
// The Networks page is the INGRESS editor — a thin editor over Model.Inbounds,
// following the same save-then-Apply contract as Nodes/DNS/Routing: every edit
@@ -242,7 +243,7 @@ function policyCopy(policy: Untunnelable, ctx: PolicyContext): PolicyCopy {
works:
scope === 'exceptPing'
? 'Nothing is being dropped: with the kill-switch open the forward chain has no drops at all, so a device’s own IPsec or PPTP connection works too.'
: 'Nothing is being dropped: with the kill-switch open the forward chain has no drops at all, so ping, traceroute and a device’s own IPsec or PPTP connection all work.',
: 'Nothing is being dropped: with the kill-switch open the forward chain has no drops at all, so ping and a device’s own IPsec or PPTP connection both work.',
// No "set the kill-switch to fail-closed" here: the moot note below owns
// that instruction, and printing it twice in one section is how the second
// copy stops being read.
@@ -275,7 +276,7 @@ function policyCopy(policy: Untunnelable, ctx: PolicyContext): PolicyCopy {
cost:
scope === 'exceptPing'
? `IPsec and PPTP VPN connections made from a device on your network stop working, and so does SCTP. ${UDP_VPNS_FINE}`
: `Ping and traceroute stop working from your devices. So do IPsec and PPTP VPN connections made from a device on your network, and SCTP. ${UDP_VPNS_FINE}`,
: `Ping stops working from your devices. So do IPsec and PPTP VPN connections made from a device on your network, and SCTP. ${UDP_VPNS_FINE}`,
tone: 'good',
claimed,
}
@@ -301,7 +302,7 @@ function policyCopy(policy: Untunnelable, ctx: PolicyContext): PolicyCopy {
}
return {
works:
'Ping and traceroute work everywhere, so you can check whether something is reachable.',
'Ping works everywhere, so you can check whether something is reachable.',
cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. IPsec and PPTP also get out — but only toward addresses your routing rules already send direct, so a VPN app on a device can still open its own connection beside this one if its server is one of those.',
tone: 'warn',
claimed,
@@ -321,7 +322,7 @@ function policyCopy(policy: Untunnelable, ctx: PolicyContext): PolicyCopy {
works:
scope === 'exceptPing'
? 'A device on your network can make its own IPsec or PPTP VPN connection. Ping already travels through the tunnel.'
: 'Ping and traceroute work, and a device on your network can make its own IPsec or PPTP VPN connection.',
: 'Ping works, and a device on your network can make its own IPsec or PPTP VPN connection.',
cost:
scope === 'exceptPing'
? 'That traffic goes out with your real IP, around the tunnel. A VPN app left running on a device keeps its own connection open beside this one — traffic through it isn’t proxied or filtered. If the ping route ever fails to come up, ping does the same instead of failing.'
@@ -344,6 +345,31 @@ function policyCopy(policy: Untunnelable, ctx: PolicyContext): PolicyCopy {
const IPTV_LINE =
'Multicast IPTV is not one of these things: it doesn’t pass this router on any of the three settings, and “Allow everything” won’t bring it back.'
/**
* Plain `traceroute`, said once and said the daemon's way.
*
* FOUR RUNGS OF THIS PAGE CLAIMED "ping and traceroute work". They do not. On the
* production router an ordinary `traceroute` from a LAN device prints `* * *` and
* nothing else, on every setting here, `direct` included — its UDP probes are
* diverted by tproxy and delivered LOCALLY to the engine, and local delivery is
* not forwarding, so nothing on the path is ever provoked into a `time-exceeded`.
* Nobody debugging a blank trace was going to find that by themselves, and the
* page was actively sending them to look for a fault at their own end.
*
* The wording is `apply/warnings.go udpTracerouteFacts`, which the daemon
* publishes for this very section and which is rendered from the running plane a
* few pixels below this line. Two versions of one fact on one screen is how the
* shorter one becomes a second opinion, so this is deliberately the same three
* claims in the same order: it prints no hops, why, and what to use instead.
* netplane/untunnelable.go states it too. Change one, change all three.
*
* It is unconditional for the same reason IPTV_LINE is: it is unconditionally
* true, and someone whose trace printed nothing arrives here looking for the
* setting that fixes it. There isn't one.
*/
const TRACEROUTE_LINE =
'Plain traceroute on Linux and macOS is a separate matter, and it reads the same under every setting here, “Allow everything” included: its UDP probes are tunnelled and do reach the target, but it prints no hops at all, only * * *. Each probe is delivered locally to the engine, and local delivery is not forwarding, so nothing on the path is ever asked for a time-exceeded — there is no fault at your end to go looking for. Use traceroute -I (ICMP probes, which is what Windows tracert already sends) for a trace that prints hops.'
/** The addr:port an inbound binds — the generator's clash key (listenKey). */
function listenKey(in_: Inbound): string {
if (effectiveType(in_) === 'tproxy') {
@@ -584,6 +610,9 @@ export default function Networks({ status }: { status?: Status | null }) {
[lanIfaces, inbounds],
)
const coveredCount = coverage.filter((c) => c.by).length
// Whether the configured coverage is in effect right now. Read live off
// /api/status, never from the config that produced `coverage` — see interceptLive.
const live = interceptLive(status ?? null)
// ---- mutations -----------------------------------------------------------
const addInbound = useCallback(
@@ -668,16 +697,33 @@ export default function Networks({ status }: { status?: Status | null }) {
<div className="nw-section" aria-label="Interception coverage">
<header className="nw-sec-hd">
<h2 className="nw-sec-title">Interception</h2>
{/* "configured" is now part of the count, because the lamps below can no
longer be read as "and it is happening" — see interceptLive. */}
<span className="nw-sec-count mono">
{coveredCount} / {lanIfaces.length} networks
{coveredCount} / {lanIfaces.length} configured
</span>
</header>
<p className="nw-sec-note">
Only a transparent (tproxy) inbound that is switched on sends a network’s traffic through
the engine. SOCKS and HTTP listeners are ports the router offers to whoever asks for them —
they don’t capture anything by themselves.
the engine, and only while the engine is running. SOCKS and HTTP listeners are ports the
router offers to whoever asks for them — they don’t capture anything by themselves.
</p>
{/* Said once, above the board, so the lamps below don't each have to carry
it. `stopped` is amber and not crit on purpose: the engine being down is
already an alarm, and it is raised once by the shell's status banner —
repeating it per network would be three copies of one fault. */}
{live !== 'running' && (
<p className="nw-sec-note nw-policy-note" role="status">
<Led variant={live === 'stopped' ? 'amber' : 'off'} />
<span>
{live === 'stopped'
? 'Nothing is being intercepted right now: the engine is not running, or no data plane is installed. What the lamps below show is what this config asks for, not what the router is doing.'
: 'Whether any of this is in effect has not been reported yet. The lamps below show what this config asks for.'}
</span>
</p>
)}
{lanIfaces.length === 0 ? (
<div className="nw-plate">
<p className="nw-plate-title">No LAN networks found</p>
@@ -689,19 +735,40 @@ export default function Networks({ status }: { status?: Status | null }) {
) : (
<ul className="nw-cov" aria-label="LAN networks and their interception state">
{coverage.map(({ iface, by }) => (
<li key={iface.name} className={by ? 'nw-cov-item on' : 'nw-cov-item off'}>
// Green only for coverage that is CONFIGURED and RUNNING. Configured
// but not running is amber (wired, not connected); configured with no
// reading is an unlit socket. An uncovered network stays unlit: it is
// not a fault, it is a network nobody asked to intercept.
<li
key={iface.name}
className={by && live === 'running' ? 'nw-cov-item on' : 'nw-cov-item off'}
>
<div className="nw-cov-hd">
<Led variant={by ? 'on' : 'off'} />
<Led
variant={
!by ? 'off' : live === 'running' ? 'on' : live === 'stopped' ? 'amber' : 'off'
}
/>
<span className="nw-cov-name mono">{iface.name}</span>
</div>
<span className="nw-cov-cidr mono">{iface.subnet || '—'}</span>
<span className="nw-cov-state">
{by ? (
{!by ? (
'Goes straight out — not intercepted'
) : live === 'running' ? (
<>
Through the tunnel via <strong className="mono">{by.Name}</strong>
</>
) : live === 'stopped' ? (
<>
Set to go through <strong className="mono">{by.Name}</strong> — not while the
engine is stopped
</>
) : (
'Goes straight out — not intercepted'
<>
Set to go through <strong className="mono">{by.Name}</strong> — not reported
as running
</>
)}
</span>
</li>
@@ -773,6 +840,10 @@ export default function Networks({ status }: { status?: Status | null }) {
<Led variant="off" />
<span>{IPTV_LINE}</span>
</p>
<p className="nw-sec-note nw-policy-note">
<Led variant="off" />
<span>{TRACEROUTE_LINE}</span>
</p>
{/* Fail-open makes the whole policy moot. The copy above now says what IS
happening; this line stays because it is the one that says what to DO. */}
+160
View File
@@ -235,6 +235,92 @@
max-width: 82ch;
overflow-wrap: anywhere;
}
/* ---- one node's test reading ----
* The whole design brief for this strip is one distinction: a MEASURED failure
* and a row nothing measured must not look alike. So the failure is crit-red
* text behind a red lamp, and the unmeasured row is the faintest text on the
* page behind an UNLIT socket — the same register the Targets card uses for its
* unmeasured state, so the two pages say "no reading" the same way. Orange stays
* out of it; good / warn / crit carry the meaning. */
.node-test {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 11.5px;
letter-spacing: 0.02em;
color: var(--dim);
}
.node-test-msg {
flex: 1 1 14ch;
min-width: 0;
overflow-wrap: anywhere;
}
.node-test-delay {
font-weight: 700;
color: var(--ink);
}
.node-test-exit {
color: var(--dim);
}
.node-test-exit--unknown {
font-family: var(--font-sans);
font-style: italic;
color: var(--faint);
cursor: help;
}
/* Which instrument took the number, in the quietest voice available: it matters
when the reading is questioned and never before. */
.node-test-src {
font-family: var(--font-sans);
font-size: 11px;
color: var(--faint);
cursor: help;
}
.node-test-at {
margin-left: auto;
font-size: 10.5px;
color: var(--faint);
}
/* A CARRIED reading — one an earlier run measured, kept because a new run no
longer wipes the board. On an inventory of 300 nodes most rows are carried, so
this must be visibly a different statement from "just measured": a bracket and
a brighter ink, never amber, because age is a qualifier and not a fault. */
.node-test-at--carried {
padding-left: 6px;
border-left: 2px solid color-mix(in srgb, var(--dim) 45%, transparent);
color: var(--dim);
cursor: help;
}
/* The hop that stopped the walk, read off `blocked_by`. Crit, because it names a
probe that ran and failed — the one `source:''` row that is a finding. */
.node-test-hop {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 700;
color: var(--crit);
white-space: nowrap;
cursor: help;
}
.node-test--wait .node-test-msg {
color: var(--amber);
}
.node-test--bad .node-test-msg,
.node-test--crit .node-test-msg {
color: var(--crit);
}
.node-test--warn .node-test-msg {
color: var(--amber);
}
/* "Nothing measured this" — and it must be impossible to mistake for the line
above it. */
.node-test--none .node-test-msg,
.node-test--unknown .node-test-msg {
font-family: var(--font-sans);
color: var(--faint);
}
/* Findings that belong to no single row (see Nodes.tsx globalFindings). */
.node-findings {
margin-bottom: calc(var(--u, 8px) * 2);
@@ -428,6 +514,80 @@
line-height: 1.45;
color: var(--faint);
}
/* protocol filter — a row of checkboxes that wraps rather than stretching the
grid column it sits in */
.opt-checks {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
padding: 7px 0 1px;
}
.opt-check {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--dim);
cursor: pointer;
}
.opt-check input {
accent-color: var(--accent);
cursor: pointer;
}
.opt-check input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* a Toggle sitting inside an .opt-field, with its explanation beside it */
.opt-toggle-row {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
}
.opt-toggle-row .opt-hint {
min-width: 0;
}
/* A caveat inside an options fieldset that changes what the setting DOES —
carries the amber LED, same plate as .dns-sec-warn on the DNS page. */
.opt-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 2px 0 0;
padding: 9px 11px;
border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--groove));
border-radius: 8px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--amber) 8%, var(--raised)),
var(--raised)
);
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.5;
color: var(--ink);
max-width: 72ch;
}
/* One honest next step under a row that has nothing in it yet. */
.row-hint {
margin: 4px 0 0;
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.5;
color: var(--faint);
max-width: 64ch;
}
.row-hint strong {
color: var(--dim);
font-weight: 600;
}
/* selects inherit .fp-input; give them a little room for the native arrow */
select.fp-input {
appearance: none;
+676 -95
View File
File diff suppressed because it is too large Load Diff
+113 -9
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Led, Module } from '../components'
import type { LedVariant } from '../components'
import { fmtDateTime, fmtDuration } from '../format'
import { fmtClock, fmtDateTime, fmtDuration } from '../format'
import {
apply as apiApply,
rollback as apiRollback,
@@ -15,17 +15,23 @@ import { confirmTimeout } from '../pendingConfirm'
import { navigate } from '../router'
import type { Route } from '../router'
import { attentionFindings, truncationNote } from '../findings'
import { engineReadout, killSwitchReadout, protectionState } from '../planeState'
import { applyRisk, engineReadout, killSwitchReadout, protectionState } from '../planeState'
import { rejectedReading, shortHash } from '../appliedConfig'
import type { RejectedReading } from '../appliedConfig'
import { ApplyRiskBand } from './Apply'
// null-safe length for a Go slice that may arrive as null.
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
const enabledCount = <T extends { Enabled?: boolean }>(a: T[] | null | undefined): number =>
(a ?? []).filter((x) => x.Enabled).length
// The config hash, shortened. Delegates to appliedConfig.shortHash so this row
// and the not-applied band cannot print two different strings for one
// configuration — in that state telling two configurations apart is the entire
// point of the reading. The em dash for "no hash" stays here: the band has no
// empty case to render.
function short(hash: string): string {
if (!hash) return '—'
const h = hash.replace(/^sha256:/, '')
return h.length > 12 ? h.slice(0, 12) : h
return hash ? shortHash(hash) : '—'
}
// Confirm is no longer one of them — see the note beside the controls row.
@@ -323,6 +329,23 @@ export function Overview({
// it is not the whole list. See findings.ts truncationNote.
const truncated = truncationNote(status?.warnings)
// What pressing "Apply config" below would do, when what it would do is take
// the whole network off the internet. Null otherwise — which is nearly always.
const risk = applyRisk(config)
/**
* THE CONFIGURATION ON DISK WAS REFUSED, and something older is running.
*
* Null on a healthy box and on a daemon too old to say — the page then looks
* exactly as it did. When it is not null, every reading below is about a
* DIFFERENT configuration than the one the operator saved, and the ones that
* name it (the hash, the traffic default, the findings) say so. See
* appliedConfig; the router-clock instant is formatted here because that module
* has no locale, and it is never turned into a browser-computed elapsed time.
*/
const rejected = rejectedReading(status, fmtClock(status?.apply_failed_since_unix ?? 0))
const stale = rejected !== null
return (
<section className="page" aria-label="Overview">
{/* One state, one sentence. This replaced a row of five pips that split a
@@ -344,7 +367,16 @@ export function Overview({
</p>
)}
<Findings warnings={warnings} criticalCount={criticalCount} truncated={truncated} />
{/* The configuration on disk is not the one running. Above the findings,
because it says what the findings are ABOUT. */}
{rejected && <NotAppliedBand r={rejected} />}
<Findings
warnings={warnings}
criticalCount={criticalCount}
truncated={truncated}
stale={stale}
/>
<div className="grid">
{/* Groups, not nodes: a group is where a dial path is defined, so it is the
@@ -411,7 +443,15 @@ export function Overview({
}}
rows={[
{ k: 'egresses', v: String(len(config?.Egresses)) },
{ k: 'default', v: defaultTarget(status, config), hot: true },
// Where traffic goes under the config that is RUNNING. When the config
// on disk was refused that is the older one, and the row has to say so
// rather than answer "where does my traffic go" about a config the
// operator replaced.
{
k: stale ? 'default · previous config' : 'default',
v: defaultTarget(status, config),
hot: true,
},
]}
/>
@@ -489,7 +529,15 @@ export function Overview({
led={{ variant: engineVariant }}
rows={[
{ k: 'process', v: engine.word, hot: engineVariant === 'crit' },
{ k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> },
// The hash identifies the config the ENGINE is running, which is not
// always the one on disk. Unqualified it is the most misleading value
// on this page in the rejected state: a real hash of a real running
// configuration, just not the one that was saved.
{
k: stale ? 'config hash · not yours' : 'config hash',
v: <span className="mono">{short(status?.hash ?? '')}</span>,
hot: stale,
},
// Uptime of the daemon PROCESS. "started" is the moment it came up,
// by the router's clock — not the moment a config was applied.
{ k: 'running for', v: uptimeText || '—' },
@@ -500,6 +548,13 @@ export function Overview({
/>
</div>
{/* The same forecast the Apply page shows, because this page has the same
button. It is predicted from the CONFIG, so it is silent on the router
that has already been applied into that state — protectionState's
"Nothing is getting out" is the readout for that one, and it is at the
top of this page. See planeState.applyRisk. */}
{risk && <ApplyRiskBand risk={risk} />}
{/* Apply / Confirm / Rollback — active voice, honest results. */}
<div className="controls" role="group" aria-label="Config actions">
<div className="controls-btns">
@@ -579,11 +634,19 @@ function Findings({
warnings,
criticalCount,
truncated,
stale,
}: {
warnings: StatusWarning[]
criticalCount: number
/** The daemon's "N further suppressed" note, when the list was capped. */
truncated: StatusWarning | null
/**
* The config on disk was refused. These findings come from the last SUCCESSFUL
* apply, so they are about the configuration that is running — not the one the
* operator saved. Under "Last apply" that reads as a report on their edit, and
* a clean list then reads as "your edit is fine".
*/
stale: boolean
}) {
if (warnings.length === 0 && !truncated) return null
@@ -593,7 +656,7 @@ function Findings({
return (
<section className="findings" aria-label="Findings from the last apply">
<header className="findings-hd">
<h2 className="findings-title">Last apply</h2>
<h2 className="findings-title">{stale ? 'Last successful apply' : 'Last apply'}</h2>
<span className="findings-count mono">
{/* "at least" whenever the list was capped: the counts below it are a
floor, not a total, and the cap drops the least severe FIRST — so
@@ -604,6 +667,15 @@ function Findings({
: `${warnings.length} note${warnings.length === 1 ? '' : 's'}`}
</span>
</header>
{/* Its own line, not a third item in the header: at 390 px the header's
three cells shrank the sentence to four words a column. It is a sentence
about the whole list, so it sits above the list. */}
{stale && (
<p className="findings-stale">
These describe the configuration that is RUNNING — not the one you saved. The refusal
is in the band above.
</p>
)}
<ul className="findings-list">
{sorted.map((w, i) => {
const route = SECTION_ROUTE[w.section]
@@ -671,6 +743,38 @@ const NAV_LABEL: Record<Route, string> = {
apply: 'Apply',
}
/**
* THE CONFIGURATION ON DISK IS NOT THE ONE RUNNING.
*
* It borrows the .risk-band vocabulary because the severity matches, but the
* eyebrow names a different tense: the hazard band is a FORECAST of what a button
* would do, this one reports a state the box is already in.
*
* The last line is the one the defect was missing. Saying "refused" is not
* enough: `engine_running` is true, the LED is green, the hash looks normal, and
* the traffic verdict answers confidently — all about a configuration the
* operator replaced. So the band names what everything else on the page is about
* before the reader gets to any of it.
*/
function NotAppliedBand({ r }: { r: RejectedReading }) {
return (
<section className="stale-band" role="alert" aria-label="Configuration not applied">
<div className="risk-band-hd">
<Led variant="crit" />
<span className="risk-eyebrow">Not applied</span>
</div>
<p className="risk-headline">The configuration on disk is not the one running.</p>
<p className="risk-detail">
/etc/config/shater was refused at <strong className="stale-stage">{r.stageText}</strong>.
{r.stageHint ? ` ${r.stageHint}` : ''}
</p>
<p className="risk-detail stale-cause mono">{r.cause}</p>
<p className="risk-detail">{r.persistence}</p>
<p className="risk-undo hot">{r.scope}</p>
</section>
)
}
// (StatusPip lived here. It backed the five-pip status strip, which collapsed
// into the single protection-state readout above; Apply.tsx keeps its own copy
// for the apply/rollback flow, where the individual flags are the actual
+38 -4
View File
@@ -102,17 +102,32 @@
padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3);
text-align: center;
}
/* The empty state stopped being one line the day it started NAMING the default
* route (see liveDefaultRoute). Five centred lines of mono is a wall, so the plate
* stays centred while the prose inside is a measured, left-aligned column in the
* page's reading face — the route tag keeps the mono, because it is a value. */
.rt-empty p {
margin: 0;
font-family: var(--font-mono);
margin: 0 auto;
max-width: 58ch;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
text-align: left;
color: var(--ink);
}
.rt-empty-sub {
margin-top: 6px !important;
font-size: 11.5px !important;
margin-top: 8px !important;
font-size: 12px !important;
color: var(--faint) !important;
}
/* Amber under BOTH branches, and that is not an oversight: with no rules at all,
* `block` means the LAN has no internet and `direct` means the LAN is on the plain
* WAN with its real address. Neither is a resting state, so neither gets the green
* or the accent that would say "this is fine". */
.rt-empty-route {
color: var(--amber);
font-weight: 600;
}
/* ---- the bus ---- */
.rt-list {
@@ -321,6 +336,25 @@
line-height: 1.45;
color: var(--dim);
}
/* ---- the per-rule kill policy (Rule.Kill) ----
*
* The same amber pill as .rt-badge.dead, and for the same reason: --amber is warn,
* --accent is ACTIVE. A rule that fails open is not broken and not an incident —
* it is a deliberate weakening — so it must be legible without reading as an
* alarm, and it must never wear the orange that means "this is working".
* Only `open` and an unreadable value are marked at all; the fail-closed default
* draws nothing (see killMark). */
.rt-badge.kill {
padding: 1px 7px;
border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--groove));
border-radius: 999px;
background: color-mix(in srgb, var(--amber) 12%, transparent);
color: var(--amber);
}
.rt-kill-note {
margin: 0;
}
/* The target is still what the operator asked for, so it stays readable — just
* quiet, because the router is not using it. */
.rt-rule.dead .rt-target {
+172 -15
View File
@@ -13,7 +13,16 @@ import {
} from '../api'
import type { Model, Rule, RuleReach, Ruleset, RulesetStatus } from '../api'
import { everyLabel, relFetch } from '../format'
import { effectiveTarget, liveDefaultRoute } from '../defaultRoute'
import { killSwitchClosed } from '../planeState'
import {
carryKill,
killMark,
killPolicy,
killSelectValue,
KILL_OPEN_FORM_WARN,
KILL_OPTIONS,
} from '../killPolicy'
import { carryRulesetFormat } from '../ruleset'
// ---------------------------------------------------------------------------
@@ -353,13 +362,6 @@ function defaultRouteConsequence(
)
}
/** Effective routing target for a rule (Target wins; a bare Egress is a target too). */
function effectiveTarget(r: RRule): string {
if (r.Target && r.Target.trim()) return r.Target.trim()
if (r.Egress && r.Egress.trim()) return `egress:${r.Egress.trim()}`
return 'direct'
}
/** Semantic tone for a target chip — block is critical, direct is quiet, else accent. */
function targetTone(t: string): 'block' | 'direct' | 'proxy' {
if (t === 'block') return 'block'
@@ -407,6 +409,8 @@ interface AddForm {
rulesets: string[]
proto: string
target: string
/** Rule.Kill, as the picker spells it — see killPolicy.ts. */
kill: string
schedEnabled: boolean
schedDays: string[]
schedStart: string
@@ -418,7 +422,27 @@ const EMPTY_FORM: AddForm = {
port: '',
rulesets: [],
proto: '',
target: 'direct',
// NO DEFAULT TARGET, and that is the point.
//
// It was `direct`. A form with nothing typed into it is a rule with no matchers,
// i.e. the router's DEFAULT ROUTE — so pressing "Add rule" on an untouched form
// put the whole network onto the plain WAN, with the real address, in one
// keystroke. The page flagged the consequence honestly ("no matchers — matches
// everything", and after an apply a critical "nothing is going through the
// tunnel"), which is precisely what made the pre-filled value so expensive: the
// trap was not the warning, it was that a value nobody chose had already been
// chosen for them, and it was the most dangerous one on the list.
//
// `block` was the tempting fix and is the wrong one for the same reason: it also
// lets one keystroke reconfigure the router, just toward an outage instead of a
// leak. The recoverable side of THIS default is not a safer value — it is no
// value. An empty target refuses to save and asks the question (see onAdd).
target: '',
// Fail-closed is the daemon's own default for a rule with no `option kill`, and
// a new rule must not start life on the side that leaks. Unlike the target, this
// one HAS a safe side: it decides what happens on a failure, so leaving it unset
// cannot itself reconfigure anything.
kill: 'closed',
schedEnabled: false,
schedDays: [],
schedStart: '',
@@ -906,6 +930,15 @@ export default function Routing() {
setFormError('A rule with that name already exists.')
return
}
// The target has no default (see EMPTY_FORM) and an empty one is not a rule
// shape the daemon reads as "nothing": generate resolves an empty target to
// `direct`, so saving one would write the leak this refusal exists to stop.
if (!form.target.trim()) {
setFormError(
'Pick where this rule sends its traffic — a rule with no target routes it direct, out of the tunnel.',
)
return
}
setFormError(null)
const rule: RRule = {
Name: name,
@@ -919,6 +952,10 @@ export default function Routing() {
Proto: form.proto,
Target: form.target,
Egress: '',
// Written explicitly, including the safe default: `option kill 'closed'`
// says which side of the kill-switch this rule chose, where a missing
// option says only that nobody was asked.
Kill: form.kill,
// Schedule (Phase 7): only carried when the admin enabled it. Empty days
// = every day; empty start = 00:00; empty/equal end = all-day. The engine
// (generate) emits the rule only inside this window; cron reconciles at
@@ -994,13 +1031,23 @@ export default function Routing() {
const enabledCount = force.filter((f) => f.on).length
const overridden = force.filter((f) => f.profile !== null)
const overrideProfile = overridden[0]?.profile ?? null
// The route unmatched traffic takes right now — named in the lead and in the
// empty state, because "the default route" on its own is what let a blocked
// network read as normal. See liveDefaultRoute.
const def = liveDefaultRoute(rules, isLiveDefault, killSwitch)
return (
<section className="page" aria-label="Routing rules">
<div className="rt-intro">
<p className="rt-lead">
Rules run top to bottom on the bus — the <strong>first match wins</strong>. Traffic that
reaches the bottom follows the default route.
reaches the bottom follows the default route, which right now is{' '}
<strong className="mono">{def.target}</strong>
{def.rule ? (
<> — rule “{def.rule}”.</>
) : (
<> — no rule claims it, so the kill-switch decides.</>
)}
</p>
<span className="rt-count mono" aria-label={`${enabledCount} of ${rules.length} rules in force`}>
{enabledCount}
@@ -1041,8 +1088,39 @@ export default function Routing() {
{rules.length === 0 ? (
<div className="rt-empty">
<p>No rules — all traffic follows the default route.</p>
<p className="rt-empty-sub">Add a rule below to steer a destination list, source, or port.</p>
{/* Names the route and says what it costs. The kill-switch is the only
thing deciding it here — with no rules there is no catch-all to
inherit Final — so each branch says which setting the operator would
have to change, not just what is happening. */}
{killSwitch === 'open' ? (
<>
<p>
No rules — so everything from your network follows the default route,{' '}
<strong className="mono rt-empty-route">direct</strong>: it leaves through your normal internet
connection with your real IP address, unproxied and unfiltered. The kill-switch is
set to <strong>fail-open</strong>, which is what chooses that.
</p>
<p className="rt-empty-sub">
Add a rule below. A rule with no matchers becomes the default route and takes it
over.
</p>
</>
) : (
<>
<p>
No rules — so everything from your network follows the default route,{' '}
<strong className="mono rt-empty-route">block</strong>:{' '}
<strong>devices have no internet</strong>{' '}
until a rule says where their traffic should go. The kill-switch is set to{' '}
<strong>fail-closed</strong>, and with no rule claiming the default, blocking is
what that means.
</p>
<p className="rt-empty-sub">
Add a rule below with no matchers — it becomes the default route — and point it at a
group, a node or <span className="mono">direct</span>.
</p>
</>
)}
</div>
) : (
<ol className="rt-list" aria-label="Routing rules in first-match order">
@@ -1165,6 +1243,12 @@ function RuleRow({
const isDefault = isCatchAll(rule) && !dead
const target = effectiveTarget(rule)
const tone = targetTone(target)
// What this rule does if its target cannot be built (Rule.Kill). null on the
// fail-closed default — see killMark: badging every row would price a deliberate
// bypass the same as the safe side, which is the reading this mark exists to
// prevent. Drawn even on an inert row: the policy is what the config SAYS, and a
// rule that is inert today is one edit away from being live.
const kill = killMark(rule)
// Dimmed by the EFFECTIVE state, never by the saved one. A rule the active
// profile switched off is not in force, and the row has to read that way even
// though its switch — which edits the saved setting — is still on.
@@ -1211,6 +1295,7 @@ function RuleRow({
faceplate's active state, and a force-enabled rule is exactly that. */}
{force.dir === 'disabled' && <span className="rt-badge dead">off · by profile</span>}
{force.dir === 'enabled' && <span className="rt-badge prof-on">on · by profile</span>}
{kill && <span className="rt-badge kill">{kill.badge}</span>}
</div>
<div className="rt-match">
{unmigrated ? (
@@ -1246,6 +1331,10 @@ function RuleRow({
overrides several rules would otherwise repeat that paragraph on every
one of them. What is left is the part only this row can say: whether it
is in force, and what its own switch is showing instead. */}
{/* The badge names the policy; this says what it costs. Under the matchers,
like the profile note, because it is a consequence of the row rather
than one of its conditions. */}
{kill && <p className="rt-dead-note rt-kill-note">{kill.note}</p>}
{force.profile && (
<p className="rt-dead-note rt-prof-note">
{force.dir === 'disabled' ? 'Not in force' : 'In force'} — profile{' '}
@@ -1398,7 +1487,16 @@ function Matchers({ rule }: { rule: RRule }): ReactNode {
* Interfaces-egresses, and the huge Nodes list LAST. `current` re-surfaces a
* value that isn't in the live config (e.g. a target pointing at a since-removed
* node) as its own option so editing a rule can never silently drop its target. */
function TargetOptions({ targets, current }: { targets: TargetGroups; current?: string }): ReactNode {
function TargetOptions({
targets,
current,
placeholder,
}: {
targets: TargetGroups
current?: string
/** Add form only: the unchosen state, which the submit handler refuses. */
placeholder?: string
}): ReactNode {
const known = useMemo(() => {
const s = new Set<string>()
for (const g of [targets.simple, targets.groups, targets.chains, targets.egresses, targets.nodes])
@@ -1407,6 +1505,7 @@ function TargetOptions({ targets, current }: { targets: TargetGroups; current?:
}, [targets])
return (
<>
{placeholder && <option value="">{placeholder}</option>}
{current && current.trim() && !known.has(current) && (
<option value={current}>{current} · current</option>
)}
@@ -1465,6 +1564,47 @@ function TargetOptions({ targets, current }: { targets: TargetGroups; current?:
* Rulesets panel's job, deliberately kept out of the rule editor so a list is
* created in exactly one place.
*/
/**
* The per-rule fallback picker — `Rule.Kill`.
*
* It sits next to Target because it is a statement ABOUT the target: what this
* rule does on the day that target cannot be built. Two options only, because the
* daemon reads only two outcomes (killPolicy.ts). A stored value that is neither
* is re-surfaced as its own option, exactly as TargetOptions re-surfaces a target
* pointing at a since-removed node: a picker that quietly dropped it would be
* rewriting a field it never showed, and would erase the evidence of the typo the
* daemon is warning about.
*/
function KillField({
value,
busy,
onChange,
}: {
value: string
busy: boolean
onChange: (v: string) => void
}) {
const unknown = killPolicy(value) === 'unknown'
return (
<label className="rt-field">
<span className="rt-flabel">If target fails</span>
<select
className="rt-input mono"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy}
>
{unknown && <option value={value}>“{value}” · not recognised, blocks</option>}
{KILL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
)
}
function RulesetPicker({
options,
selected,
@@ -1690,9 +1830,11 @@ function AddRule({
value={form.target}
onChange={(e) => set('target', e.target.value)}
>
<TargetOptions targets={targets} />
<TargetOptions targets={targets} placeholder="— pick a target —" />
</select>
</label>
<KillField value={form.kill} busy={busy} onChange={(v) => set('kill', v)} />
</div>
<RulesetPicker
@@ -1721,6 +1863,9 @@ function AddRule({
{noMatchers && !error && (
<span className="rt-edit-warn">no matchers — matches everything</span>
)}
{killPolicy(form.kill) === 'open' && !error && (
<span className="rt-edit-warn">{KILL_OPEN_FORM_WARN}</span>
)}
{error && (
<span className="rt-add-error" role="alert">
{error}
@@ -1759,6 +1904,9 @@ function RuleEditForm({
const [port, setPort] = useState(initial.DstPort ?? '')
const [proto, setProto] = useState(initial.Proto ?? '')
const [target, setTarget] = useState(effectiveTarget(initial))
// The stored spelling, shown as the picker spells it. An unrecognised value is
// carried through verbatim so the form cannot rewrite what it did not offer.
const [kill, setKill] = useState(killSelectValue(initial.Kill))
const [rulesets, setRulesets] = useState<string[]>([...(initial.DstRuleset ?? [])])
const [schedEnabled, setSchedEnabled] = useState(!!initial.SchedEnabled)
const [schedDays, setSchedDays] = useState<string[]>([...(initial.SchedDays ?? [])])
@@ -1792,8 +1940,8 @@ function RuleEditForm({
setErr('A rule with that name already exists.')
return
}
// Spread carries Order/Enabled/Kill/Egress + any off-form field through
// untouched; the form fields below overwrite exactly the matchers/target/schedule.
// Spread carries Order/Enabled/Egress + any off-form field through untouched;
// the form fields below overwrite exactly the matchers/target/kill/schedule.
// An empty field clears its matcher on purpose (you can strip a matcher this way).
// Saving re-anchors the schedule to THIS browser's UTC offset: the times in the
// form are taken as your wall clock (the router evaluates at UTC+offset).
@@ -1805,6 +1953,10 @@ function RuleEditForm({
DstRuleset: rulesets,
Proto: proto,
Target: target,
// carryKill keeps the stored spelling when the policy did not change: ''
// and 'default' mean 'closed', and rewriting one into the other would put an
// explicit option on every rule anyone ever opened this form for.
Kill: carryKill(initial.Kill, kill),
SchedEnabled: schedEnabled,
SchedDays: schedEnabled ? schedDays : [],
SchedStart: schedEnabled ? schedStart : '',
@@ -1893,6 +2045,8 @@ function RuleEditForm({
<TargetOptions targets={targets} current={target} />
</select>
</label>
<KillField value={kill} busy={busy} onChange={setKill} />
</div>
<RulesetPicker options={rulesetOptions} selected={rulesets} busy={busy} onToggle={toggleRuleset} />
@@ -1920,6 +2074,9 @@ function RuleEditForm({
{noMatchers && !err && (
<span className="rt-edit-warn">no matchers — matches everything</span>
)}
{killPolicy(kill) === 'open' && !err && (
<span className="rt-edit-warn">{KILL_OPEN_FORM_WARN}</span>
)}
{err && (
<span className="rt-add-error" role="alert">
{err}
+12
View File
@@ -309,6 +309,18 @@
.set-field-ctl {
min-width: 0;
}
/* A <select> shrink-wraps to its WIDEST option, and nothing capped it: the geo
provider labels ("Auto — country codes from SagerNet, the rest from
Loyalsoldier") pushed the element to 559px inside a 390px viewport and the
whole page scrolled sideways. The labels are load-bearing — they are where
the coverage and cost difference between providers is stated — so the fix is
to cap the control, not to shorten what it says. Truncation is the browser's
job once there is a definite width to truncate against. */
.set-field-ctl select {
width: 100%;
max-width: 100%;
min-width: 0;
}
.set-dl-row {
justify-content: flex-start;
}
+215 -10
View File
@@ -6,6 +6,13 @@ import { AlertsSection } from './Alerts'
import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api'
import type { Globals, LogRange, Model } from '../api'
import { killSwitchClosed } from '../planeState'
import {
GEO_PROVIDERS,
checkGeoTemplate,
isKnownGeoProvider,
normGeoProvider,
usesCustomTemplates,
} from '../geoProvider'
// The Settings page is a thin editor over the desired-state Model's Globals —
// same save→apply split as DNS.tsx: every edit rewrites model.Globals in-place,
@@ -17,13 +24,12 @@ import { killSwitchClosed } from '../planeState'
// on blur/Enter after validation — an invalid value shows an inline error and is
// NOT saved.
//
// api.ts types Globals without PanelPort (it rides through the untyped Model
// passthrough), so we surface it via a local structural extension.
// ---- local Globals extension (promote to api.ts) --------------------------
/** Globals plus the admin-panel port, carried through the Model passthrough. */
type GlobalsX = Globals & { PanelPort?: number }
// There is no local extension of `Globals` here. There used to be one for
// PanelPort, with a note saying api.ts did not type the field — api.ts has typed
// it for a long time now, and the stale note was an invitation to declare the
// next field twice, in two shapes that could drift. Every key this page writes is
// on `Globals` in api.ts, and it has to be: PUT /api/config decodes with
// DisallowUnknownFields, so a key that exists only here fails the WHOLE write.
// ---- helpers ---------------------------------------------------------------
@@ -105,6 +111,30 @@ function parseDuration(raw: string): ParseResult<string> {
return { ok: true, value: s.toLowerCase() }
}
/**
* Parse a custom `{category}` geo URL template.
*
* EMPTY IS LEGAL and is not an error to refuse: it means "this source keeps using
* the built-in Auto chain", which is a state the operator is allowed to return to
* by clearing the box. A non-empty template without the placeholder is refused
* here, because the daemon will not splice the category in and would otherwise
* fetch a plausible-looking wrong URL that only fails at update time.
*/
function parseGeoTemplate(raw: string): ParseResult<string> {
const s = raw.trim()
if (s === '') return { ok: true, value: '' }
const v = checkGeoTemplate(s)
return v.ok ? { ok: true, value: s } : { ok: false, error: v.reason }
}
/** Parse an optional http(s):// URL — blank clears it. */
function parseUrlOrBlank(raw: string): ParseResult<string> {
const s = raw.trim()
if (s === '') return { ok: true, value: '' }
if (!HTTP_RE.test(s)) return { ok: false, error: 'Enter an http(s):// URL, or leave it blank.' }
return { ok: true, value: s }
}
// `none` really does silence the engine log — it is emitted as the engine's own
// log-disable switch, not as a quieter level.
const LOG_LEVELS: ReadonlyArray<{ value: string; label: string }> = [
@@ -211,15 +241,15 @@ export default function Settings() {
// ---- one setter for every Globals field -----------------------------------
const setGlobal = useCallback(
<K extends keyof GlobalsX>(key: K, value: GlobalsX[K], okMsg: string) => {
<K extends keyof Globals>(key: K, value: Globals[K], okMsg: string) => {
if (!config) return
const nextGlobals = { ...(config.Globals as GlobalsX), [key]: value }
const nextGlobals: Globals = { ...config.Globals, [key]: value }
void save({ ...config, Globals: nextGlobals }, okMsg)
},
[config, save],
)
const globals = config?.Globals as GlobalsX | undefined
const globals = config?.Globals
const busy = saving || applying
const ready = !!config
@@ -269,6 +299,24 @@ export default function Settings() {
// the Targets page. Absent ⇒ enabled (older config), so read it as `!== false`.
const groupHealthOn = globals?.GroupHealth !== false
// ---- geo data --------------------------------------------------------------
// An absent GeoProvider reads as `auto`, because auto is what the router runs.
// An UNKNOWN one is preserved verbatim and marked: opening this page must not be
// an edit, and the daemon degrades it to auto with a warning rather than
// refusing to start — so the panel says which of the two is true instead of
// drawing a setting that is not in effect.
const geoProvider = normGeoProvider(globals?.GeoProvider)
const geoProviderKnown = isKnownGeoProvider(geoProvider)
const geoCustom = usesCustomTemplates(geoProvider)
const geoProviderNote =
GEO_PROVIDERS.find((p) => p.id === geoProvider)?.blurb ??
'The router has no such provider and is running Auto instead — pick one from the list.'
// "in effect" here means exactly the daemon's test: non-empty AND carrying the
// placeholder. A template failing either keeps that ONE source on the built-in
// chain; the other source is unaffected, which is why they are two verdicts.
const geoSiteTemplateOk = checkGeoTemplate(globals?.GeositeURL ?? '').ok
const geoIpTemplateOk = checkGeoTemplate(globals?.GeoipURL ?? '').ok
// Normalised the daemon's way (planeState.killSwitchClosed), not by string
// equality: `kill_switch 'OPEN'` is fail-OPEN on the router, and `=== 'open'`
// read it as closed — the panel would have drawn the protective setting over a
@@ -599,6 +647,163 @@ export default function Settings() {
</Field>
</Group>
{/* ---- GEO DATA ---- */}
{/* Where every geosite/geoip list on the Routing and DNS pages fetches
from. Five real Globals fields that had no control at all: the only
way to move `geoip:us` off SagerNet was to edit the config over SSH,
while this panel used the data those settings pick. */}
<Group title="Geo data" count={geoProvider}>
<p className="set-group-note">
The catalogue behind every <strong>geosite</strong> and <strong>geoip</strong> list —
the rule-sets on Routing, the category blocklists on DNS, and the suggestions their
pickers offer. Changing it changes where those lists are downloaded from, and how big
they are: <span className="mono">netflix</span> is about 108 address prefixes, while
the country <span className="mono">us</span> is about 159 000.
</p>
<Field label="Provider" note={geoProviderNote}>
<Select
value={geoProvider}
options={GEO_PROVIDERS.map((p) => ({ value: p.id, label: p.label }))}
ariaLabel="Geo data provider"
busy={busy}
disabled={!ready}
onChange={(v) => setGlobal('GeoProvider', v, `Geo provider → ${v}`)}
/>
</Field>
{!geoProviderKnown && (
<p className="set-warn" role="status">
The router does not recognise{' '}
<span className="mono">{globals?.GeoProvider}</span> and is using{' '}
<strong>Auto</strong> instead — it warns and carries on rather than refusing to
start. Pick a provider above to make the setting real.
</p>
)}
{geoCustom && (
<>
<p className="set-group-note">
Each template must contain <span className="mono">{'{category}'}</span> exactly
once — the router splices the category in there and will not append it, because
appending would build a wrong URL that only fails at update time. Leave one blank
to keep that source on the built-in Auto chain.
</p>
<Field
label="Geosite URL template"
note="Where a domain category (youtube, category-ads-all…) is fetched from."
>
<InlineEdit<string>
value={globals?.GeositeURL ?? ''}
format={(s) => s}
parse={parseGeoTemplate}
inputMode="url"
width="26rem"
placeholder="https://mirror.example/geosite/{category}.srs"
ariaLabel="Custom geosite URL template"
busy={busy}
disabled={!ready}
onCommit={(v) =>
setGlobal(
'GeositeURL',
v,
v ? 'Geosite template saved' : 'Geosite template cleared — back to Auto',
)
}
/>
</Field>
{!geoSiteTemplateOk && (
<p className="set-warn" role="status">
No geosite template — geosite lists keep using the built-in Auto chain
(SagerNet), whatever this provider says.
</p>
)}
<Field
label="Geoip URL template"
note="Where an address category (a country code, an ASN…) is fetched from."
>
<InlineEdit<string>
value={globals?.GeoipURL ?? ''}
format={(s) => s}
parse={parseGeoTemplate}
inputMode="url"
width="26rem"
placeholder="https://mirror.example/geoip/{category}.srs"
ariaLabel="Custom geoip URL template"
busy={busy}
disabled={!ready}
onCommit={(v) =>
setGlobal(
'GeoipURL',
v,
v ? 'Geoip template saved' : 'Geoip template cleared — back to Auto',
)
}
/>
</Field>
{!geoIpTemplateOk && (
<p className="set-warn" role="status">
No geoip template — geoip lists keep using the built-in Auto chain, whatever
this provider says.
</p>
)}
<Field
label="Geosite category index"
// Suggestions only. Saying more would be a promise the daemon
// does not make: an empty or unreachable index leaves the field
// free text, exactly as it already degrades on a failed fetch.
note="A GitHub git-trees API URL listing the published files. Used only to suggest category names in the pickers — leave it blank and you simply type the category yourself."
>
<InlineEdit<string>
value={globals?.GeositeIndexURL ?? ''}
format={(s) => s}
parse={parseUrlOrBlank}
inputMode="url"
width="26rem"
placeholder="https://api.github.com/repos/…/git/trees/…"
ariaLabel="Geosite category index URL"
busy={busy}
disabled={!ready}
onCommit={(v) =>
setGlobal(
'GeositeIndexURL',
v,
v ? 'Geosite index saved' : 'Geosite index cleared — no suggestions',
)
}
/>
</Field>
<Field
label="Geoip category index"
note="The same, for address categories. Blank means no suggestions for geoip."
>
<InlineEdit<string>
value={globals?.GeoipIndexURL ?? ''}
format={(s) => s}
parse={parseUrlOrBlank}
inputMode="url"
width="26rem"
placeholder="https://api.github.com/repos/…/git/trees/…"
ariaLabel="Geoip category index URL"
busy={busy}
disabled={!ready}
onCommit={(v) =>
setGlobal(
'GeoipIndexURL',
v,
v ? 'Geoip index saved' : 'Geoip index cleared — no suggestions',
)
}
/>
</Field>
</>
)}
</Group>
{/* ---- HEALTH CHECK ---- */}
<Group title="Health check">
<p className="set-group-note">
+29 -6
View File
@@ -174,11 +174,16 @@
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
color: var(--accent);
}
/* Semantics carry the colour: amber = degraded, not on fire (same recipe as the
dev-badge--warn / dns-badge--warn variants). */
.tg-badge--warn {
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
color: var(--amber);
/* Semantics carry the colour: crit = broken. The --warn / --good / --unknown
variants that stood beside it went out with the readiness plate that was their
only caller — an unused rule is a rule nobody is keeping true, and the recipe
is not lost: dev-badge--warn / dns-badge--warn are the same one, still live.
The dashed --unknown treatment is worth re-deriving rather than re-copying if a
third state ever comes back here; painting a not-yet-taken reading amber makes
it indistinguishable from a degraded one. */
.tg-badge--crit {
border-color: color-mix(in srgb, var(--crit) 55%, var(--groove));
color: var(--crit);
}
/* ---- chain signal path (the signature) ---- */
@@ -548,7 +553,6 @@
.tg-fhint--warn {
color: var(--amber);
}
/* editor footer */
.tg-ed-foot {
display: flex;
@@ -719,6 +723,25 @@
font-size: 10.5px;
color: var(--faint);
}
/* A CARRIED reading — measured by an earlier run, kept on the board because a
run no longer wipes it. It gets a bracket and a brighter ink than the plain
stamp so it reads as a qualifier rather than as one more faint timestamp:
drawn identically, a twenty-minute-old number passes for a fresh one, which is
the whole failure this rendering exists to prevent. Never amber — an old
reading is not a fault. */
.tg-test-at--carried {
padding-left: 6px;
border-left: 2px solid color-mix(in srgb, var(--dim) 45%, transparent);
color: var(--dim);
cursor: help;
}
/* The hop that stopped a chain, read off `blocked_by` rather than off the
sentence beside it. Sits before the message so the number is the first thing
on the row. */
.tg-test-hop {
cursor: help;
white-space: nowrap;
}
/* ---- per-group membership health ----
* The signature of this page's group section: a band that is only as long as
+118 -127
View File
@@ -8,7 +8,6 @@ import {
getGroupsHealth,
getGroupsTest,
getInterfaces,
getStatus,
postGroupsTest,
putConfig,
ApiError,
@@ -35,7 +34,12 @@ import {
UNKNOWN_EGRESS_TYPE_HINT,
egressTypeInfo,
nextEgress,
retiredEgressType,
} from '../egressEdit'
import { blockedHop, originStamp, readingTone, rowOrigin } from '../testResult'
import type { RowOrigin } from '../testResult'
import { targetResultFor } from '../targetResult'
import type { TargetKind } from '../targetResult'
// The Targets page is a thin editor over the desired-state Model — the same
// shape as DNS.tsx and Nodes.tsx. It manages the three things a routing rule
@@ -473,31 +477,6 @@ export default function Targets() {
}
}, [])
// Whether the `ciadpi` binary (the optional `byedpi` package) is present on
// the router. A byedpi egress without it is DEAD — fail-closed, everything
// bound to it is blocked — so the editor refuses to create NEW byedpi
// egresses when it is missing. null = unknown (older daemon without the
// field, or the read failed): unknown must NOT gate — never lock an operator
// out of a control on a guess. Existing byedpi egresses are never hidden or
// rewritten either way; they just carry a warning (config is sacred).
const [byedpiInstalled, setByedpiInstalled] = useState<boolean | null>(null)
useEffect(() => {
let alive = true
getStatus()
.then((s) => {
if (alive && typeof s.byedpi_installed === 'boolean')
setByedpiInstalled(s.byedpi_installed)
})
.catch(() => {
/* leave null → no gating */
})
return () => {
alive = false
}
}, [])
// Only an explicit "not installed" gates; null (unknown) does not.
const byedpiMissing = byedpiInstalled === false
// ---- toast + persistent apply banner --------------------------------------
const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<number | undefined>(undefined)
@@ -650,10 +629,21 @@ export default function Targets() {
[flash, readTest],
)
const testByGroup = useMemo(
() => new Map(gtest.results.map((r) => [r.group, r])),
[gtest],
)
/**
* The board row belonging to one card, claimed by (name, KIND).
*
* NOT a Map keyed by name. The board holds the latest reading PER TARGET, and a
* target is (name, kind): the daemon carries forward the rows a run does not
* re-measure and supersedes them by name AND kind, so a chain `x` and a node
* `x` are two live rows under one name — and the carried one is appended last.
* Keying by name alone let that carried NODE row overwrite the chain's own
* reading, and the card then showed another target's milliseconds, exit address
* and verdict as its own. See targetResult.targetResultFor for the fallbacks.
*/
const testFor = useMemo(() => {
const rows = asArray(gtest.results)
return (name: string, kind: TargetKind) => targetResultFor(rows, name, kind)
}, [gtest])
/**
* The groups and chains the current run covers. This — not `running` — is what puts the
@@ -724,7 +714,6 @@ export default function Targets() {
const groupNames = useMemo(() => new Set(groups.map((g) => g.Name)), [groups])
const chainNames = useMemo(() => new Set(chains.map((c) => c.Name)), [chains])
const egressNames = useMemo(() => new Set(egresses.map((e) => e.Name)), [egresses])
// Hop picker options for chains: every group and node in the config.
const hopOptions = useMemo<Opt[]>(
() => [
@@ -1018,7 +1007,10 @@ export default function Targets() {
showHealth={groupHealthOn}
health={healthByGroup.get(g.Name)}
healthKnown={healthRead}
test={testByGroup.get(g.Name)}
test={testFor(g.Name, 'group')}
// …and the reading it shows may be from an EARLIER run: the
// board is no longer wiped, so the row has to say which.
testOrigin={rowOrigin(testFor(g.Name, 'group'), gtest.scope)}
// The badge is this card's business only when the run names it.
testing={gtest.running && testScope.has(g.Name)}
// …but the daemon runs one test at a time, so any run in flight
@@ -1104,7 +1096,10 @@ export default function Targets() {
// The whole chain health record, not just `.used` — the card
// renders the observatory's per-hop measurements from it.
health={healthByChain.get(c.Name)}
test={testByGroup.get(c.Name)}
test={testFor(c.Name, 'chain')}
// …and the reading it shows may be from an EARLIER run: the
// board is no longer wiped, so the row has to say which.
testOrigin={rowOrigin(testFor(c.Name, 'chain'), gtest.scope)}
// The badge is this card's business only when the run names it.
testing={gtest.running && testScope.has(c.Name)}
// …but the daemon runs one test at a time, so any run in flight
@@ -1136,8 +1131,8 @@ export default function Targets() {
</Button>
</header>
<p className="tg-sec-note">
An egress is a raw <strong>exit</strong> — a WAN interface or tunnel, the normal route with
a DPI preset on it, or the local ByeDPI desync proxy. Rules, nodes and groups point at
An egress is a raw <strong>exit</strong> — a WAN interface or tunnel, or the normal route
with a DPI preset on it. Rules, nodes and groups point at
egress:<code>&lt;name&gt;</code>. To send traffic through a proxy, route it at a group, node
or chain instead; to drop it, give the rule the <code>block</code> target.
</p>
@@ -1145,7 +1140,6 @@ export default function Targets() {
{egressEd?.mode === 'add' && (
<EgressEditor
interfaces={interfaces}
byedpiMissing={byedpiMissing}
taken={egressNames}
busy={busy}
onCancel={() => setEgressEd(null)}
@@ -1172,7 +1166,6 @@ export default function Targets() {
<EgressEditor
initial={e}
interfaces={interfaces}
byedpiMissing={byedpiMissing}
taken={without(egressNames, e.Name)}
busy={busy}
onCancel={() => setEgressEd(null)}
@@ -1187,7 +1180,6 @@ export default function Targets() {
<EgressRow
key={e.Name}
egress={e}
byedpiMissing={byedpiMissing}
busy={busy}
onEdit={() => setEgressEd({ mode: 'edit', name: e.Name })}
onDelete={() => removeEgress(e.Name)}
@@ -1216,6 +1208,7 @@ function GroupRow({
health,
healthKnown,
test,
testOrigin,
testing,
testBusy,
onTest,
@@ -1234,6 +1227,9 @@ function GroupRow({
* does mean "the engine doesn't have this group". */
healthKnown: boolean
test?: GroupTestResult
/** Which run `test` came from — this one, or an earlier one whose reading the
* board carried forward. Resolved by the caller against the run's scope. */
testOrigin: RowOrigin
/**
* A refresh pass covering THIS group is in flight.
*
@@ -1304,6 +1300,7 @@ function GroupRow({
/>
<GroupTestReadout
test={test}
origin={testOrigin}
pending={testing && !test}
hideAbsence={health?.used === false}
/>
@@ -1705,36 +1702,35 @@ function MemberRow({ member }: { member: GroupMemberHealth }) {
* not red, and the LED stays green.
*/
/**
* Errors that mean NO MEASUREMENT EXISTS, as opposed to "this target is broken".
* WHICH INSTRUMENT SAID SO decides the colour now, not the wording.
*
* Three of the observatory's four failure reasons are about the observatory, not
* about the path: nothing routes here, nothing has reached it yet, or background
* probing is switched off. Painting those crit-red — which is what `ok:false`
* used to buy you — reports a fault that nobody has found, on a target that may
* be carrying traffic perfectly. Only "the observatory's probe through this path
* failed" is a health finding, and it is deliberately NOT in this list.
* `readingTone` (testResult.ts) reads the result's `source`: a row measured by
* the observatory or by an on-demand probe that came back negative is a health
* finding and stays red; a row with `source:''` measured NOTHING, and painting
* that red reports a fault nobody has found on a target that may be carrying
* traffic perfectly. The single exception it keeps is the blocked chain, whose
* `source:''` still names a hop that WAS probed and did not answer.
*
* Matched on a stable fragment rather than the whole sentence, so a daemon that
* rewords the tail still classifies. An error we don't recognise stays red: an
* unknown failure is likelier to be real than not, and that is the safe default.
* The error prose survives as the fallback for a daemon that predates `source`,
* inside that helper, and nowhere else.
*/
const NO_MEASUREMENT = [
'not routed by any enabled rule',
'has not reached this target yet',
'background probing is disabled',
]
const isAbsence = (err: string): boolean => NO_MEASUREMENT.some((frag) => err.includes(frag))
function GroupTestReadout({
test,
pending,
hideAbsence,
origin,
}: {
test?: GroupTestResult
pending: boolean
/** The card already explains why nothing measures this target (the unused
* note), so an absence error here would just say it a second time. */
hideAbsence?: boolean
/**
* Which run this row came from — see testResult.rowOrigin. A run no longer
* wipes the board, so a card can be showing a reading from twenty minutes ago
* beside another card's fresh one, and the two must not be drawn alike.
*/
origin: RowOrigin
}) {
if (pending) {
// Names who is working and on what: the prober, on this target. The badge is
@@ -1748,26 +1744,48 @@ function GroupTestReadout({
}
if (!test) return null
const at = test.tested_unix ? fmtClock(test.tested_unix) : ''
// WHEN, and WHOSE RUN. A carried row gets different words and its own class:
// a bare timestamp is read as "now" whatever it says, because the reader has
// no reason to suspect the board holds two ages at once.
const stamp = originStamp(origin, test.tested_unix ? fmtClock(test.tested_unix) : '')
const at = stamp.text ? (
<span
className={`tg-test-at mono${origin === 'carried' ? ' tg-test-at--carried' : ''}`}
title={stamp.hint}
>
{stamp.text}
</span>
) : null
// The hop that was probed, failed, and stopped this chain before its exit was
// ever dialled — read off `blocked_by`, never off the sentence beside it.
const hop = blockedHop(test)
if (!test.ok) {
// No measurement exists. Unlit lamp, quiet text: this panel's way of saying
// "no verdict", which is precisely the state — never a red one.
if (isAbsence(test.error)) {
if (readingTone(test) === 'unknown') {
if (hideAbsence) return null
return (
<div className="tg-test tg-test--none" role="status">
<Led variant="off" />
<span className="tg-test-msg">{test.error}</span>
{at && <span className="tg-test-at mono">{at}</span>}
{at}
</div>
)
}
return (
<div className="tg-test tg-test--bad" role="status">
<Led variant="crit" />
{hop > 0 && (
<span
className="tg-badge tg-badge--crit tg-test-hop"
title="The prober walks a chain in order and stops at the first hop that does not answer, so this chain’s exit was never dialled. Fix that hop first."
>
blocked at hop {hop}
</span>
)}
<span className="tg-test-msg">{test.error || 'the probe failed'}</span>
{at && <span className="tg-test-at mono">{at}</span>}
{at}
</div>
)
}
@@ -1790,7 +1808,7 @@ function GroupTestReadout({
exit address not determined
</span>
)}
{at && <span className="tg-test-at mono">{at}</span>}
{at}
</div>
)
}
@@ -2193,6 +2211,7 @@ function ChainRow({
showHealth,
health,
test,
testOrigin,
testing,
testBusy,
onTest,
@@ -2211,6 +2230,9 @@ function ChainRow({
* rather than guessing. */
health?: ChainHealth
test?: GroupTestResult
/** Which run `test` came from — this one, or an earlier one whose reading the
* board carried forward. Resolved by the caller against the run's scope. */
testOrigin: RowOrigin
/** A refresh pass covering THIS chain is in flight (the caller resolves it
* against the run's scope, exactly as for a group card). */
testing: boolean
@@ -2280,6 +2302,7 @@ function ChainRow({
) : null}
<GroupTestReadout
test={test}
origin={testOrigin}
pending={testing && !test}
hideAbsence={health?.used === false}
/>
@@ -2734,37 +2757,29 @@ function ChainEditor({
function EgressRow({
egress,
byedpiMissing,
busy,
onEdit,
onDelete,
}: {
egress: Egress
// The ciadpi binary is confirmed absent. An existing byedpi egress is still
// SHOWN (saved config never silently disappears) — it just wears a warning,
// because everything routed to it is blocked until the package is installed.
byedpiMissing: boolean
busy: boolean
onEdit: () => void
onDelete: () => void
}) {
const dead = egress.Type === 'byedpi' && byedpiMissing
const detail = useMemo(() => {
switch (egress.Type) {
case 'interface':
return egress.Interface ? `iface ${egress.Interface}` : 'no interface set'
case 'byedpi':
return dead
? `127.0.0.1:${egress.Port || 1080} — byedpi package not installed, nothing routed here can leave`
: `127.0.0.1:${egress.Port || 1080}`
case 'direct':
return 'straight to WAN'
default:
// No outbound is built for a type outside the three, so everything bound
// No outbound is built for a type outside the two, so everything bound
// to it is blocked. Say so on the row rather than printing a bare word.
// A RETIRED type lands here too, and the editor is where it gets its own
// sentence — the row states the consequence, which is the same one.
return `${egress.Type || 'no type'} — nothing routed here can leave`
}
}, [egress, dead])
}, [egress])
const dpi = DPI_TYPES.has(egress.Type) && egress.DPI && egress.DPI !== 'off' ? egress.DPI : ''
return (
@@ -2774,7 +2789,6 @@ function EgressRow({
<span className="tg-row-name">{egress.Name}</span>
<span className="tg-badge tg-badge--accent">{EGRESS_TYPE_LABEL[egress.Type] ?? egress.Type}</span>
{dpi && <span className="tg-badge">dpi: {dpi}</span>}
{dead && <span className="tg-badge tg-badge--warn">ciadpi missing</span>}
</div>
<div className="tg-row-l2 mono">
<span className="tg-row-detail">{detail}</span>
@@ -2794,7 +2808,6 @@ function EgressRow({
function EgressEditor({
initial,
interfaces,
byedpiMissing,
taken,
busy,
onCancel,
@@ -2802,11 +2815,6 @@ function EgressEditor({
}: {
initial?: Egress
interfaces: Interface[]
// The ciadpi binary is confirmed absent, so choosing byedpi would create a
// dead egress (fail-closed: everything bound to it is blocked). The option is
// then disabled for a NEW choice — but an egress that is ALREADY byedpi keeps
// it selectable, so opening and re-saving never rewrites stored config.
byedpiMissing: boolean
taken: Set<string>
busy: boolean
onCancel: () => void
@@ -2815,14 +2823,14 @@ function EgressEditor({
const [name, setName] = useState(initial?.Name ?? '')
const [type, setType] = useState(initial?.Type || 'interface')
const [iface, setIface] = useState(initial?.Interface ?? '')
const [port, setPort] = useState(initial?.Port != null ? String(initial.Port) : '')
const [dpi, setDpi] = useState(initial?.DPI || 'off')
const [err, setErr] = useState<string | null>(null)
const typeInfo = egressTypeInfo(type)
// This egress was byedpi when the editor opened — its own type stays legal
// even with the package gone, so saved config can always round-trip.
const wasByedpi = initial?.Type === 'byedpi'
const byedpiLocked = byedpiMissing && !wasByedpi
// A type this product used to build and does not any more. Answered BEFORE the
// generic unknown hint: the operator did not mistype anything, so telling them
// the router "does not recognise" it would send them looking for a typo that is
// not there. Undefined for every live type and for a genuine unknown.
const retired = retiredEgressType(type)
const submit = async () => {
const nm = name.trim()
@@ -2830,16 +2838,12 @@ function EgressEditor({
if (taken.has(nm)) return setErr(`An egress named “${nm}” already exists.`)
if (type === 'interface' && !iface.trim())
return setErr('Enter the UCI interface name (e.g. wan, wg0).')
// Belt to the disabled option's braces: a stale select state must not save
// an egress the router cannot run.
if (type === 'byedpi' && byedpiLocked)
return setErr('Install the byedpi package to add a ByeDPI egress.')
setErr(null)
// Carry the fields the chosen type uses and clear the rest — but ONLY for a
// type this editor renders those fields for. An unknown type keeps every
// stored setting untouched, because this form showed the operator none of
// them and must not delete what it declined to display. See nextEgress.
await onSave(nextEgress(initial, { name: nm, type, iface, port, dpi }))
// type this editor renders those fields for. An unknown or retired type keeps
// every stored setting untouched, because this form showed the operator none
// of them and must not delete what it declined to display. See nextEgress.
await onSave(nextEgress(initial, { name: nm, type, iface, dpi }))
}
return (
@@ -2878,27 +2882,31 @@ function EgressEditor({
}}
disabled={busy}
>
{EGRESS_TYPES.map((t) => {
const locked = t.id === 'byedpi' && byedpiLocked
return (
<option key={t.id} value={t.id} disabled={locked}>
{locked ? `${t.label} (package not installed)` : t.label}
</option>
)
})}
{!typeInfo && <option value={type}>{type || '—'} (unknown)</option>}
{EGRESS_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
{/* The stored type, kept selectable so a config this editor cannot
build is never silently rewritten by opening it. Its parenthesis
says WHICH kind of stranger it is: a type that was taken out of
the product reads "removed", not "unknown". */}
{!typeInfo && (
<option value={type}>
{type || '—'} ({retired ? 'removed' : 'unknown'})
</option>
)}
</select>
<p className="tg-fhint">{typeInfo?.blurb ?? UNKNOWN_EGRESS_TYPE_HINT}</p>
{byedpiLocked && (
<p className="tg-fhint">
Install the <code>byedpi</code> package to enable the ByeDPI egress.
</p>
)}
{byedpiMissing && wasByedpi && type === 'byedpi' && (
{/* A retired type answers FIRST: it is a different fact from an
unrecognised one, and it sends the operator somewhere else. */}
{typeInfo ? (
<p className="tg-fhint">{typeInfo.blurb}</p>
) : retired ? (
<p className="tg-fhint tg-fhint--warn" role="alert">
The <code>byedpi</code> package (ciadpi) is not installed — everything routed to this
egress is blocked until you install it. The egress is kept as saved.
{retired}
</p>
) : (
<p className="tg-fhint">{UNKNOWN_EGRESS_TYPE_HINT}</p>
)}
</label>
@@ -2942,23 +2950,6 @@ function EgressEditor({
</label>
)}
{/* Only a byedpi egress dials anywhere, so only it has a port. */}
{type === 'byedpi' && (
<label className="tg-field">
<span className="tg-flabel">ciadpi port</span>
<input
className="tg-input"
value={port}
spellCheck={false}
autoComplete="off"
inputMode="numeric"
placeholder="1080"
onChange={(e) => setPort(e.target.value)}
disabled={busy}
/>
</label>
)}
{DPI_TYPES.has(type) && (
<label className="tg-field">
<span className="tg-flabel">DPI preset</span>
+297 -5
View File
@@ -13,6 +13,49 @@
import type { LedVariant } from './components'
import type { Status, Traffic } from './api'
// ---------------------------------------------------------------------------
// Was the service ever asked to run?
// ---------------------------------------------------------------------------
/**
* Three answers, and "unknown" is one of them.
*
* on — the operator switched the service on. Everything downstream of it
* is SUPPOSED to be running, so anything that isn't is a fault.
* off — the operator switched it off. Nothing downstream is supposed to be
* running, so nothing missing downstream is a fault.
* unknown — nobody could tell us, because the configuration could not be read
* or no status has arrived. Never treated as either of the above.
*
* WHY THIS EXISTS. Every lamp in this panel was derived from what IS INSTALLED,
* and none of them from whether anything was MEANT to be. The shipped config is
* `enabled '0'`, `kill_switch 'closed'`, nothing applied — so a package that
* installed exactly as designed produced `plane:"none"`, `engine_running:false`,
* and the panel lit two crit lamps and a red "NOT IN EFFECT" 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.
*
* So the readouts below ask this first, and a missing subsystem under `off` is an
* UNLIT socket — never green (nothing is protected), never crit (nothing failed).
* Under `on` the same reading keeps every bit of its old severity: an engine that
* was asked to run and did not is still crit, and this must stay true or the
* change has merely repainted an outage.
*
* `config_readable === false` lands on `unknown` and NOT on `off`, for the same
* reason protectionState checks it first: `enabled` is one of the three fields
* sourced from the config, so it is a zero value — a hard `false` — exactly when
* the fail-closed plane has the LAN cut off on purpose. Reading that as "the
* operator switched it off" would hand the reassuring branch to the one state
* that must keep alarming.
*/
export type ServiceIntent = 'on' | 'off' | 'unknown'
export function serviceIntent(status: Status | null): ServiceIntent {
if (!status) return 'unknown'
if (status.config_readable === false) return 'unknown'
return status.enabled ? 'on' : 'off'
}
export interface ProtectionState {
variant: LedVariant
headline: string
@@ -59,11 +102,21 @@ export function engineState(status: Status | null): EngineState {
* `unknown` is an UNLIT socket, never amber and never green: amber is this
* panel's "degraded", and there is nothing to be degraded about when no reading
* has arrived. `down` is crit even when the kill-switch caught it — the engine
* being dead is the fault; whether traffic leaks is a separate lamp. */
* being dead is the fault; whether traffic leaks is a separate lamp.
*
* THE ONE EXCEPTION IS AN ENGINE NOBODY ASKED TO RUN. With the service switched
* off there is no process by design, and calling that "stopped" in crit red is
* reporting a failure that did not happen — it was the first of the two red
* lamps a correct fresh installation showed. The lamp goes unlit and the word
* says why. This branch is gated on {@link serviceIntent} being a positive
* `off`, so a daemon whose configuration could not be read (`unknown`) keeps the
* crit: that is the state where the LAN really is cut off. */
export function engineReadout(status: Status | null): { variant: LedVariant; word: string } {
switch (engineState(status)) {
case 'down':
return { variant: 'crit', word: 'stopped' }
return serviceIntent(status) === 'off'
? { variant: 'off', word: 'not started' }
: { variant: 'crit', word: 'stopped' }
case 'up':
return status?.active ? { variant: 'on', word: 'active' } : { variant: 'amber', word: 'idle' }
default:
@@ -80,11 +133,15 @@ export function engineReadout(status: Status | null): { variant: LedVariant; wor
*
* armed — configured fail-closed AND a data plane is installed to enforce it.
* inert — configured fail-closed, but there is no plane. Nothing is blocking.
* standby — configured fail-closed, no plane, AND the service is switched off.
* Same installed reality as `inert`, opposite meaning: with the
* service off there is nothing for a kill-switch to guard, so the
* absence of a plane is the design and not a failure. See below.
* unknown — the daemon has not said how much plane is installed, so whether the
* setting is in force is not known. NEVER paint this green.
* open — configured fail-open. Nothing is meant to be blocked.
*/
export type KillSwitchState = 'armed' | 'inert' | 'unknown' | 'open'
export type KillSwitchState = 'armed' | 'inert' | 'standby' | 'unknown' | 'open'
/**
* IS THE KILL-SWITCH CLOSED? The daemon's rule, exactly:
@@ -199,6 +256,8 @@ export function killSwitchReadout(
case 'full':
case 'hold':
// Something is installed, so the fail-closed guard is really in the path.
// Reached even with the service switched off, and deliberately so: a plane
// that is still installed IS still blocking, whatever the config now says.
return {
state: 'armed',
value: 'ARMED',
@@ -208,6 +267,29 @@ export function killSwitchReadout(
...setting,
}
case 'none':
// NO PLANE, AND NOBODY ASKED FOR ONE. This was the loudest thing on a
// correct fresh installation: shipped config is `enabled '0'` +
// `kill_switch 'closed'` + nothing applied, so this module read the plane
// as missing and shouted crit "NOT IN EFFECT" — a broken-protection alarm
// about protection that was never switched on.
//
// The distinction is entirely in WHY the plane is absent, and only
// `serviceIntent` can say. Off ⇒ expected, unlit, no alarm. On ⇒ the
// config asked for a fail-closed guard and there is none: `inert`, crit,
// untouched by this branch and it must stay that way — that reading is the
// difference between "traffic is going out raw" and "nothing is running".
if (serviceIntent(status) === 'off') {
return {
state: 'standby',
value: 'STANDBY',
variant: 'off',
// Says the true thing (nothing is being blocked) and the reason in the
// same breath, so it cannot be read as a guard that failed.
blockingNow: 'no — the service is off',
hot: false,
...setting,
}
}
return {
state: 'inert',
value: 'NOT IN EFFECT',
@@ -300,8 +382,12 @@ export function protectionState(status: Status | null): ProtectionState {
const failClosed = killSwitchClosed(status.kill_switch)
// The service being switched off is a deliberate state, not a fault.
if (!status.enabled) {
// The service being switched off is a deliberate state, not a fault. Asked
// through serviceIntent so this page and the lamps in the header, the Apply
// pips and the kill-switch module all split "off" from "broken" on ONE
// predicate — they used to each have their own idea, and only this one said
// "turned off" while the other three said crit about the same router.
if (serviceIntent(status) === 'off') {
return {
variant: 'amber',
headline: 'Turned off',
@@ -458,3 +544,209 @@ function ruleCount(n: number): string {
if (n === 1) return 'One rule sends traffic'
return `${n} rules send traffic`
}
// ---------------------------------------------------------------------------
// Is the config about to be applied one that takes the network down?
// ---------------------------------------------------------------------------
/**
* The fields of a routing rule that decide whether it matches EVERYTHING.
*
* Declared here rather than importing `api.Rule` because of `LegacyDst`: the
* daemon publishes it (model.Rule.LegacyDst) and the Routing page reads it, but
* it is not on the panel's `Rule` type. Structural typing makes an `api.Rule`
* assignable to this, so callers pass `model.Rules` straight in.
*/
export interface CatchAllRule {
Enabled: boolean
Src?: string[] | null
DstRuleset?: string[] | null
DstPort?: string
Proto?: string
/** The unmigrated-rule tripwire. Non-empty ⇒ NOT a catch-all; see below. */
LegacyDst?: string[] | null
}
/**
* A rule with no matcher of any kind is the effective catch-all — the engine
* emits it as route.Final. Mirrors model.IsCatchAll, and the copy in
* Routing.tsx's `isCatchAll`; the three must agree or the warning below fires on
* a router that has a default rule, and a warning that cries wolf is worse than
* no warning.
*
* AN UNMIGRATED RULE IS NEVER A CATCH-ALL, checked first exactly as on the Go
* side (model/reachability.go). A non-empty `LegacyDst` means the destination is
* still written in schema-v1 options the parser no longer reads, so the lack of
* matchers means "the destination is unreadable", not "matches everything" — and
* the daemon holds such a rule disabled. Reading it the other way here would
* suppress this warning on precisely the config that needs it.
*/
export function isCatchAllRule(r: CatchAllRule): boolean {
if ((r.LegacyDst ?? []).length > 0) return false
return (
(r.Src ?? []).length === 0 &&
(r.DstRuleset ?? []).length === 0 &&
!(r.DstPort && r.DstPort.trim()) &&
!(r.Proto && r.Proto.trim())
)
}
/** The inbound fields that decide whether it hands LAN traffic to the engine. */
export interface InterceptingInbound {
Enabled: boolean
Type?: string
}
/**
* Does this inbound actually put LAN traffic in front of the engine?
*
* Only an ENABLED tproxy inbound is wired into the nft TPROXY plane
* (model.IsTproxyInbound); `socks`/`http`/`dokodemo` are local listeners that
* intercept nothing on their own. An absent/empty Type means tproxy
* (model.Inbound.EffectiveType) — which is why the list is written POSITIVELY
* and closed: an open `!== 'socks'` style test would sweep every future inbound
* type into "intercepts everything" and warn about a router that diverts nothing.
*/
export function isInterceptingInbound(i: InterceptingInbound): boolean {
if (!i.Enabled) return false
const t = (i.Type ?? '').trim().toLowerCase()
return t === '' || t === 'tproxy'
}
/** The parts of the desired-state model this question is decided from. An
* `api.Model` is structurally assignable. */
export interface ApplyRiskInput {
Globals: { Enabled: boolean; KillSwitch: string; ConfirmTimeout: number }
Rules?: CatchAllRule[] | null
Inbounds?: InterceptingInbound[] | null
Resolvers?: unknown[] | null
}
/** A predicted outcome of pressing Apply, in the words shown to the operator. */
export interface ApplyRisk {
/** Headline: what this apply does, not what is wrong with the config. */
headline: string
/** The mechanism, in traffic terms — only claims that are certain. */
detail: string
/** What will (or will not) undo it on its own. */
undo: string
/** True when nothing undoes it: no commit-confirm window is configured. */
noAutoRollback: boolean
/** What to change, most important first. Never empty. */
steps: string[]
}
/**
* WILL APPLYING THIS CONFIG BLOCK EVERY DEVICE ON THE NETWORK?
*
* Returns the warning to show, or null when this apply is not that.
*
* Traced through the daemon, and every step of it is reachable from the config
* this package ships:
*
* - nothing rejects an empty config. `model.Validate` is advisory by contract,
* `generate` errors only on a nil model, and the engine starts fine because
* the outbound list always holds the built-in `direct` + `block`.
* - with the kill-switch closed and NO catch-all rule, generate/route.go sets
* `route.final = "block"`.
* - 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.
*
* What survives is LAN-to-LAN, the router itself (so the panel stays reachable),
* and DNS, which at zero configured resolvers leaves for the provider in the
* clear.
*
* MEASURED on the stand, with real LAN clients in netns behind veth and counters
* in a separate nft table: 0 packets out of the WAN across the whole run in this
* state, against 27 in the control that differs only by one added catch-all rule.
* TCP out: none. UDP out: exactly 2 packets, both plaintext UDP/53 — which is why
* `detail` says "nothing ELSE reaches the internet" and not "nothing". The word is
* load-bearing: the band's own DNS step calls that lookup "the only thing that
* still leaves this network", and a band that says both is a band that is wrong
* once whichever half you believe. "takes ... and drops it" is measured too — the
* engine accepts on the local tproxy socket in ~100 µs 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 here says anything about ping, deliberately: the stand's ICMP probe was
* 100% loss in BOTH the dangerous and the control state, so it proved nothing
* either way. The panel already promises nothing about ICMP and must keep not to.
*
* The panel already reads this state PERFECTLY once it exists
* (fullPlaneState → "Nothing is getting out"). The gap this closes is that
* nobody was told BEFORE, and that the shipped `confirm_timeout` is 0 — so the
* most dangerous apply this router will ever do runs with no auto-rollback,
* while the README tells people to set 120 first and the panel never did.
*
* IT DOES NOT BLOCK THE APPLY. It is the operator's router and the config is
* legal; the warning names the outcome and the fix and gets out of the way.
*
* FOUR CONDITIONS, all required, each one a way for the danger to be absent:
* 1. the service will be ON — otherwise no plane is built at all;
* 2. the kill-switch is closed — fail-open leaves unmatched traffic out
* (leaking, which is a different warning's job) rather than dropping it;
* 3. something actually intercepts — with no enabled tproxy inbound nothing is
* diverted to the engine, so nothing it decides can block anything;
* 4. no ENABLED catch-all rule — one is exactly what stops `final` being
* `block`, whatever it points at.
*
* TWO THINGS IT CANNOT SEE, and neither is silently assumed away. An active WAN
* profile can disable the catch-all after the apply (`effective_enabled` on
* /api/rules/reachability is that verdict, and it describes the RUNNING config,
* not the one being staged). A catch-all on a schedule is counted as present
* even outside its window. Both make this warning slightly quieter than reality;
* quieter is the right side to fail on for a predictive warning, because the
* observed state — which is never a guess — is already alarmed about by
* protectionState the moment the apply lands.
*/
export function applyRisk(cfg: ApplyRiskInput | null | undefined): ApplyRisk | null {
if (!cfg?.Globals) return null
const g = cfg.Globals
if (!g.Enabled) return null
if (!killSwitchClosed(g.KillSwitch)) return null
if (!(cfg.Inbounds ?? []).some(isInterceptingInbound)) return null
if ((cfg.Rules ?? []).some((r) => r.Enabled && isCatchAllRule(r))) return null
const window = g.ConfirmTimeout
const noAutoRollback = !(window > 0)
const steps = [
'Add a default rule on the Routing page — one with no conditions — and point it at a group, a node, or direct. That is the rule this router has no answer without.',
]
// Zero resolvers is not what blocks the traffic, so it is not in the headline.
// It IS what happens to the one kind of traffic that still gets out, and this
// is the last screen before that starts, so it is said here rather than found
// later.
//
// IT IS NOT ONLY THE LOOKUPS ADDRESSED TO THE ROUTER, and the earlier wording
// said it was. With the shipped `dns_intercept '1'` a query aimed at a resolver
// the device picked for itself is pulled into the engine too, answered there,
// and leaves in the same clear UDP/53 — measured both ways on the stand, each
// producing its own plaintext packet on the WAN. The generator's own critical
// warning has said this all along (generate/dns.go: "every intercepted query is
// answered by the router's own system resolver ... in the clear"); this line was
// the one that had fallen behind it. Encrypted DNS is not the escape hatch
// either: :853 out of the LAN measured connects=0, because the plan rejects it.
if ((cfg.Resolvers ?? []).length === 0) {
steps.push(
'Add a resolver on the DNS page. No resolver is configured, so every plaintext lookup your devices make — to the router or to a resolver they picked themselves — is answered by the router’s own system resolver and goes out to your provider in the clear. It is the only thing that still leaves this network; encrypted DNS on :853 is refused.',
)
}
if (noAutoRollback) {
steps.push(
'Or set a confirm window first, on the Settings page. 120 seconds is the documented value for a first apply, and it is what gets you back in if this locks you out.',
)
}
return {
headline: 'Applying this cuts every device off the internet',
detail:
'The service is switched on, the kill-switch is closed, and no rule matches what is left over — so the engine takes every TCP connection and UDP flow from your devices and drops it instead of letting it out. Devices can still reach each other and this panel; nothing else reaches the internet.',
undo: noAutoRollback
? 'Nothing will undo this on its own: the confirm window is 0, so the config commits immediately and there is no auto-rollback. If it locks you out, getting back in means SSH.'
: `The confirm window is ${window}s: if you do not keep this config before the timer ends, the router reverts to the last-good one on its own.`,
noAutoRollback,
steps,
}
}
+141
View File
@@ -0,0 +1,141 @@
// What the panel PROMISES the log search does, against what the daemon does.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// The `?mock` backend searches with logRoute.rowMatches over
// logSearchFields/connSearchFields, and the search box tells the operator which
// fields those are. Three copies of one contract — the daemon
// (shater/stats/filter.go), the mock, and the hint text — and they had drifted:
//
// - CONN_SEARCH_HINT named neither `proto` nor the chain hops, both of which
// matchConn searches;
// - rowMatches folded case with `toLowerCase()`, which is Unicode-aware. The
// daemon folds ASCII only (lowerASCII + matchAtFold), and its own non-ASCII
// case in stats/logfilter_test.go TestContainsFold asserts that an upper-case
// non-ASCII needle does NOT match its lower-case haystack. A needle that finds
// rows in the panel and none on the router is the one outcome that makes the
// filter untrustworthy.
//
// The hint assertions are deliberately written as "every searched field is named",
// not as a string comparison, so adding a field to the list fails here until the
// sentence the operator reads is updated with it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ConnLogEntry, QueryLogEntry } from './api.ts'
import {
CONN_SEARCH_HINT,
LOG_SEARCH_HINT,
connSearchFields,
logSearchFields,
rowMatches,
} from './logRoute.ts'
const conn = (over: Partial<ConnLogEntry> = {}): ConnLogEntry => ({
unix: 1_700_000_000,
src_ip: '192.168.1.50',
src_name: 'laptop',
dest: 'youtube.com',
dest_ip: '142.250.74.238',
port: 443,
network: 'tcp',
proto: 'tls',
outbound: 'nl-reality-1',
seq: 7,
rule_kind: 'matched',
rule: 'domain_suffix=youtube.com',
chain: ['nl-reality-1', 'eu-fast'],
...over,
})
const query = (over: Partial<QueryLogEntry> = {}): QueryLogEntry => ({
seq: 1,
time: '13:37:00',
unix: 1_700_000_000,
domain: 'www.youtube.com',
qtype: 'A',
rcode: 0,
blocked: false,
server: 'cloudflare-doh',
action: 'proxy',
device: 'laptop',
outbound: 'nl-reality-1',
outbound_kind: 'detour',
status: 'answered',
error: '',
...over,
})
// ---- the hint names every field the search covers ---------------------------
test('the connection hint names proto, which the daemon has always searched', () => {
assert.equal(rowMatches(connSearchFields(conn()), 'tls'), true, 'proto is not searched')
assert.match(CONN_SEARCH_HINT, /proto/, 'proto is searched but the hint does not say so')
})
test('the connection hint names the chain hops it searches', () => {
assert.equal(rowMatches(connSearchFields(conn()), 'eu-fast'), true, 'chain hops are not searched')
assert.match(CONN_SEARCH_HINT, /path/, 'chain hops are searched but the hint does not say so')
})
test('every other searched connection field is named too', () => {
const named: [string, RegExp][] = [
['laptop', /device/],
['youtube.com', /destination/],
['tcp', /network/],
['nl-reality-1', /exit/],
['domain_suffix', /rule/],
]
for (const [needle, word] of named) {
assert.equal(rowMatches(connSearchFields(conn()), needle), true, `${needle} is not searched`)
assert.match(CONN_SEARCH_HINT, word)
}
})
test('the query hint still names error — the search somebody actually runs', () => {
const failed = query({ status: 'failed', error: 'i/o timeout' })
assert.equal(rowMatches(logSearchFields(failed), 'timeout'), true)
assert.match(LOG_SEARCH_HINT, /error/)
})
// ---- the fold is the daemon's fold, not JavaScript's ------------------------
test('ASCII case folds, on both sides of the comparison', () => {
assert.equal(rowMatches(['WWW.YouTube.COM'], 'youtube'), true)
assert.equal(rowMatches(['www.youtube.com'], 'YOUTUBE'), true)
})
test('non-ASCII does NOT fold — the daemon pins exactly this', () => {
// stats/logfilter_test.go TestContainsFold asserts, on a non-ASCII pair, that the
// exact-case needle matches and the upper-case one does NOT. `toLowerCase()` gets
// the second one wrong, and then the panel finds rows the router never will.
//
// (Written with Latin-1 and Greek letters rather than the daemon's own literals:
// this tree admits no non-Latin script in panel/src, and the property under test
// is "non-ASCII", not "one particular alphabet".)
assert.equal(rowMatches(['strasse-ä'], 'ä'), true, 'the exact-case match broke')
assert.equal(
rowMatches(['strasse-ä'], 'Ä'),
false,
'the panel folded a non-ASCII letter; the daemon does not, so this needle finds nothing on the router',
)
// The other direction, and a second script, so the rule reads as "non-ASCII"
// rather than as one accented character.
assert.equal(rowMatches(['STRASSE-Ä'], 'ä'), false)
assert.equal(rowMatches(['ΘΕΣΗ'], 'θεση'), false)
assert.equal(rowMatches(['θεση'], 'θεση'), true)
})
test('CONTROL: the fold is still a fold — it has not simply become case-sensitive', () => {
// Without this, "the upper-case non-ASCII needle misses" is satisfied by dropping
// case folding altogether, which would break every ordinary search.
assert.equal(rowMatches(['NL-Reality-1'], 'nl-reality'), true)
assert.equal(rowMatches(['nl-reality-1'], 'NL-REALITY'), true)
})
test('no pattern syntax, and an empty needle is “no filter”', () => {
assert.equal(rowMatches(['www.youtube.com'], '.*'), false)
assert.equal(rowMatches(['a.*b'], '.*'), true)
assert.equal(rowMatches(['anything'], ' '), true)
})
+193
View File
@@ -0,0 +1,193 @@
// "Switched off" is not "broken" — and the point of every test here is the
// CONTROL that comes with it.
//
// The defect: a correctly installed package ships `enabled '0'`,
// `kill_switch 'closed'`, nothing applied — so `plane:"none"`,
// `engine_running:false`, `table:false`. Every lamp in the panel was derived from
// what is INSTALLED and none from what was MEANT to be, so that router 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.
//
// Repainting an outage amber would be a worse bug than the one being fixed, so
// every case below is paired: the SAME reading with the service switched ON must
// keep its crit. A test that only shows the amber half proves nothing — the
// instrument has to be shown capable of the positive result.
//
// 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 {
engineReadout,
engineState,
killSwitchReadout,
protectionState,
serviceIntent,
} from './planeState.ts'
import type { Status } from './api.ts'
/**
* EXACTLY WHAT THE PACKAGE SHIPS. `enabled '0'`, `kill_switch 'closed'`, zero
* nodes/rules/resolvers, nothing ever applied — so the daemon is up, the engine
* is not, and there is no data plane.
*/
function freshInstall(over: Partial<Status> = {}): Status {
return {
running: true,
enabled: false,
active: false,
table: false,
hash: '',
version: '1.11.0-shater',
kill_switch: 'closed',
config_readable: true,
engine_running: false,
plane: 'none',
warnings: [],
...over,
}
}
/** The SAME installed reality, with the service switched on. This is the router
* whose engine failed to start: everything below must stay crit for it. */
function engineFailedToStart(over: Partial<Status> = {}): Status {
return freshInstall({ enabled: true, ...over })
}
// --- the predicate ----------------------------------------------------------
test('serviceIntent: off is the operator, unknown is an unreadable config', () => {
assert.equal(serviceIntent(freshInstall()), 'off')
assert.equal(serviceIntent(engineFailedToStart()), 'on')
assert.equal(serviceIntent(null), 'unknown')
// `enabled` is one of the three fields sourced from the config, so it is a
// hard `false` when the config could not be read — precisely when the
// fail-closed plane has the LAN cut off on purpose. That must NOT read as
// "the operator switched it off".
assert.equal(serviceIntent(freshInstall({ config_readable: false })), 'unknown')
// An older daemon that never had the field means what `enabled` says.
assert.equal(serviceIntent(freshInstall({ config_readable: undefined })), 'off')
})
// --- the master lamp's reading ----------------------------------------------
test('engine readout: not started when off, stopped when it should be running', () => {
const off = engineReadout(freshInstall())
assert.equal(off.variant, 'off', 'a service nobody started has not failed')
assert.equal(off.word, 'not started')
// CONTROL. Same engine_running:false, same plane, same table — only the
// operator's switch differs, and the alarm must survive it intact.
const on = engineReadout(engineFailedToStart())
assert.equal(on.variant, 'crit')
assert.equal(on.word, 'stopped')
// CONTROL 2. An unreadable config keeps the crit: `enabled` is meaningless
// there, and that is the state where the LAN really is cut off.
const unreadable = engineReadout(freshInstall({ config_readable: false }))
assert.equal(unreadable.variant, 'crit')
assert.equal(unreadable.word, 'stopped')
})
test('engineState itself is unchanged — the engine really is down either way', () => {
// The severity moved; the FACT did not. Networks.tsx reads this one directly.
assert.equal(engineState(freshInstall()), 'down')
assert.equal(engineState(engineFailedToStart()), 'down')
})
// --- the kill-switch module -------------------------------------------------
test('kill-switch: standby when off, NOT IN EFFECT when it should be guarding', () => {
const off = killSwitchReadout(freshInstall())
assert.equal(off.state, 'standby')
assert.equal(off.variant, 'off', 'nothing failed, so nothing is red')
assert.equal(off.hot, false)
assert.notEqual(off.value, 'NOT IN EFFECT')
// It still says the true thing — nothing is being blocked — and why.
assert.match(off.blockingNow ?? '', /service is off/)
// The configured policy is still reported honestly.
assert.equal(off.setting, 'fail-closed')
assert.equal(off.settingHot, false)
// CONTROL. Identical plane:'none' + kill_switch:'closed'; the service is on,
// so the configured guard is genuinely missing and traffic is leaving raw.
const on = killSwitchReadout(engineFailedToStart())
assert.equal(on.state, 'inert')
assert.equal(on.value, 'NOT IN EFFECT')
assert.equal(on.variant, 'crit')
assert.equal(on.hot, true)
// CONTROL 2. Unreadable config keeps its own, older alarm — it must not have
// been swallowed by the new branch.
const unreadable = killSwitchReadout(freshInstall({ config_readable: false }))
assert.equal(unreadable.state, 'unknown')
assert.equal(unreadable.setting, 'not known')
})
test('kill-switch: an installed plane still reads ARMED even with the service off', () => {
// A stale plane IS still blocking, whatever the config now says. Standby is
// only for "there is no plane and none was asked for".
const stale = killSwitchReadout(freshInstall({ plane: 'hold' }))
assert.equal(stale.state, 'armed')
assert.equal(stale.variant, 'on')
})
test('kill-switch: an unreported plane is still an unlit unknown, not standby', () => {
// Off + "the daemon never said" is not the same as off + "it said none".
// Claiming standby there would be claiming knowledge we do not have.
const r = killSwitchReadout(freshInstall({ plane: undefined }))
assert.equal(r.state, 'unknown')
assert.equal(r.variant, 'off')
})
test('kill-switch: fail-open still wins over standby', () => {
const r = killSwitchReadout(freshInstall({ kill_switch: 'open' }))
assert.equal(r.state, 'open')
assert.equal(r.setting, 'fail-open')
assert.equal(r.settingHot, true)
})
// --- the headline readout ---------------------------------------------------
test('protection state: off is amber and does not raise an alarm', () => {
const off = protectionState(freshInstall())
assert.equal(off.variant, 'amber')
assert.equal(off.alarm, false)
// It is the one line in the product that names the next step. Keep it named.
assert.match(off.detail, /Settings/)
// CONTROL. Service on, no plane, fail-closed: traffic is going out raw.
const on = protectionState(engineFailedToStart())
assert.equal(on.variant, 'crit')
assert.equal(on.alarm, true)
// CONTROL 2. Unreadable config must NOT be reported as "Turned off" — this is
// the ordering bug protectionState was already fixed for, and the switch to
// serviceIntent must not have reintroduced it.
const unreadable = protectionState(freshInstall({ config_readable: false, plane: 'hold' }))
assert.equal(unreadable.variant, 'crit')
assert.equal(unreadable.alarm, true)
assert.doesNotMatch(unreadable.headline, /Turned off/)
})
// --- what the fresh installation adds up to ---------------------------------
test('a correct fresh installation raises no crit anywhere', () => {
const s = freshInstall()
assert.notEqual(engineReadout(s).variant, 'crit')
assert.notEqual(killSwitchReadout(s).variant, 'crit')
assert.notEqual(protectionState(s).variant, 'crit')
assert.equal(protectionState(s).alarm, false)
})
test('the same router with the service on raises crit in all three', () => {
// The control for the test above: the instrument can still produce the
// positive result, so its negative on a fresh install means something.
const s = engineFailedToStart()
assert.equal(engineReadout(s).variant, 'crit')
assert.equal(killSwitchReadout(s).variant, 'crit')
assert.equal(protectionState(s).variant, 'crit')
assert.equal(protectionState(s).alarm, true)
})
+288
View File
@@ -0,0 +1,288 @@
// The subscription options editor's merge.
//
// Run with `npm test` (node's built-in test runner + native type stripping).
// subEdit.ts has no runtime imports, so this runs against the real module.
//
// THE CASE THIS FILE WAS WRITTEN FOR: the form used to render five of the
// subscription's fields and carry the rest by spreading the stored object, with a
// comment saying so. Now that Format, the four filters, dedup, the expiry alert
// and the three device-identity headers all have controls, the spread is the only
// thing still protecting the five PROVIDER-REPORTED counters — quota and expiry
// folded in from the `subscription-userinfo` header and persisted so they survive
// a restart with no refetch, which matters exactly when a refetch is impossible.
// A rebuild would wipe them on the first rename and there would be no way to get
// them back short of a successful fetch.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
SUB_FORMATS,
SUB_PROTOS,
formatExpireAlert,
joinFilterList,
nextSubscription,
parseExpireAlert,
parseFilterList,
proxyFetchGoesDirect,
subToForm,
toHeaderRow,
} from './subEdit.ts'
import type { Subscription } from './api.ts'
/** A live subscription with provider-reported account state on it. */
function stored(over: Partial<Subscription> = {}): Subscription {
return {
Name: 'qomar',
Enabled: true,
URL: 'https://provider.example/sub?token=secret',
UpdateInterval: '6h',
UserUpload: 1234,
UserDownload: 98765,
UserTotal: 107374182400,
UserExpire: 1800000000,
UserInfoAt: 1790000000,
...over,
}
}
// ---- the fields no control shows --------------------------------------------
test('renaming a subscription keeps the provider-reported quota and expiry', () => {
const s = stored()
const out = nextSubscription(s, { ...subToForm(s, 'direct'), Name: 'qomar-main' }, undefined)
assert.equal(out.Name, 'qomar-main')
assert.equal(out.UserUpload, 1234, 'no control shows it, so no save may clear it')
assert.equal(out.UserDownload, 98765)
assert.equal(out.UserTotal, 107374182400)
assert.equal(out.UserExpire, 1800000000)
assert.equal(out.UserInfoAt, 1790000000)
})
test('the stored subscription is never mutated — a failed save leaves it usable', () => {
const s = stored()
nextSubscription(s, { ...subToForm(s, 'direct'), Name: 'other', Dedup: true }, 7)
assert.equal(s.Name, 'qomar')
assert.equal(s.Dedup, undefined)
})
test('a blanked name or url falls back to the stored value', () => {
// Name is the ONLY link to the sub's cached nodes (Node.FromSub); a sub with no
// URL fetches nothing. Neither is a field the form may empty into the config.
const s = stored()
const out = nextSubscription(s, { ...subToForm(s, 'direct'), Name: ' ', URL: '' }, undefined)
assert.equal(out.Name, 'qomar')
assert.equal(out.URL, 'https://provider.example/sub?token=secret')
})
// ---- the filters the form now owns ------------------------------------------
test('the four-Dutch-nodes case: filters round-trip through the form', () => {
// The scenario the controls exist for — a provider hands over 376 nodes and the
// operator wants the four Dutch ones. Every one of these was reachable only by
// editing /etc/config/shater over SSH.
const s = stored({
Include: ['🇳🇱'],
Exclude: ['expired', 'trial'],
FilterProto: ['vless', 'trojan'],
FilterCountry: ['nl', '!ru'],
Dedup: true,
})
const form = subToForm(s, 'direct')
assert.equal(form.Include, '🇳🇱')
assert.equal(form.Exclude, 'expired, trial')
assert.deepEqual(form.FilterProto, ['vless', 'trojan'])
assert.equal(form.FilterCountry, 'nl, !ru')
assert.equal(form.Dedup, true)
const out = nextSubscription(s, form, undefined)
assert.deepEqual(out.Include, ['🇳🇱'])
assert.deepEqual(out.Exclude, ['expired', 'trial'])
assert.deepEqual(out.FilterProto, ['vless', 'trojan'])
assert.deepEqual(out.FilterCountry, ['nl', '!ru'])
assert.equal(out.Dedup, true)
})
test('clearing a filter writes no key at all, not an empty list', () => {
const s = stored({ FilterCountry: ['nl'], Include: ['premium'], FilterProto: ['vless'] })
const out = nextSubscription(
s,
{ ...subToForm(s, 'direct'), FilterCountry: ' ', Include: '', FilterProto: [] },
undefined,
)
assert.equal(out.FilterCountry, undefined)
assert.equal(out.Include, undefined)
assert.equal(out.FilterProto, undefined)
})
test('the protocol list is copied, not aliased into the config', () => {
const s = stored()
const form = subToForm(s, 'direct')
form.FilterProto = ['vless']
const out = nextSubscription(s, form, undefined)
form.FilterProto.push('vmess')
assert.deepEqual(out.FilterProto, ['vless'], 'a later keystroke must not rewrite a saved value')
})
test('dedup off writes nothing rather than a false the daemon already assumes', () => {
const s = stored({ Dedup: true })
const out = nextSubscription(s, { ...subToForm(s, 'direct'), Dedup: false }, undefined)
assert.equal(out.Dedup, undefined)
})
// ---- format ------------------------------------------------------------------
test('a forced parser is stored; auto is the absence of a choice', () => {
const s = stored()
const forced = nextSubscription(s, { ...subToForm(s, 'direct'), Format: 'clash' }, undefined)
assert.equal(forced.Format, 'clash')
const back = nextSubscription(forced, { ...subToForm(forced, 'direct'), Format: 'auto' }, undefined)
assert.equal(back.Format, undefined, '“auto” is the default sniff — writing it stores nothing')
})
test('the format list is exactly the five the daemon accepts', () => {
assert.deepEqual(
SUB_FORMATS.map((f) => f.id),
['auto', 'clash', 'xray', 'singbox', 'links'],
'model.SubFormatNames — a sixth here would be accepted by the panel and rejected by the daemon',
)
})
test('the protocol list matches the group filter’s', () => {
assert.deepEqual(SUB_PROTOS, ['vless', 'vmess', 'trojan', 'ss'])
})
// ---- device identity headers --------------------------------------------------
test('the three device-identity fields round-trip', () => {
// Sent as x-device-os / x-ver-os / x-device-model (subscribe/fetch.go). Some
// providers gate the feed on them, and no control existed for any of the three.
const s = stored({ DeviceOS: 'iOS', VerOS: '17.4', DeviceModel: 'iPhone15,2' })
const form = subToForm(s, 'direct')
assert.equal(form.DeviceOS, 'iOS')
assert.equal(form.VerOS, '17.4')
assert.equal(form.DeviceModel, 'iPhone15,2')
const out = nextSubscription(s, { ...form, VerOS: '17.5' }, undefined)
assert.equal(out.DeviceOS, 'iOS')
assert.equal(out.VerOS, '17.5')
assert.equal(out.DeviceModel, 'iPhone15,2')
})
// ---- expiry alert -------------------------------------------------------------
test('the expiry alert is three-state and the panel does not flatten it', () => {
// model.go: "0/unset = default 3; negative = off". Collapsing "off" into 0 turns
// a deliberate silence back into a warning three days before expiry.
assert.deepEqual(parseExpireAlert(''), { ok: true, value: undefined })
assert.deepEqual(parseExpireAlert(' '), { ok: true, value: undefined })
assert.deepEqual(parseExpireAlert('off'), { ok: true, value: -1 })
assert.deepEqual(parseExpireAlert('OFF'), { ok: true, value: -1 })
assert.deepEqual(parseExpireAlert('never'), { ok: true, value: -1 })
assert.deepEqual(parseExpireAlert('7'), { ok: true, value: 7 })
assert.deepEqual(parseExpireAlert('0'), { ok: true, value: 0 })
})
test('every negative value means the same thing, so it normalises to -1', () => {
assert.deepEqual(parseExpireAlert('-7'), { ok: true, value: -1 })
})
test('a value that is neither a number nor “off” is refused, not guessed at', () => {
const r = parseExpireAlert('soon')
assert.equal(r.ok, false)
if (!r.ok) assert.match(r.error, /number of days/)
assert.equal(parseExpireAlert('3650').ok, true)
assert.equal(parseExpireAlert('3651').ok, false)
})
test('the alert renders back into the field it came from', () => {
assert.equal(formatExpireAlert(undefined), '')
assert.equal(formatExpireAlert(0), '', '0 is “unset” — the field must not claim 0 days')
assert.equal(formatExpireAlert(-1), 'off')
assert.equal(formatExpireAlert(-7), 'off')
assert.equal(formatExpireAlert(14), '14')
})
test('the parsed alert is what gets written, including the explicit off', () => {
const s = stored()
assert.equal(nextSubscription(s, subToForm(s, 'direct'), -1).ExpireAlertDays, -1)
assert.equal(nextSubscription(s, subToForm(s, 'direct'), 14).ExpireAlertDays, 14)
assert.equal(nextSubscription(s, subToForm(s, 'direct'), undefined).ExpireAlertDays, undefined)
})
// ---- "via proxy" that isn't ---------------------------------------------------
test('proxy with no detour is direct, and the panel has to know it', () => {
// engine.ViaToTag("") returns the tag `direct`. So a subscription set to
// FetchVia=proxy with no FetchDetour fetches over the plain WAN — the provider
// logs the router's real address, which is the single thing `proxy` is chosen
// to prevent. The update still succeeds, so nothing on screen says otherwise
// unless this predicate says it.
assert.equal(proxyFetchGoesDirect({ FetchVia: 'proxy' }), true)
assert.equal(proxyFetchGoesDirect({ FetchVia: 'proxy', FetchDetour: '' }), true)
assert.equal(proxyFetchGoesDirect({ FetchVia: 'proxy', FetchDetour: ' ' }), true)
assert.equal(
proxyFetchGoesDirect({ FetchVia: 'proxy', FetchDetour: 'direct' }),
true,
'spelling it out resolves to the same tag — it is not more hidden for being explicit',
)
assert.equal(proxyFetchGoesDirect({ FetchVia: 'proxy', FetchDetour: 'DIRECT' }), true)
})
test('a real route is a real route, and direct fetching is not the warning', () => {
for (const d of ['group:auto', 'node:nl-1', 'egress:wg0', 'chain:double']) {
assert.equal(proxyFetchGoesDirect({ FetchVia: 'proxy', FetchDetour: d }), false, d)
}
// FetchVia=direct is an honest choice, not a broken one — it must not warn.
assert.equal(proxyFetchGoesDirect({ FetchVia: 'direct' }), false)
assert.equal(proxyFetchGoesDirect({}), false)
})
test('a saved proxy sub with the picker on Direct trips the predicate', () => {
// The end-to-end shape: the picker defaults to `direct`, the merge writes
// FetchDetour: undefined for it, and the result must still be recognised.
const s = stored({ FetchVia: 'proxy' })
const out = nextSubscription(
s,
{ ...subToForm(s, 'direct'), FetchVia: 'proxy', FetchDetour: 'direct' },
undefined,
)
assert.equal(out.FetchVia, 'proxy')
assert.equal(out.FetchDetour, undefined)
assert.equal(proxyFetchGoesDirect(out), true)
})
// ---- list + header parsing ----------------------------------------------------
test('filter lists split on commas and newlines, trimmed and deduped', () => {
assert.deepEqual(parseFilterList('nl, de\n!ru , nl'), ['nl', 'de', '!ru'])
assert.deepEqual(parseFilterList(' '), [])
assert.equal(joinFilterList(['nl', 'de']), 'nl, de')
assert.equal(joinFilterList(null), '')
})
test('a header value containing a colon survives the split', () => {
assert.deepEqual(toHeaderRow('X-Ref: https://a.test/x'), {
key: 'X-Ref',
value: 'https://a.test/x',
})
assert.deepEqual(toHeaderRow('Bare'), { key: 'Bare', value: '' })
})
test('a header row with no name is dropped instead of serialising a nameless header', () => {
const s = stored()
const out = nextSubscription(
s,
{
...subToForm(s, 'direct'),
Headers: [
{ key: 'X-Api-Key', value: 'abc' },
{ key: ' ', value: 'orphan' },
],
},
undefined,
)
assert.deepEqual(out.Headers, ['X-Api-Key: abc'])
})
+249
View File
@@ -0,0 +1,249 @@
import type { Subscription } from './api'
/**
* The subscription options editor's data half: the closed value lists, the
* expiry-alert parser, and the one function that folds the form back onto a
* stored subscription.
*
* Outside `pages/Nodes.tsx` because it is the part that must be TESTED, and the
* panel's runner is `node --test src/*.test.ts` — plain modules, no JSX, no DOM.
*
* # Why this one EXTENDS instead of rebuilding
*
* `dnsListEdit.ts` rebuilds its shapes and returns `Complete<T>`, because the
* editor there renders a control for every field. A subscription is different:
* five of its fields are not settings at all. `UserUpload`, `UserDownload`,
* `UserTotal`, `UserExpire` and `UserInfoAt` are PROVIDER-REPORTED STATE, folded
* in from the `subscription-userinfo` response header and persisted so quota and
* expiry survive a daemon restart with no refetch — which matters most exactly
* when a refetch is impossible (sub expired, tunnel down). No control can or
* should show them, so the form must not be in a position to clear them.
*
* So `nextSubscription` spreads the stored sub first and overwrites only what the
* form owns. A field added to `Subscription` later survives an edit untouched
* rather than being silently dropped — the failure this pattern is here to stop.
*/
/** The parsers `option format` accepts (model.SubFormatNames), in menu order.
*
* A CLOSED list: the daemon takes exactly these five, and `auto` sniffs the body.
* The other four FORCE a parser, which is what rescues a feed the sniffer reads
* wrong — and a forced parser that yields zero nodes falls back to the sniff
* rather than emptying a working cache, so a wrong guess here costs nothing. */
export const SUB_FORMATS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'auto', label: 'Auto — sniff the body' },
{ id: 'clash', label: 'Clash (YAML)' },
{ id: 'xray', label: 'Xray / V2Ray JSON' },
{ id: 'singbox', label: 'sing-box JSON' },
{ id: 'links', label: 'Share links (one per line)' },
]
/** The protocols `FilterProto` recognises — the same four the group filter offers
* (Targets.tsx PROTOS), because they are one list in the daemon. */
export const SUB_PROTOS: ReadonlyArray<string> = ['vless', 'vmess', 'trojan', 'ss']
/** Split a comma/newline list into a deduped, trimmed array. Mirrors the group
* filter's `parseList` so the two forms accept exactly the same input. */
export function parseFilterList(text: string): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of text.split(/[\n,]+/)) {
const v = raw.trim()
if (!v || seen.has(v)) continue
seen.add(v)
out.push(v)
}
return out
}
/** Render a stored filter list back into the text control. */
export const joinFilterList = (a: string[] | null | undefined): string => (a ? a.join(', ') : '')
export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string }
/**
* True when this subscription SAYS it fetches through the tunnel and does not.
*
* `FetchVia=proxy` exists for exactly one reason: so the subscription provider,
* and every hop to it, does not learn the router's real address. But the route is
* named by the SEPARATE `FetchDetour` field, and an empty one is not "no
* preference" — `engine.ViaToTag("")` returns the tag `direct`
* (engine/httpclient.go), so the fetch leaves over the plain WAN. It is still
* dialled from inside the daemon process, which is why it looks like it worked:
* the update succeeds, the nodes arrive, and the address the provider logged is
* the one `proxy` was chosen to hide.
*
* So `proxy` + no detour is not a partial configuration, it is `direct` wearing
* another word — and the panel must not draw it as protection. This is the
* predicate the row badge and the editor warning both read, so the two cannot
* disagree about it.
*
* `direct` spelled out explicitly counts the same as empty: it resolves to the
* same tag, and picking it deliberately does not make the address any more hidden.
*/
export function proxyFetchGoesDirect(s: {
FetchVia?: string
FetchDetour?: string
}): boolean {
if ((s.FetchVia ?? '').trim().toLowerCase() !== 'proxy') return false
const d = (s.FetchDetour ?? '').trim()
return d === '' || d.toLowerCase() === 'direct'
}
/** Words that turn the expiry alert off, matching what the field's hint offers. */
const ALERT_OFF_RE = /^(off|never|no|none)$/i
/**
* Parse the expiry-alert field into `ExpireAlertDays`.
*
* The stored field is a THREE-STATE int and the panel must not flatten it
* (model.go: "0/unset = default 3; negative = off"):
*
* blank ⇒ `undefined` — the key is not written, so the daemon uses its
* 3-day default. Distinct from 0 only in the config file; identical
* in behaviour, and leaving it unwritten is the honest form of
* "I did not choose".
* off ⇒ `-1`, an explicit "never warn me".
* a number ⇒ that many days.
*
* A negative number typed by hand is accepted and normalised to -1: every
* negative value means the same thing to the daemon, and echoing `-7` back would
* suggest the sign carried information.
*/
export function parseExpireAlert(raw: string): ParseResult<number | undefined> {
const s = raw.trim()
if (s === '') return { ok: true, value: undefined }
if (ALERT_OFF_RE.test(s)) return { ok: true, value: -1 }
if (!/^-?\d+$/.test(s)) {
return { ok: false, error: 'Enter a number of days, or “off”.' }
}
const n = Number(s)
if (!Number.isSafeInteger(n)) return { ok: false, error: 'Enter a number of days, or “off”.' }
if (n < 0) return { ok: true, value: -1 }
if (n > 3650) return { ok: false, error: 'Use 0–3650 days, or “off”.' }
return { ok: true, value: n }
}
/** Render a stored `ExpireAlertDays` back into the field. */
export function formatExpireAlert(days: number | undefined): string {
if (days === undefined || days === 0) return ''
return days < 0 ? 'off' : String(days)
}
/** One extra request header, as the key/value rows hold it. */
export interface HeaderRow {
key: string
value: string
}
/** Flat, all-strings shape the subscription options form binds to. */
export interface SubForm {
Name: string
URL: string
UpdateInterval: string
FetchVia: string
/** Canonical: `direct` | `group:x` | `node:x` | `egress:x` | `chain:x`. */
FetchDetour: string
UA: string
HWID: string
DeviceOS: string
VerOS: string
DeviceModel: string
Headers: HeaderRow[]
Format: string
Include: string
Exclude: string
FilterProto: string[]
FilterCountry: string
Dedup: boolean
/** Raw text; run through {@link parseExpireAlert} before saving. */
ExpireAlertDays: string
}
/** Seed the form from a stored subscription. `canonDetour` is supplied by the
* caller, which owns the live catalog a bare legacy name resolves against. */
export function subToForm(s: Subscription, canonDetour: string): SubForm {
return {
Name: s.Name,
URL: s.URL,
UpdateInterval: s.UpdateInterval ?? '',
FetchVia: s.FetchVia === 'proxy' ? 'proxy' : 'direct',
FetchDetour: canonDetour,
UA: s.UA ?? '',
HWID: s.HWID ?? '',
DeviceOS: s.DeviceOS ?? '',
VerOS: s.VerOS ?? '',
DeviceModel: s.DeviceModel ?? '',
Headers: (s.Headers ?? []).map(toHeaderRow),
Format: s.Format ?? '',
Include: joinFilterList(s.Include),
Exclude: joinFilterList(s.Exclude),
FilterProto: s.FilterProto ?? [],
FilterCountry: joinFilterList(s.FilterCountry),
Dedup: s.Dedup ?? false,
ExpireAlertDays: formatExpireAlert(s.ExpireAlertDays),
}
}
/** Split a raw `"Key: value"` header into its two halves. Everything after the
* first colon is the value, so a value containing `:` survives a round-trip. */
export function toHeaderRow(raw: string): HeaderRow {
const i = raw.indexOf(':')
if (i === -1) return { key: raw.trim(), value: '' }
return { key: raw.slice(0, i).trim(), value: raw.slice(i + 1).trim() }
}
const str = (v: string): string | undefined => (v.trim() ? v.trim() : undefined)
/**
* Fold the form back onto the stored subscription.
*
* `base` is spread first and never mutated: the caller keeps a usable object if
* the save fails, and every field this form does not own — the five
* provider-reported counters above all — rides through untouched.
*
* Empty controls collapse to `undefined` so the written config stays lean, which
* is also how a filter gets CLEARED: emptying the country box writes no
* `filter_country` at all rather than an empty list.
*
* A blanked Name or URL falls back to the stored value instead of wiping the
* subscription — Name is the only link to its cached nodes (`Node.FromSub`), and
* a sub with no URL fetches nothing.
*/
export function nextSubscription(
base: Subscription,
f: SubForm,
expireAlertDays: number | undefined,
): Subscription {
const proxy = f.FetchVia === 'proxy'
const headers = f.Headers.filter((h) => h.key.trim()).map(
(h) => `${h.key.trim()}: ${h.value.trim()}`,
)
const include = parseFilterList(f.Include)
const exclude = parseFilterList(f.Exclude)
const country = parseFilterList(f.FilterCountry)
return {
...base,
Name: f.Name.trim() || base.Name,
URL: f.URL.trim() || base.URL,
UpdateInterval: str(f.UpdateInterval),
FetchVia: proxy ? 'proxy' : undefined,
// The detour only rides along when fetching via proxy and it isn't plain Direct.
FetchDetour: proxy && f.FetchDetour !== 'direct' ? f.FetchDetour : undefined,
UA: str(f.UA),
HWID: str(f.HWID),
DeviceOS: str(f.DeviceOS),
VerOS: str(f.VerOS),
DeviceModel: str(f.DeviceModel),
Headers: headers.length ? headers : undefined,
// `auto` is the default and means "sniff" — writing it would only persist the
// absence of a choice.
Format: f.Format && f.Format !== 'auto' ? f.Format : undefined,
Include: include.length ? include : undefined,
Exclude: exclude.length ? exclude : undefined,
FilterProto: f.FilterProto.length ? [...f.FilterProto] : undefined,
FilterCountry: country.length ? country : undefined,
Dedup: f.Dedup ? true : undefined,
ExpireAlertDays: expireAlertDays,
}
}
+255
View File
@@ -0,0 +1,255 @@
// One badge for the fetch route — the panel's prediction and the daemon's
// verdict, reconciled.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THESE PROTECT, in the order they matter:
//
// 1. NEVER TWO BADGES FOR ONE FACT AT TWO SEVERITIES. A saved subscription
// that leaks used to wear an amber "proxy · no route" from the panel's own
// predicate AND a red critical finding underneath saying the same thing.
// A reader takes that as the product disagreeing with itself.
// 2. THE AGREEMENT IS CHECKED BOTH WAYS. The dangerous direction is not the
// loud one: it is a row where the PANEL is content — a tidy `via wg-exit`
// badge — while the daemon reports the leak. Silence from the predicate is
// not evidence, and the finding wins there too.
// 3. CONTROL: A HEALTHY SUBSCRIPTION IS NOT MARKED. A rule that flags
// everything is not a rule. The clean row must come back with no warning
// tone and no "flagged" leftovers.
// 4. THE TWO FETCH FINDINGS ARE DIFFERENT FINDINGS. A leak (critical) and a
// detour that resolves to nothing (warning) call for different actions —
// one discloses your address, the other stops the feed refreshing.
// 5. THE DAEMON'S OWN BOUNDS. A disabled subscription is never fetched, so it
// discloses nothing yet; the daemon skips it deliberately and the panel may
// not announce a leak the daemon knows is not happening.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { StatusWarning, Subscription } from './api.ts'
import { fetchBadge, fetchFindingRoute, isFetchFinding, loudest, otherFindings } from './subFetch.ts'
const sub = (over: Partial<Subscription> = {}): Subscription => ({
Name: 'qomar',
Enabled: true,
URL: 'https://provider.example/sub?token=x',
...over,
})
/** The daemon's own critical finding, abridged but keeping its shape. */
const LEAK: StatusWarning = {
severity: 'critical',
section: 'subscription',
name: 'qomar',
message:
'`fetch_via` is "proxy" and no detour is set, which reads as configured and is not: an empty detour resolves to the `direct` outbound. So this subscription\'s feed is fetched over your ordinary WAN: the provider that serves it sees your router\'s real IP address…',
}
/** The other fetch finding: a detour that names nothing. Refuses, does not leak. */
const BROKEN_DETOUR: StatusWarning = {
severity: 'warning',
section: 'subscription',
name: 'qomar',
message:
'`fetch_via` is "proxy" and the detour is "ghost", but this configuration defines no group named "ghost".',
}
/** A finding about something else entirely on the same row. */
const FORMAT_NOTE: StatusWarning = {
severity: 'warning',
section: 'subscription',
name: 'qomar',
message: 'format "clash-meta" is not a parser this build has, so the body is auto-detected instead',
}
// --- 1. one fact, one badge ---------------------------------------------------
test('a leaking APPLIED subscription wears the daemon’s severity, not the prediction’s', () => {
const s = sub({ FetchVia: 'proxy', FetchDetour: '' })
const b = fetchBadge(s, [LEAK])
assert.ok(b)
assert.equal(
b.route,
'leak-live',
'the daemon reported this leak on the APPLIED config — the draft prediction must not stand in for it',
)
assert.equal(b.tone, 'crit', 'the daemon graded this critical; amber would understate it')
// …and it is NOT the draft prediction, which is the badge that used to sit
// beside the red finding saying the same thing more quietly.
assert.notEqual(b.route, 'leak-draft')
})
test('the badge does not restate the finding — the row prints that underneath', () => {
const b = fetchBadge(sub({ FetchVia: 'proxy', FetchDetour: '' }), [LEAK])
assert.ok(b)
assert.doesNotMatch(b.title, /empty detour resolves/, 'that sentence belongs to the finding')
assert.match(b.title, /finding is below/i)
})
test('the fetch finding is not counted a second time as a generic "flagged"', () => {
const rest = otherFindings([LEAK])
assert.deepEqual(rest, [], 'the badge already speaks for it')
// A finding about something else still gets its marker.
assert.deepEqual(otherFindings([LEAK, FORMAT_NOTE]), [FORMAT_NOTE])
})
test('a leftover marker carries the severity its findings really have', () => {
assert.equal(loudest([FORMAT_NOTE]), 'warning')
assert.equal(loudest([FORMAT_NOTE, { ...LEAK, message: 'unrelated critical' }]), 'critical')
assert.equal(loudest([]), 'info')
})
// --- 2. the agreement, in the direction that is easy to miss ------------------
test('the panel may not say "safely routed" while the daemon reports the leak', () => {
// The predicate is CONTENT here: a detour is named, so proxyFetchGoesDirect is
// false and the old code drew a plain, reassuring `via wg-exit`.
const s = sub({ FetchVia: 'proxy', FetchDetour: 'group:auto' })
const quiet = fetchBadge(s, [])
assert.equal(quiet?.route, 'routed')
assert.equal(quiet?.tone, 'none')
// Same row, daemon disagrees. The reassurance must not survive.
const b = fetchBadge(s, [LEAK])
assert.equal(
b?.route,
'leak-live',
'the panel’s silence is not evidence — a daemon finding must overturn a content predicate',
)
assert.equal(b?.tone, 'crit', 'a leaking row may not keep the plain, reassuring badge')
assert.notEqual(b?.route, quiet?.route, 'a finding must be able to overturn the prediction')
})
test('with no finding the prediction still speaks — for the draft that has not been applied', () => {
const b = fetchBadge(sub({ FetchVia: 'proxy', FetchDetour: '' }), [])
assert.equal(b?.route, 'leak-draft')
assert.equal(b?.tone, 'warn')
assert.match(b?.title ?? '', /will go out over the plain WAN/)
})
// --- 3. CONTROL: the healthy row beside the leaking one -----------------------
test('CONTROL: a healthy subscription next to a leaking one is not marked', () => {
const healthy = sub({ Name: 'clean', FetchVia: 'proxy', FetchDetour: 'chain:ewan' })
// Findings are routed per row, so the leaking sibling's finding is not here.
const b = fetchBadge(healthy, [])
assert.equal(b?.route, 'routed')
assert.equal(b?.tone, 'none', 'no warning tone on a row nothing is wrong with')
assert.equal(otherFindings([]).length, 0, 'and nothing left to flag')
// A direct-fetch subscription makes no tunnel claim at all: no badge.
assert.equal(fetchBadge(sub({ FetchVia: 'direct' }), []), null)
assert.equal(fetchBadge(sub({}), []), null)
})
// --- 4. the two fetch findings are different findings -------------------------
test('a detour that resolves to nothing is a refusal, not a disclosure', () => {
const b = fetchBadge(sub({ FetchVia: 'proxy', FetchDetour: 'ghost' }), [BROKEN_DETOUR])
assert.equal(b?.route, 'route-broken')
assert.equal(b?.tone, 'warn', 'nothing escaped — the fetch is refused by name')
assert.match(b?.label ?? '', /ghost/, 'name the detour that is missing')
assert.match(b?.title ?? '', /rather than sent out in the clear/)
assert.notEqual(b?.tone, 'crit')
})
test('severity is what tells the two fetch findings apart, not their wording', () => {
assert.equal(fetchFindingRoute([LEAK]), 'leak-live')
assert.equal(fetchFindingRoute([BROKEN_DETOUR]), 'route-broken')
assert.equal(fetchFindingRoute([FORMAT_NOTE]), null, 'not a route verdict at all')
assert.equal(fetchFindingRoute([]), null)
// A row carrying both: the disclosure outranks the refusal.
assert.equal(fetchFindingRoute([BROKEN_DETOUR, LEAK]), 'leak-live')
})
test('a finding is recognised by the daemon’s own option name', () => {
assert.equal(isFetchFinding(LEAK), true)
assert.equal(isFetchFinding(BROKEN_DETOUR), true)
assert.equal(isFetchFinding(FORMAT_NOTE), false)
})
// --- 5. the daemon's own bounds ------------------------------------------------
test('a subscription with NO URL is not accused of leaking — nothing can be requested', () => {
// The one bound that holds all the way down: subscribe/fetch.go refuses an
// empty URL before it builds a request, so no address of this router leaves.
const b = fetchBadge(sub({ URL: '', FetchVia: 'proxy', FetchDetour: '' }), [])
assert.equal(b?.route, 'unfetchable')
assert.equal(b?.tone, 'none', 'there is genuinely nothing to disclose')
assert.match(b?.title ?? '', /nothing to request/)
})
test('being SWITCHED OFF is not a bound on fetching, and the badge no longer says it is', () => {
// THE DEFECT. This used to answer `leak-idle`, tone `none`, "Nothing is
// disclosed yet — this subscription is switched off … so it is never fetched".
// That is false: apply/apply.go resolves a subscription by NAME and never reads
// Enabled, shaterd's `sub update` consults Enabled only for the unnamed
// "update all", and this panel's own "Fetch now" button sits on every row
// gated on `busy || fetching` alone. Off + proxy + no detour was one click from
// sending the router's real address to the feed host, under a badge that said
// nothing was disclosed.
const b = fetchBadge(sub({ Enabled: false, FetchVia: 'proxy', FetchDetour: '' }), [])
assert.equal(b?.route, 'leak-paused')
assert.equal(b?.tone, 'warn', 'a quiet badge here is the reassurance that made it dangerous')
assert.doesNotMatch(
b?.title ?? '',
/never fetched|Nothing is disclosed/,
'the panel may not claim a request is impossible while a button on the same row makes it',
)
assert.match(b?.title ?? '', /plain WAN/, 'say what actually happens')
assert.match(b?.title ?? '', /stops the scheduled refresh/, 'and what the switch really does')
})
test('CONTROL: the two are told apart, and off is not simply drawn as enabled', () => {
// Without this, "off is warn" would also be satisfied by collapsing every case
// into one amber badge — which would lose the URL-less state that IS safe, and
// lose the difference between "leaking on a timer" and "leaking if you press
// the button".
const off = fetchBadge(sub({ Enabled: false, FetchVia: 'proxy', FetchDetour: '' }), [])
const noUrl = fetchBadge(sub({ URL: '', FetchVia: 'proxy', FetchDetour: '' }), [])
const live = fetchBadge(sub({ Enabled: true, FetchVia: 'proxy', FetchDetour: '' }), [])
assert.notEqual(off?.tone, noUrl?.tone, 'no URL is genuinely quiet; switched off is not')
assert.equal(live?.route, 'leak-draft')
assert.notEqual(off?.route, live?.route, 'the scheduled refresh really is stopped by the switch')
assert.notEqual(off?.title, live?.title)
// …and neither of the two leaking states is drawn quiet.
for (const b of [off, live]) assert.notEqual(b?.tone, 'none')
})
test('the daemon still outranks all of it, on a switched-off row too', () => {
// A finding about the applied configuration wins wherever it exists — the rule
// this module was built on, and the new branch must not have carved a hole in
// it. The daemon now FILES this finding for a disabled subscription (it used to
// skip it on the same false premise the badge carried).
const b = fetchBadge(sub({ Enabled: false, FetchVia: 'proxy', FetchDetour: '' }), [LEAK])
assert.equal(b?.route, 'leak-live')
assert.equal(b?.tone, 'crit')
})
test('the applied badge does not promise a schedule that is switched off', () => {
// apply/warnings.go varies its own sentence on Enabled — an enabled feed leaks
// on every scheduled refresh, a disabled one leaks when a human asks. This
// one-line summary sits directly above that sentence and must not contradict
// it: "on every scheduled refresh" over a switched-off row sends the reader
// looking for a cron job that is not running.
const on = fetchBadge(sub({ Enabled: true, FetchVia: 'proxy', FetchDetour: '' }), [LEAK])
const off = fetchBadge(sub({ Enabled: false, FetchVia: 'proxy', FetchDetour: '' }), [LEAK])
assert.match(on?.title ?? '', /every scheduled refresh/)
assert.doesNotMatch(off?.title ?? '', /on every scheduled refresh/)
assert.match(off?.title ?? '', /switched off/)
assert.match(off?.title ?? '', /does not block a fetch you start yourself/)
// CONTROL: both still say the disclosure happens, and both stay crit — the
// correction is to the WHEN, never to the WHETHER.
for (const b of [on, off]) {
assert.equal(b?.tone, 'crit')
assert.match(b?.title ?? '', /ordinary WAN/)
}
})
test('an explicit “direct” detour is the same disclosure as none', () => {
// subEdit writes a chosen Direct out as "", and the daemon treats both alike.
for (const d of ['direct', 'DIRECT', ' ']) {
const b = fetchBadge(sub({ FetchVia: 'proxy', FetchDetour: d }), [])
assert.equal(b?.route, 'leak-draft', JSON.stringify(d))
}
})
+255
View File
@@ -0,0 +1,255 @@
// One verdict for "where does this subscription's feed actually travel", built
// from BOTH instruments the panel has, so the row can never show two.
//
// THE COLLISION THIS RESOLVES. The panel has a predicate over the draft
// (subEdit.proxyFetchGoesDirect: `fetch_via=proxy` with no detour resolves to
// the `direct` outbound), and it drew an amber badge from it. The daemon now
// publishes the SAME fact as a `critical` finding on the same row
// (apply/warnings.go subscriptionFetchWarnings), because what the panel predicts
// about a draft, the daemon knows about the configuration it actually applied.
//
// Left alone, a saved leaking subscription wore an amber badge saying one thing
// and a red sentence saying the same thing harder. A reader takes that as the
// product disagreeing with itself and stops believing either half — which costs
// more than the finding is worth.
//
// THE RULE: the daemon outranks the prediction wherever the daemon has spoken.
//
// - a finding exists ⇒ the badge carries the daemon's severity and the daemon
// owns the sentence. The prediction is not drawn a second time.
// - no finding ⇒ the prediction is drawn, as a prediction: this is what
// the configuration WILL do once it is applied. That is the draft case, and
// it is the reason the predicate exists at all.
//
// AND IT IS CHECKED IN BOTH DIRECTIONS. If the predicate says "safely routed"
// while the daemon reports the leak, the reassuring `via <detour>` badge is
// exactly the lie this module removes, so the finding wins there too. Silence
// from the panel's own predicate is not evidence of anything.
//
// WHAT "NEVER FETCHED" IS WORTH, AND WHAT IT IS NOT.
//
// This module used to bound itself the way the daemon's warning does: a
// subscription switched off OR with no URL was called "never fetched", drawn
// quiet, and given the sentence "Nothing is disclosed yet". Half of that is
// false, and the false half is the dangerous one.
//
// Read down the fetch path and the two halves come apart:
//
// NO URL — real, and refused at the bottom. subscribe/fetch.go's
// fetchWithHeader returns `subscription %q has no url` before it constructs
// a request. Nothing can leave. This branch keeps its quiet badge.
//
// SWITCHED OFF — NOT a bound on fetching at all. apply/apply.go finds the
// subscription BY NAME and never reads Enabled; shaterd's `sub update`
// consults Enabled only when no name was given (it is the filter for "update
// all", not a gate on one). So a fetch of a named, disabled subscription runs
// exactly like an enabled one — and this panel's own "Fetch now", which sits
// on every subscription row, is disabled on `busy || fetching` and on nothing
// else. Off plus `fetch_via=proxy` plus no detour is a button that sends the
// router's real address to the feed host, under a badge that said nothing was
// disclosed.
//
// So the switch now says only what it does: it stops the SCHEDULED refresh. The
// panel does not claim a request is impossible unless the code refuses it.
//
// The daemon reached the same conclusion from its own side: apply/warnings.go no
// longer skips a disabled subscription, and the behaviour was deliberately KEPT
// — an explicit, operator-initiated fetch of a switched-off subscription goes
// through, and is logged. Its finding varies its own sentence on `Enabled`: an
// enabled feed leaks on every scheduled refresh, a disabled one leaks whenever a
// human asks and at no other time. Where this module writes its own summary over
// that finding, it varies the same way — a badge promising a schedule that is
// switched off is the same defect inverted, and sends the reader hunting a cron
// job that is not running.
import type { StatusWarning, Subscription } from './api'
// Explicit extension: this module is exercised by `npm test` under node's own
// loader, which does not do bundler-style extension guessing. Same reason as
// intercept.ts's import of planeState.
import { proxyFetchGoesDirect } from './subEdit.ts'
/**
* What the row should say about the fetch route. A CLOSED set — the caller
* switches on it exhaustively and there is no `default:` to fall into.
*
* silent — `fetch_via` is not `proxy`: this row makes no claim about a
* tunnel, so there is nothing to reconcile and nothing to draw.
* leak-live — the DAEMON says this configuration fetches over the plain WAN
* with the router's real address, and the fetch reports success
* while it does. WHEN depends on the switch: every scheduled
* refresh while the subscription is on, every hand-started fetch
* while it is off.
* leak-draft — the PANEL's predicate over the current config says the same
* will happen, and the daemon has not (yet) said so: unapplied.
* leak-paused — the predicate matches and the subscription is SWITCHED OFF.
* The switch stops the SCHEDULED refresh and nothing else: it is
* not what keeps the request off the plain WAN. See the note on
* `unfetchable` below for why these two stopped being one state.
* unfetchable — there is no URL. Nothing can be requested, so nothing can be
* disclosed — the one branch here that is genuinely quiet, and
* the only one verified all the way down (subscribe/fetch.go
* refuses an empty URL before it builds a request).
* route-broken— a detour is named and names nothing. This does NOT leak: the
* fetch is refused by name, so the cost is a feed that stops
* refreshing while its cached nodes keep working.
* routed — a detour is named, and neither instrument has anything against
* it.
*/
export type FetchRoute =
| 'silent'
| 'leak-live'
| 'leak-draft'
| 'leak-paused'
| 'unfetchable'
| 'route-broken'
| 'routed'
/** How the badge is drawn. `warn` is amber, `crit` red, `none` the plain badge. */
export type FetchTone = 'none' | 'warn' | 'crit'
export interface FetchBadge {
route: FetchRoute
label: string
tone: FetchTone
/** The hover/assistive sentence. When the daemon has spoken it is the daemon's
* own text — the row prints the full finding underneath anyway, and two
* paraphrases of one fact is how a screen starts sounding unsure. */
title: string
}
/**
* Does this finding describe the FETCH ROUTE of a subscription?
*
* Matched on the daemon's own option name, which appears in both fetch findings
* (`subFetchDirectMessage`, `subFetchDetourFinding`) and in no other finding
* filed under this section — the only other one is `ValidateSubscriptions`'
* note about an unknown `format`. Matching the token rather than a sentence
* survives a reword; matching a sentence would go quiet on the day someone
* improves the prose, and going quiet here restores the amber-over-red collision.
*/
export const isFetchFinding = (w: StatusWarning): boolean => w.message.includes('fetch_via')
/**
* The daemon's verdict about the fetch route, if it gave one.
*
* SEVERITY IS THE DISCRIMINATOR, and it is the daemon's, not a re-reading of its
* prose: `critical` is reserved for "configured protection is not in effect",
* which for this setting is the disclosure itself; a broken detour is graded
* `warning` precisely because it refuses rather than leaks. Anything else is not
* a route verdict.
*/
export function fetchFindingRoute(findings: StatusWarning[]): FetchRoute | null {
const fetchOnes = findings.filter(isFetchFinding)
if (fetchOnes.some((w) => w.severity === 'critical')) return 'leak-live'
if (fetchOnes.length > 0) return 'route-broken'
return null
}
/** The findings this badge does NOT already speak for — what is left for a
* generic "flagged" marker, so one fact is never counted twice at two
* different severities. */
export const otherFindings = (findings: StatusWarning[]): StatusWarning[] =>
findings.filter((w) => !isFetchFinding(w))
/** The loudest severity in a set, for a badge that stands in for several
* findings. Positive and closed: an unrecognised severity does not become the
* quiet one. */
export function loudest(findings: StatusWarning[]): 'critical' | 'warning' | 'info' {
if (findings.some((w) => w.severity === 'critical')) return 'critical'
if (findings.some((w) => w.severity === 'warning')) return 'warning'
return 'info'
}
/**
* Is there anything to request? The ONE precondition this panel can follow all
* the way down to a refusal: subscribe/fetch.go rejects an empty URL before it
* builds a request, so no address of this router reaches anybody.
*
* Deliberately NOT `sub.Enabled && …`, which is what it used to be. That read
* the daemon's warning-skip as if it were a gate on fetching; it is not one (see
* the file header), and treating it as one is what let a switched-off
* subscription carry a badge saying nothing was disclosed while a button on the
* same row disclosed it.
*/
const hasURL = (sub: Subscription): boolean => (sub.URL ?? '').trim() !== ''
/**
* The one badge for this row, or null when there is nothing to say.
*
* `findings` must already be scoped to THIS subscription (Nodes.tsx routes them
* by `section`/`name`), because a finding about another row would otherwise
* silence or escalate this one.
*/
export function fetchBadge(sub: Subscription, findings: StatusWarning[]): FetchBadge | null {
const detour = (sub.FetchDetour ?? '').trim()
const daemon = fetchFindingRoute(findings)
// The daemon has spoken about the applied configuration. It outranks the
// prediction in BOTH directions — including when the prediction is content.
if (daemon === 'leak-live') {
return {
route: 'leak-live',
label: 'fetched in the clear',
tone: 'crit',
// WHEN it happens depends on the switch, and the daemon's own finding now
// says which — so this one-line summary must not contradict the sentence
// printed directly under it. "On every scheduled refresh" over a
// switched-off subscription would send the reader hunting a cron job that
// is not running, which is the same defect as the old badge, inverted.
title: sub.Enabled
? 'The last apply reported that this feed is fetched over the ordinary WAN with this router’s real address, on every scheduled refresh — the disclosure “via Proxy” is chosen to prevent. The full finding is below.'
: 'The last apply reported that this feed is fetched over the ordinary WAN with this router’s real address. This subscription is switched off, so no scheduled refresh touches it — but the switch does not block a fetch you start yourself, from this row or the command line. The full finding is below.',
}
}
if (daemon === 'route-broken') {
return {
route: 'route-broken',
label: detour ? `via ${detour} · missing` : 'route missing',
tone: 'warn',
title:
'The last apply could not resolve this fetch route, so the refresh is refused by name rather than sent out in the clear: the feed stops updating while its cached nodes keep working. The full finding is below.',
}
}
if ((sub.FetchVia ?? '') !== 'proxy') return null
if (proxyFetchGoesDirect(sub)) {
// No URL is the only quiet answer, and it is quiet because the fetch is
// refused at the bottom rather than merely unlikely.
if (!hasURL(sub)) {
return {
route: 'unfetchable',
label: 'proxy · no route',
tone: 'none',
title:
'Fetch via is Proxy with no route chosen, which resolves to the direct outbound. This subscription has no URL, so there is nothing to request and nothing can be disclosed. Choose a route in Options before you add one.',
}
}
// Switched off. The badge stays amber and the sentence says exactly what the
// switch does, because the switch does not stop a fetch — it stops the
// scheduled one. The "Fetch now" button on this row is not gated on it.
if (!sub.Enabled) {
return {
route: 'leak-paused',
label: 'proxy · no route',
tone: 'warn',
title:
'Fetch via is Proxy but no route is chosen, so a fetch goes out over the plain WAN with this router’s real address. Being switched off does not prevent that: it stops the scheduled refresh, and this row’s “Fetch now” asks for a fetch whatever the switch says. Open Options to pick a route.',
}
}
return {
route: 'leak-draft',
label: 'proxy · no route',
tone: 'warn',
title:
'Fetch via is Proxy but no route is chosen, so the fetch will go out over the plain WAN with this router’s real address. Open Options to pick one.',
}
}
return {
route: 'routed',
label: `via ${detour}`,
tone: 'none',
title: `The feed is fetched through “${detour}”.`,
}
}
+166
View File
@@ -0,0 +1,166 @@
// Which board row a GROUP or CHAIN card is allowed to show.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// THE DEFECT THIS EXISTS FOR. The Targets page keyed the test board by NAME:
// `new Map(gtest.results.map(r => [r.group, r]))`. The board is not one row per
// name. shater/engine/grouptest.go carryForward supersedes by (name, KIND), so a
// chain `x` and a node `x` both survive a run, and startTestRun appends the
// carried rows LAST — and `Map` keeps the last. The chain card therefore showed
// the NODE's milliseconds, exit address and verdict as its own end-to-end
// measurement, with nothing on screen saying so. A stale green for a target
// nobody measured is the worst reading this page can produce.
//
// WHAT THESE PROTECT:
//
// 1. A ROW OF ANOTHER KIND IS NEVER AN ANSWER — even when it is the last row on
// the board, which is exactly where a carried row lands.
// 2. THE CONTROL, so #1 is not satisfied by a function that just refuses
// same-named rows: with DIFFERENT names the correct row must still be found,
// and the numbers on the card must be that row's.
// 3. THE SECOND CONTROL: the helper must be able to return the wrong-kind row's
// own data when asked for that kind, or "they differ" would be satisfiable by
// a function that can only ever return undefined.
// 4. THE TWO FALLBACKS STAY IN ORDER. `kind:''` (the daemon resolved the name to
// nothing) and a missing `kind` (a daemon older than the field) may answer a
// card only when no correctly-kinded row exists.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import { targetResultFor } from './targetResult.ts'
const row = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'nl-1',
selected: '',
kind: 'group',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error: '',
tested_unix: 1_700_000_000,
source: 'observatory',
...over,
})
/** The chain's own reading: measured now, and it is what the chain card must show. */
const chainRow = row({
group: 'nl-1',
kind: 'chain',
delay_ms: 412,
exit_ip: '203.0.113.9',
exit_country: 'NL',
ok: false,
error: 'the path did not answer',
tested_unix: 1_700_000_900,
})
/**
* A NODE row for the SAME name, carried forward from an earlier run — fast, green
* and stale. This is the row that used to win, because carried rows are appended
* last and a name-keyed Map keeps the last.
*/
const carriedNode = row({
group: 'nl-1',
kind: 'node',
delay_ms: 38,
exit_ip: '198.51.100.4',
exit_country: 'DE',
ok: true,
tested_unix: 1_700_000_000,
})
test('a carried row of another kind cannot answer for the chain, even placed last', () => {
const board = [chainRow, carriedNode] // wire order: carried rows come last
const got = targetResultFor(board, 'nl-1', 'chain')
assert.equal(got?.kind, 'chain', 'the chain card took a row that was not the chain’s')
// Named one by one: a card that shows another target's numbers is the defect,
// and asserting only on `kind` would pass a helper that returned a right-kinded
// row with the wrong body.
assert.equal(got?.delay_ms, 412, 'the card shows another target’s milliseconds')
assert.equal(got?.exit_ip, '203.0.113.9', 'the card shows another target’s exit address')
assert.equal(got?.ok, false, 'the card shows another target’s verdict')
})
test('a node row never answers a group card either — the same collision, the other way', () => {
const groupRow = row({ group: 'nl-1', kind: 'group', delay_ms: 250, ok: true })
const board = [groupRow, carriedNode]
assert.equal(targetResultFor(board, 'nl-1', 'group')?.delay_ms, 250)
})
test('a chain row never answers a group card: two kinds this page itself draws', () => {
const groupRow = row({ group: 'nl-1', kind: 'group', delay_ms: 250 })
// Chain first, group second — order must not decide it in either direction.
assert.equal(targetResultFor([chainRow, groupRow], 'nl-1', 'group')?.delay_ms, 250)
assert.equal(targetResultFor([groupRow, chainRow], 'nl-1', 'chain')?.delay_ms, 412)
})
// ---- the controls -----------------------------------------------------------
test('CONTROL: with distinct names the card still finds its own row', () => {
// Without this, the assertions above are satisfied by a helper that answers
// "undefined" whenever a name appears twice — i.e. one that simply forbids
// name collisions instead of attributing rows.
const board = [
row({ group: 'de-edge', kind: 'chain', delay_ms: 77, ok: true }),
row({ group: 'nl-1', kind: 'node', delay_ms: 38, ok: true }),
]
const got = targetResultFor(board, 'de-edge', 'chain')
assert.equal(got?.delay_ms, 77, 'a chain with a name nobody shares was not found')
assert.equal(got?.ok, true)
})
test('CONTROL: the same board yields the node row to whoever asks for a node kind', () => {
// The helper is capable of returning `carriedNode`; it just may not hand it to
// a chain or a group. Without this, "the chain card refuses the node row" is
// satisfiable by a function that can never return a node row at all.
const board = [chainRow, carriedNode]
const rows = board.filter((r) => r.kind === 'node')
assert.equal(rows.length, 1)
assert.equal(rows[0].delay_ms, 38)
// …and the chain lookup on that same board returns something DIFFERENT.
assert.notEqual(targetResultFor(board, 'nl-1', 'chain')?.delay_ms, rows[0].delay_ms)
})
test('a card with no row of its kind shows nothing rather than somebody else’s', () => {
assert.equal(targetResultFor([carriedNode], 'nl-1', 'chain'), undefined)
assert.equal(targetResultFor([], 'nl-1', 'group'), undefined)
assert.equal(targetResultFor(null, 'nl-1', 'group'), undefined)
})
// ---- the two fallbacks ------------------------------------------------------
test('“no such group or chain” (kind:"") answers both cards of that name', () => {
// The daemon's stub for a name it resolved to nothing. It is a source:'' /
// ok:false statement that NEITHER exists, so it belongs on both cards — and it
// can never manufacture a green.
const stub = row({
group: 'typo',
kind: '',
source: '',
ok: false,
error: 'no such group or chain in the running engine',
})
assert.equal(targetResultFor([stub], 'typo', 'group')?.error, stub.error)
assert.equal(targetResultFor([stub], 'typo', 'chain')?.error, stub.error)
})
test('kind:"" is a LAST resort — a correctly-kinded row on the same name wins', () => {
const stub = row({ group: 'nl-1', kind: '', source: '', ok: false })
// Stub first, so winning by kind is not winning by position.
assert.equal(targetResultFor([stub, chainRow], 'nl-1', 'chain')?.delay_ms, 412)
})
test('a daemon that sends no kind is matched by name — but only after both others', () => {
const legacy = row({ group: 'nl-1', kind: undefined, delay_ms: 9, ok: true })
assert.equal(targetResultFor([legacy], 'nl-1', 'chain')?.delay_ms, 9)
assert.equal(targetResultFor([legacy], 'nl-1', 'group')?.delay_ms, 9)
// …and it never takes the card from a row that names its kind.
assert.equal(targetResultFor([legacy, chainRow], 'nl-1', 'chain')?.delay_ms, 412)
const stub = row({ group: 'nl-1', kind: '', source: '', ok: false, delay_ms: 0 })
assert.equal(targetResultFor([legacy, stub], 'nl-1', 'chain')?.error, stub.error)
})
+62
View File
@@ -0,0 +1,62 @@
// Which row of the group-test board belongs to a GROUP or a CHAIN card.
//
// Backend contract: shater/engine/grouptest.go — GroupTestResult.Kind (the
// closed set TestKindGroup/TestKindChain/TestKindNode plus ""), and
// Engine.carryForward, which is why this file has to exist at all.
//
// WHY NOT A Map KEYED BY NAME. The board is not one row per name. A run carries
// forward the rows it will not itself re-measure, and carryForward supersedes by
// (name, KIND) — so testing the chain `nl-1` keeps the node `nl-1`'s reading, and
// both rows sit on one board under one name. The carried rows are appended LAST
// (startTestRun), so `new Map(results.map(r => [r.group, r]))` — last wins —
// handed the chain card the NODE's milliseconds, exit address and verdict, with
// no mark saying so. A stale green from an instrument that never looked at this
// target is the worst reading this page can produce, so attribution here is by
// kind, never by name alone.
//
// This is the group/chain twin of testResult.nodeResultFor, and deliberately not
// the same function: the two have DIFFERENT fallback rules for the fourth kind,
// "" (see below). A shared helper with a flag would hide exactly the difference
// that matters.
import type { GroupTestResult } from './api'
/** The two kinds of card the Targets page draws. Nodes live on the Nodes page
* and are claimed by testResult.nodeResultFor. */
export type TargetKind = 'group' | 'chain'
/**
* The reading to show on the card for `name`, or `undefined` for "no reading
* right now" — which is never a verdict.
*
* Three passes, most specific first, and the order is the contract:
*
* 1. the row this run (or an earlier one) produced FOR THIS KIND. The only
* positive attribution, and the only one that may carry a measurement.
*
* 2. a row with kind `''` — the daemon resolved the name to NOTHING and said so
* ("no such group or chain in the running engine"). It is unattributable by
* construction, and it belongs on both the group card and the chain card of
* that name, because it reports that NEITHER exists. It can only ever be a
* source:'' / ok:false stub, so admitting it cannot manufacture a green.
*
* 3. a row with no `kind` field at all — a daemon older than the field. A name
* match is the best that can be done there, and it is last so it can never
* take a card away from a correctly-kinded row.
*
* A row of ANOTHER kind is never an answer. A `node` row never answers a group
* or chain card, and a `chain` row never answers a group card: testing one says
* nothing about the other, which is the whole reason Kind exists.
*/
export function targetResultFor(
results: GroupTestResult[] | null | undefined,
name: string,
kind: TargetKind,
): GroupTestResult | undefined {
const rows = results ?? []
return (
rows.find((r) => r.group === name && r.kind === kind) ??
rows.find((r) => r.group === name && r.kind === '') ??
rows.find((r) => r.group === name && r.kind === undefined)
)
}
+287
View File
@@ -0,0 +1,287 @@
// Reading a test result: which instrument spoke, and what that entitles the
// screen to say.
//
// Run with `npm test`. Plain module, no React, no DOM.
//
// WHAT THESE PROTECT, in the order they matter:
//
// 1. "NOT CHECKED" AND "DEAD" MUST NOT LOOK ALIKE. Both arrive as `ok:false`.
// A measuring instrument whose negative result cannot be told from a check
// that never ran is worthless, so the test asserts the two get DIFFERENT
// tones — and it is written so that drawing them the same way fails it.
// 2. THE CONTROL. The same helper must also produce the positive reading, or
// "they differ" could be satisfied by a function that never says anything
// good at all.
// 3. THE ONE ESCALATION IS EXACTLY ONE. A blocked chain has no end-to-end
// measurement and still names a hop that WAS probed and failed, so it stays
// loud — without that turning every other `source:''` red. It is decided by
// the `blocked_by` FIELD; see blockedHop.test.ts for that field's own edges,
// and carriedRow.test.ts for which run a row came from.
// 4. THREE REFUSALS, THREE ANSWERS. 400 / 404 / 503 are three different facts;
// collapsing them into one "error" is what leaves an operator guessing.
// 5. ATTRIBUTION IS BY KIND. A group and a node may share a name on one board.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { GroupTestResult } from './api.ts'
import {
blockedHop,
errStatus,
isAbsence,
measuredOnDemand,
nodeResultFor,
readingTone,
refusalMessage,
refusalOf,
refusalTone,
testReading,
testSource,
} from './testResult.ts'
const row = (over: Partial<GroupTestResult>): GroupTestResult => ({
group: 'n1',
selected: '',
kind: 'node',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error: '',
tested_unix: 1_700_000_000,
source: '',
...over,
})
/** Measured, and it did not answer — a health verdict. */
const measuredDead = row({
source: 'on-demand',
ok: false,
error: 'checked now over this node’s own configured path: the probe did not get through',
})
/** Nothing measured it — `ok:false` sitting next to no instrument at all. */
const neverMeasured = row({
source: '',
ok: false,
error:
'no such node in the running engine — it is not in the applied configuration (not applied yet, or dropped as unusable)',
})
// --- 1 + 2. the distinction, and the control that it is a real distinction ----
test('a measured failure and an unmeasured row are not the same reading', () => {
assert.equal(testReading(measuredDead), 'failed', 'a probe that ran and failed is a failure')
assert.equal(
testReading(neverMeasured),
'not-measured',
'source:"" means NOTHING measured this — reading it as a failure invents a death',
)
assert.notEqual(
testReading(measuredDead),
testReading(neverMeasured),
'ok:false with an instrument behind it is a different fact from ok:false with none',
)
})
test('and they are DRAWN differently — the same fact, at the pixel level', () => {
const dead = readingTone(measuredDead)
const never = readingTone(neverMeasured)
assert.equal(dead, 'crit', 'a measured failure is a health finding and is drawn as one')
assert.equal(
never,
'unknown',
'an unmeasured row must be an unlit lamp — red would report a fault nobody found',
)
assert.notEqual(dead, never, 'a screen that paints both the same answers nothing')
})
test('CONTROL: the same helper does produce a positive reading', () => {
// Without this, "the two differ" would also be satisfied by a classifier that
// can only ever say bad things.
const alive = row({ source: 'on-demand', ok: true, delay_ms: 61 })
assert.equal(testReading(alive), 'ok')
assert.equal(readingTone(alive), 'good')
assert.notEqual(readingTone(alive), readingTone(measuredDead))
assert.notEqual(readingTone(alive), readingTone(neverMeasured))
})
test('an observatory failure is measured too — both instruments count', () => {
const obs = row({
kind: 'group',
source: 'observatory',
error: 'the observatory’s probe through this path failed',
})
assert.equal(testReading(obs), 'failed')
assert.equal(readingTone(obs), 'crit')
})
test('every "no measurement exists" reason reads as unmeasured, whatever it says', () => {
const reasons = [
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
'the observatory has not reached this target yet — it refreshes on the global probe interval',
'background probing is disabled, so there is nothing to measure this target with',
'could not check: this daemon has no health board to record a measurement in',
'the engine is not running, so there is nothing to dial this node with — apply the configuration first',
'that name is a group, not a node — test it as a group',
]
for (const error of reasons) {
const r = row({ source: '', error })
assert.equal(testReading(r), 'not-measured', error)
assert.equal(readingTone(r), 'unknown', error)
}
})
test('no row at all is a third thing again — the caller draws nothing', () => {
assert.equal(testReading(undefined), undefined)
assert.equal(readingTone(undefined), undefined)
assert.equal(testReading(null), undefined)
})
// --- 3. exactly one escalation ------------------------------------------------
test('a chain blocked at a hop keeps its severity, though nothing measured the exit', () => {
const blocked = row({
group: 'ewan-wg-subs',
kind: 'chain',
source: '',
blocked_by: 3,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
})
assert.equal(testReading(blocked), 'not-measured', 'there is no end-to-end measurement')
assert.equal(readingTone(blocked), 'crit', 'but a probe did run, at the hop, and failed')
assert.equal(blockedHop(blocked), 3)
})
test('the escalation is exactly one FIELD, not a sentence and not a mood', () => {
// It used to be a prose match — the last place the panel decided anything from
// the daemon's wording. `blocked_by` is the same fact as a number, so a
// reworded message can no longer turn a red row grey. Full coverage of the
// field's edges lives in blockedHop.test.ts.
for (const error of [
'not routed by any enabled rule',
'the observatory has not reached this target yet',
'could not check: no health board',
'hop 3 of this chain was probed and did not answer',
]) {
assert.equal(blockedHop(row({ error })), 0, error)
assert.equal(readingTone(row({ error })), 'unknown', error)
}
})
// --- source normalisation ------------------------------------------------------
test('an unrecognised source claims no measurement rather than inventing one', () => {
for (const raw of ['', ' ', 'probe', 'OBSERVATORY', 'ondemand']) {
assert.equal(testSource(raw), '', JSON.stringify(raw))
}
assert.equal(testSource('observatory'), 'observatory')
assert.equal(testSource('on-demand'), 'on-demand')
})
test('a daemon that predates `source` is read by its prose, and only then', () => {
// No `source` key at all — the field is absent, not empty.
const old = { ...row({ ok: false, error: 'the observatory’s probe through this path failed' }) }
delete (old as { source?: string }).source
assert.equal(testReading(old), 'failed', 'an unrecognised prose failure stays a failure')
const oldAbsence = { ...row({ ok: false, error: 'not routed by any enabled rule, so nothing measures it' }) }
delete (oldAbsence as { source?: string }).source
assert.equal(testReading(oldAbsence), 'not-measured')
assert.equal(isAbsence('not routed by any enabled rule'), true)
assert.equal(isAbsence('the probe failed'), false)
})
test('on-demand is called out; an observatory reading is not', () => {
assert.equal(measuredOnDemand(row({ source: 'on-demand', ok: true })), true)
assert.equal(measuredOnDemand(row({ source: 'observatory', ok: true })), false)
assert.equal(measuredOnDemand(undefined), false)
})
// --- 4. three refusals, three answers ------------------------------------------
test('400, 404 and 503 stay three different facts', () => {
assert.equal(refusalOf(400), 'no-name', '400 is "you sent no name"')
assert.equal(refusalOf(404), 'unsaved', '404 is about the NAME — the node is not in the config')
assert.equal(
refusalOf(503),
'unknown',
'503 is "the configuration could not be read" — an unknown, never a verdict about the node',
)
// A positive, closed list: nothing else inherits a reassuring branch.
assert.equal(refusalOf(500), 'failed')
assert.equal(refusalOf(409), 'failed')
assert.equal(refusalOf(0), 'failed')
assert.equal(refusalOf(undefined), 'failed')
const kinds = [refusalOf(400), refusalOf(404), refusalOf(503)]
assert.equal(new Set(kinds).size, 3, 'the three outcomes must not collapse into one')
})
test('an unreadable configuration is an unknown, and never a verdict about the node', () => {
// The daemon's own 503 text (panel/nodetest.go errNodeTestNoConfig) plus the
// read error it appends. It is shown verbatim: it already says the one thing
// that matters, and it carries the cause.
const daemon =
'could not read the configuration to check that this node exists, so the test was not started — this is an unknown, not a verdict about the node: uci show shater: exit status 1'
const msg = refusalMessage('unknown', daemon)
assert.equal(msg, daemon, 'the daemon’s sentence is not paraphrased on top of itself')
assert.match(msg, /not a verdict about the node/)
assert.match(msg, /uci show shater/, 'the underlying error is the actionable half')
// With no text at all the panel still has to say something true.
const bare = refusalMessage('unknown', '')
assert.match(bare, /says nothing about the node/)
assert.equal(refusalTone('unknown'), 'unknown', 'an instrument that could not look is not red')
assert.notEqual(refusalTone('unknown'), refusalTone('unsaved'))
assert.notEqual(refusalTone('unknown'), refusalTone('failed'))
})
test('a node that was never saved is told so, without a second copy of the same sentence', () => {
const msg = refusalMessage('unsaved', 'no node with that name in the configuration')
assert.match(msg, /not in the saved configuration/)
assert.doesNotMatch(msg, /no node with that name/, 'the daemon text says the same thing')
assert.equal(refusalTone('unsaved'), 'warn')
})
test('none of the refusals claims the node is down', () => {
for (const kind of ['unsaved', 'unknown', 'no-name', 'failed'] as const) {
const msg = refusalMessage(kind, 'boom')
assert.doesNotMatch(msg, /\bdead\b|\bdown\b|not alive/i, kind)
assert.ok(msg.length > 0, kind)
}
})
test('the HTTP status is read off the mock’s error too, not just the real class', () => {
class MockErr extends Error {
status = 404
}
assert.equal(errStatus(new MockErr('nope')), 404)
assert.equal(errStatus(new Error('plain')), undefined)
assert.equal(errStatus(null), undefined)
assert.equal(errStatus('404'), undefined)
})
// --- 5. attribution by kind ----------------------------------------------------
test('a node result is claimed by kind, so a same-named group cannot steal it', () => {
const board: GroupTestResult[] = [
row({ group: 'edge', kind: 'group', source: 'observatory', ok: true, delay_ms: 12 }),
row({ group: 'edge', kind: 'node', source: 'on-demand', ok: true, delay_ms: 61 }),
]
assert.equal(nodeResultFor(board, 'edge')?.delay_ms, 61)
assert.equal(nodeResultFor(board, 'missing'), undefined)
assert.equal(nodeResultFor(null, 'edge'), undefined)
})
test('a daemon that sends no kind still matches by name — but only as a fallback', () => {
const legacy = { ...row({ group: 'edge', ok: true, delay_ms: 7 }) }
delete (legacy as { kind?: string }).kind
assert.equal(nodeResultFor([legacy], 'edge')?.delay_ms, 7)
// With a properly kinded row present, the kindless one must not win.
const both = [legacy, row({ group: 'edge', kind: 'node', ok: true, delay_ms: 61 })]
assert.equal(nodeResultFor(both, 'edge')?.delay_ms, 61)
})
+372
View File
@@ -0,0 +1,372 @@
// Reading a /api/groups/test result honestly: what was measured, and by what.
//
// THE ONE RULE. `ok:false` is two completely different facts wearing one flag:
//
// the instrument ran and the target did not answer → the target is broken
// nothing ran at all → we have not looked
//
// A measuring instrument whose negative result is indistinguishable from a check
// that never happened is not an instrument. The daemon now says which, on every
// row, in `source` — "observatory" or "on-demand" mean a measurement was taken
// (on either verdict), "" means none was. This module is the only place the
// panel is allowed to decide it, so the two cannot drift apart on two pages.
//
// The error PROSE is the fallback instrument and only that: it is what a daemon
// older than `source` leaves us, and reading it is strictly better than assuming
// either answer. Where `source` is present it wins outright.
//
// The one place prose still DECIDED something — a chain blocked at a hop, kept
// red by matching a fragment of the daemon's sentence — is gone: that fact
// arrives as `blocked_by`, a number. A message is for a person, a field is for a
// program, and the two must not be the same string.
//
// This module also answers WHICH RUN a row came from. A run no longer wipes the
// board, so a card can show a reading measured twenty minutes ago beside one
// measured a second ago, and the two must not look alike — see rowOrigin.
import type { GroupTestResult } from './api'
/** The closed set of instruments. `''` means none — see the file header. */
export type TestSource = '' | 'observatory' | 'on-demand'
const SOURCES: readonly TestSource[] = ['', 'observatory', 'on-demand']
/**
* Normalise `source` into the closed set. An unrecognised value is `''` — "no
* instrument I know of", which is the recoverable direction: it under-claims a
* measurement rather than inventing one.
*
* `undefined` is NOT the same input and must not be routed through here — a
* daemon that never sends the field is a different situation from one that sends
* "". {@link testReading} keeps them apart.
*/
export function testSource(raw: string | null | undefined): TestSource {
return (SOURCES as readonly string[]).includes(raw ?? '') ? ((raw ?? '') as TestSource) : ''
}
/**
* Errors that mean NO MEASUREMENT EXISTS, as opposed to "this target is broken".
*
* Three of the observatory's four failure reasons are about the observatory, not
* about the path: nothing routes here, nothing has reached it yet, or background
* probing is switched off. Painting those crit-red — which is what `ok:false`
* used to buy you — reports a fault nobody has found, on a target that may be
* carrying traffic perfectly. Only "the observatory's probe through this path
* failed" is a health finding, and it is deliberately NOT in this list.
*
* The node branch adds its own two "could not check" sentences (engine/
* nodetest.go): no health board to record into, and a probe whose verdict could
* not be read back. Both are instrument failures, and both already carry
* `source:''` — these fragments only matter for a daemon that predates the field.
*
* Matched on a stable fragment rather than the whole sentence, so a daemon that
* rewords the tail still classifies. An error we do not recognise stays a
* failure: an unknown fault is likelier to be real than not.
*/
export const NO_MEASUREMENT = [
'not routed by any enabled rule',
'has not reached this target yet',
'background probing is disabled',
'could not check',
]
/** True when the prose says no measurement exists. Prefer {@link testReading}. */
export const isAbsence = (err: string): boolean =>
NO_MEASUREMENT.some((frag) => err.includes(frag))
/**
* NOTHING MEASURED THIS *BECAUSE SOMETHING ELSE WAS MEASURED AND FAILED* — and
* the daemon now says which hop, in a FIELD.
*
* One case, and it is a chain: hop N answered nothing, so the walk stopped and
* the exit was never dialled. The daemon files that as `source:''` — correctly,
* there is no end-to-end measurement — but the row is not the quiet "nobody has
* looked yet" that every other `source:''` is. A probe ran, it failed, and there
* is a hop to go and fix.
*
* THIS USED TO BE A STRING COMPARISON. `blockedUpstream(err)` matched a fragment
* of one daemon sentence, and it was the last place in the panel where prose
* decided anything: a reworded message would have quietly turned a red row grey.
* `blocked_by` is the same fact as a number, so the match is gone.
*
* Returns the 1-BASED hop, or 0 for "not about this" — which is what every
* non-chain result carries. Only a finite integer ≥ 1 counts; a missing field
* (a daemon older than it), a 0, a negative or a NaN all answer 0, so the
* escalation can never be entered by accident.
*
* It only ever ESCALATES a row that `source` already put in the not-measured
* bucket. It cannot turn a measured failure into a success or the other way
* round.
*/
export function blockedHop(r: GroupTestResult | undefined | null): number {
const n = r?.blocked_by
if (typeof n !== 'number' || !Number.isFinite(n) || n < 1) return 0
return Math.floor(n)
}
/**
* What a row is actually saying.
*
* ok — measured, and the target answered.
* failed — measured, and it did not. A health finding.
* not-measured — nothing was measured. NOT a health finding, and the reason
* belongs on screen (it names the fix: route a rule through it,
* wait for the probe interval, turn probing back on, apply).
*/
export type TestReading = 'ok' | 'failed' | 'not-measured'
/**
* Classify one result. `undefined` in ⇒ `undefined` out: "there is no row for
* this card", which is a third thing again and the caller draws nothing.
*
* Order of instruments, and it matters:
* 1. `ok:true` is a measurement by construction — a delay was read.
* 2. `source` present ⇒ it decides, prose ignored.
* 3. `source` absent (pre-field daemon) ⇒ the prose is all there is.
*/
export function testReading(r: GroupTestResult | undefined | null): TestReading | undefined {
if (!r) return undefined
if (r.ok) return 'ok'
if (r.source === undefined) return isAbsence(r.error ?? '') ? 'not-measured' : 'failed'
return testSource(r.source) === '' ? 'not-measured' : 'failed'
}
/**
* Was this number taken by THIS button press, over the target's own configured
* path, rather than read off the background board? Worth saying out loud on a
* node: it is the difference between "the path your rules use" and "this node's
* own route", and those are the same only when the node has no egress binding.
*/
export function measuredOnDemand(r: GroupTestResult | undefined | null): boolean {
return !!r && testSource(r.source) === 'on-demand'
}
/**
* The tone a readout is drawn in — the display half of {@link testReading},
* kept here so two pages cannot answer it differently.
*
* good — measured, answered.
* crit — measured and failed, PLUS the one escalation above
* ({@link blockedHop}): a chain whose exit was never reached
* because a hop it runs through was probed and did not answer.
* unknown — nothing measured. An unlit lamp: not a fault, not a pass.
*/
export function readingTone(
r: GroupTestResult | undefined | null,
): 'good' | 'crit' | 'unknown' | undefined {
const reading = testReading(r)
if (reading === undefined) return undefined
if (reading === 'ok') return 'good'
if (reading === 'failed') return 'crit'
return blockedHop(r) > 0 ? 'crit' : 'unknown'
}
// ---- which run does this row belong to? -------------------------------------
//
// THE DEFECT THIS PART EXISTS FOR, FROM THE OTHER SIDE. Starting a run used to
// replace the board outright, so pressing Test on one node blanked every group
// and chain card — the daemon threw true measurements away and nothing on screen
// explained it. It no longer does: rows the run will not re-measure are carried
// forward (engine.startTestRun), capped at 64, oldest evicted first.
//
// That fixes one lie and creates the opportunity for another. Two kinds of row
// now sit side by side — one measured by the button just pressed, one measured
// at some earlier time that may be twenty minutes ago — and drawing them alike
// would make the whole board read as "as of now".
//
// It is NOT decided by comparing timestamps. The router has no RTC, so its clock
// can sit far from the browser's and a computed "n minutes ago" would be
// fiction. The daemon publishes `scope`, the set of names THIS run covers, and
// membership is the answer.
/**
* WHERE A ROW CAME FROM — a closed set of three.
*
* this-run — the current run covers this target. `tested_unix` is from the
* measurement the operator just asked for.
* carried — an EARLIER run measured it and the row survived into this
* board. It must be shown with when it was taken, and it must
* not be drawn as if it had just been read.
* unattributed — the daemon published no scope, so which run this row belongs
* to is not known. Claim neither: show the timestamp, say
* nothing about freshness.
*/
export type RowOrigin = 'this-run' | 'carried' | 'unattributed'
/**
* Attribute one row against the run's scope.
*
* An EMPTY scope is `unattributed`, not "everything is carried". A real run
* always covers at least one target, so an empty set can only mean the daemon
* published none — the same situation as a missing field, and the panel's
* normalizer turns the missing one into `[]` before this ever sees it. Reading
* it as a real scope would stamp "measured earlier" on every row of a run that
* had just measured them all.
*
* Attribution is BY NAME, because that is all `scope` carries. A group and a
* node may share one, so a carried node row can be called `this-run` by a group
* run of the same name. That is the daemon's contract, and it errs toward
* under-warning on exactly one collision that the operator themselves created;
* the alternative would need a kinded scope, which is a daemon change.
*/
export function rowOrigin(
r: GroupTestResult | undefined | null,
scope: readonly string[] | null | undefined,
): RowOrigin {
if (!r) return 'unattributed'
const names = scope ?? []
if (names.length === 0) return 'unattributed'
return names.includes(r.group) ? 'this-run' : 'carried'
}
/**
* The stamp shown beside a reading: the words, and the longer sentence behind
* them.
*
* `clock` is the already-formatted router-clock time of `tested_unix` (the
* caller owns the formatting — this module has no opinion about locales). An
* empty clock means the row carries no timestamp at all, and then a carried row
* still has to say it is carried: that is the case where silence would be worst.
*
* The three cases produce three DIFFERENT texts. A test asserts that, because
* "shows the age" is satisfiable by a function that shows the same thing three
* times.
*/
export function originStamp(origin: RowOrigin, clock: string): { text: string; hint: string } {
switch (origin) {
case 'this-run':
return {
text: clock,
hint: 'Measured by the run you just started, at this time on the router’s clock.',
}
case 'carried':
return {
text: clock ? `earlier run · ${clock}` : 'earlier run',
hint: 'This reading is from an EARLIER run — the current one did not measure this target, and the board keeps the last known reading rather than blanking the card. The time is the router’s clock, which is not the browser’s.',
}
case 'unattributed':
return {
text: clock,
hint: 'The time this reading was taken, on the router’s clock. This daemon does not say which run produced it.',
}
}
}
/**
* Read an HTTP status off a rejection without depending on the ApiError class.
* The `?mock` backend throws a structurally identical error rather than the real
* one (it cannot import api.ts at runtime without closing a module cycle), so an
* `instanceof` check here would classify every offline refusal as "other" and
* the three node-test outcomes would be untestable in the one place they can be
* exercised without a router.
*/
export function errStatus(e: unknown): number | undefined {
if (typeof e !== 'object' || e === null) return undefined
const s = (e as { status?: unknown }).status
return typeof s === 'number' ? s : undefined
}
/**
* The result row for one NODE.
*
* Attribution is by `kind`, never by name alone: a group and a node may share a
* name, and one board holds both. A node run always answers `kind:'node'` — even
* when the name resolves to nothing — because that kind came from the request.
*
* A daemon older than `kind` sends none, and then a name match is the best that
* can be done; that fallback is deliberately last so it can never steal a row
* from a correctly-kinded one.
*/
export function nodeResultFor(
results: GroupTestResult[] | null | undefined,
name: string,
): GroupTestResult | undefined {
const rows = results ?? []
return (
rows.find((r) => r.group === name && r.kind === 'node') ??
rows.find((r) => r.group === name && r.kind === undefined)
)
}
// ---- starting a node test ---------------------------------------------------
/**
* Why a node test did not start. THREE distinct outcomes, because the daemon
* reports three distinct facts and folding them into one "error" throws away
* the only thing that tells the operator what to do next:
*
* unsaved (404) — no node of that name in the CONFIGURATION. About the name,
* not the node: it has not been saved yet, or was renamed.
* unknown (503) — the configuration could not be READ to check. Nothing was
* established about the node at all. Never a verdict.
* no-name (400) — no name was sent. There is no "test every node".
* failed (—) — anything else (network, 500, a rejected fetch).
*/
export type NodeTestRefusal = 'unsaved' | 'unknown' | 'no-name' | 'failed'
/** The tone each refusal is drawn in. Only a real refusal about the CONFIG is
* amber; an instrument that could not look is the unlit `unknown`, exactly as
* it is everywhere else in this panel. */
export function refusalTone(kind: NodeTestRefusal): 'warn' | 'unknown' | 'crit' {
switch (kind) {
case 'unsaved':
return 'warn'
case 'unknown':
return 'unknown'
case 'no-name':
case 'failed':
return 'crit'
}
}
/**
* Map an HTTP status onto the refusal set. Anything outside the three named
* codes is `failed` — a positive, closed list, so a future 409 cannot quietly
* inherit the reassuring branch.
*/
export function refusalOf(status: number | undefined): NodeTestRefusal {
switch (status) {
case 400:
return 'no-name'
case 404:
return 'unsaved'
case 503:
return 'unknown'
default:
return 'failed'
}
}
/**
* The sentence for a refusal, and WHOSE sentence it is.
*
* unknown — the DAEMON's, verbatim, whenever it sent one. Its 503 text is
* written for exactly this moment ("…so the test was not started —
* this is an unknown, not a verdict about the node: <read error>")
* and it carries the underlying failure, which is the actionable
* half. Writing our own sentence in front of it produced the same
* point twice in two voices; the panel's version below is the
* fallback for a refusal that arrives with no text at all.
* failed — ours, framing whatever technical string came back ("request
* failed (500)" says nothing on its own).
* unsaved / no-name — ours alone. The daemon's text for these says what this
* one says, and a second copy is where wording drifts apart.
*
* None of these says the node is down, because none of them established that.
*/
export function refusalMessage(kind: NodeTestRefusal, detail = ''): string {
const text = detail.trim()
switch (kind) {
case 'unsaved':
return 'This node is not in the saved configuration, so there is nothing to test yet. Save the node first.'
case 'unknown':
return (
text ||
'The configuration could not be read, so the test was not started. This says nothing about the node.'
)
case 'no-name':
return 'A node test needs a name, and there is no “test every node”.'
case 'failed':
return `The test could not be started${text ? ` — ${text}` : ''}.`
}
}
+38 -1
View File
@@ -41,6 +41,14 @@ type backendBase struct {
returnAccess sync.Mutex
returnPaths []tun.Return
// lx: reassembles IP fragments read off the bridge TUN before they are
// offered to a return path — sing-tun's classifyReturn refuses to judge a
// fragment, and unlike the WireGuard endpoint there is no second consumer
// here, so a passed fragment is dropped outright. See frag_reassembly.go.
// The zero value is ready to use and costs nothing on a bridge that never
// sees a fragment.
fragments fragmentReassembler
egressAccess sync.Mutex
forwardingRestore []sysctlState
unregister func()
@@ -181,7 +189,10 @@ func (b *backendBase) readLoop() {
if n <= tun.PacketOffset {
continue
}
packet := buffer[tun.PacketOffset:n]
packet := b.returnDatagram(buffer[tun.PacketOffset:n], 0)
if packet == nil {
continue
}
// On checksum-offloading NICs (notably virtio) the kernel leaves the L4
// checksum uncomputed when the forwarding path TXes to a tun; recompute it.
fixReturnChecksum(packet)
@@ -189,6 +200,32 @@ func (b *backendBase) readLoop() {
}
}
// lx: returnDatagram is the fragment seam every bridge read loop shares.
//
// packet is one packet just read off the bridge TUN, laid out with prefix bytes
// of already-reserved writable headroom in front of the IP header (prefix 0 for
// the loops that copy into their headroom afterwards).
//
// It returns what the return path should actually be offered:
//
// - packet itself, the SAME slice, when it is not a fragment. That is the
// whole cost of this seam on the hot path: isFragmentPacket, which for
// IPv4 is two byte loads and a mask.
// - a freshly owned buffer carrying the same prefix and the whole datagram,
// when this fragment completed one.
// - nil while the datagram is still incomplete, or when the fragment was
// refused. nil means "deliver nothing for this packet", not "error": the
// bytes are held in the cache until the datagram completes or expires.
//
// Callers must not retain the argument past the call — the cache copies
// whatever it keeps, because every read loop reuses its buffers.
func (b *backendBase) returnDatagram(packet []byte, prefix int) []byte {
if !isFragmentPacket(packet[prefix:]) {
return packet
}
return b.fragments.reassemble(packet[prefix:], prefix)
}
func (b *backendBase) deliverReturn(packet []byte) {
b.returnAccess.Lock()
returnPaths := b.returnPaths
+13 -1
View File
@@ -286,8 +286,20 @@ func (b *backendDarwin) batchReadLoop() {
if len(payload) == 0 {
continue
}
// lx: a fragment is held until its datagram is whole (see
// frag_reassembly.go). The result can be up to 65535 bytes, i.e.
// larger than the per-read staging buffers, so it gets its own.
payload = b.returnDatagram(payload, 0)
if payload == nil {
continue
}
fixReturnChecksum(payload)
buffer := buffers[len(batch)][:headroom+len(payload)]
var buffer []byte
if headroom+len(payload) <= cap(buffers[len(batch)]) {
buffer = buffers[len(batch)][:headroom+len(payload)]
} else {
buffer = make([]byte, headroom+len(payload))
}
copy(buffer[headroom:], payload)
batch = append(batch, buffer)
}
+8 -1
View File
@@ -352,7 +352,14 @@ func (b *backendLinux) batchReadLoop() {
if sizes[i] == 0 {
continue
}
batch = append(batch, buffers[i][:headroom+sizes[i]])
// lx: a fragment is held until its datagram is whole (see
// frag_reassembly.go); anything else travels by reference exactly
// as before.
entry := b.returnDatagram(buffers[i][:headroom+sizes[i]], headroom)
if entry == nil {
continue
}
batch = append(batch, entry)
}
unconsumed := batch
currentHeadroom := headroom
+719
View File
@@ -0,0 +1,719 @@
// lx: IP fragment reassembly on the bridge outbound's *return* path.
//
// WHY THIS EXISTS
//
// A bridge outbound is a tun.Port: the l3 dispatcher SNATs a client packet to
// the bridge's port address, writes it to the bridge TUN, the host kernel
// forwards and masquerades it out the egress, and the answer comes back the
// same way — kernel -> bridge TUN -> the read loops in this package ->
// returnPath.ReturnPackets.
//
// That return path is sing-tun's `forwardReturn.classifyReturn`
// (flow_dispatch.go), which refuses to judge a fragment: `parseForwardPacket`
// sets `fragment` for any IPv4 packet with MF/offset set or any IPv6 packet
// carrying a fragment extension header, and classifyReturn answers
// `returnPass` for those. `returnPass` means "not mine".
//
// For the WireGuard endpoint a passed packet still reaches the endpoint's own
// tun stack. HERE IT DOES NOT: backendBase.deliverReturn and the batch read
// loops offer a packet to each attached return path in turn and then simply
// DROP whatever nobody claimed — there is no second consumer. So a fragmented
// answer is not merely misrouted, it is lost, and the client sees a 100%
// loss on exactly the datagrams that were too big for a single frame.
//
// Fragments really do arrive here. The forward direction cannot produce them
// (PortMTU() returns 0, so the dispatcher neither clamps nor fragments), but
// the return direction is fragmented by the local kernel: with NAT the
// conntrack defragmenter reassembles the WAN-side fragments at PREROUTING to
// do the lookup, and the output path then re-fragments to the bridge TUN's
// 1500-byte MTU (ip_fragment / ip6_fragment honour IPCB frag_max_size). The
// in-tree evidence that this was already known is packet.go's
// fixReturnChecksum, which explicitly recognises a fragment and declines to
// touch it — a non-first fragment has no transport header to checksum.
//
// sing-tun is a pinned upstream with no `replace` in go.mod, so the fix cannot
// live there without forking it. It does not have to: every packet read off
// the bridge TUN passes through this package before it is offered to
// `ReturnPackets`. Reassembling here hands classifyReturn a whole datagram,
// which it judges normally.
//
// RELATION TO transport/wireguard/frag_reassembly.go
//
// This is a deliberate sibling of that file, not an accident: the same
// upstream blindness reached through a second, independent tun.Port
// implementation. The algorithm, the ceilings and the overlap policy are
// identical and are meant to stay identical, so that collapsing the two into
// one shared package later is a mechanical move. They are not shared today
// because the only seam that could host the shared type —
// transport/wireguard/port.go, which declares the wrapper's `reassembler`
// field, plus the 900-line test suite that drives it through unexported
// names — is owned by other work in flight. If you touch one of the two
// files, touch both.
//
// WHAT IT DOES NOT DO
//
// It does not touch the forward direction (WritePackets), and it does not
// change the bridge TUN MTU. Raising that MTU would also stop the kernel
// re-fragmenting on the way in, but only where a defragmenter ran first, and
// it would change what the kernel accepts and clamps on the forward path of
// three different platforms. Reassembling what actually arrives is the
// narrower claim.
//
// It does not fix backend_windows.go. There a fragment never reaches the
// delivery function at all: classifyInbound parses the transport header to
// decide ours/not-ours, a non-first fragment has none, so WinDivert reinjects
// it into the host stack instead of handing it to deliver(). That is a
// different defect in a different function on a platform this project does
// not ship, and closing it means changing the classifier, not the read loop.
//
// MEMORY
//
// This runs on a 128-256 MB router, so every ceiling below is a hard number
// with an eviction policy behind it, not a hope. See the const block.
//go:build linux || darwin || (windows && (amd64 || 386))
package bridge
import (
"net/netip"
"sync"
"time"
"github.com/sagernet/sing-tun/gtcpip/header"
)
// Hard ceilings of the reassembly cache. Nothing here grows without a bound.
//
// Worst-case resident cost:
//
// payload + header bytes <= fragMaxTotalBytes = 1 MiB
// range bookkeeping <= fragMaxEntries * fragMaxRanges * 16 = 64 KiB
// map + struct overhead <= fragMaxEntries * ~200 B = ~13 KiB
//
// i.e. ~1.1 MiB with an adversary trying his hardest, and in practice a few
// kilobytes: a fragmented datagram lives for the microseconds between its
// fragments arriving in the same read batch.
//
// These are the same numbers the WireGuard return path uses, and they are
// re-derived rather than copied: the fragments that arrive here are emitted
// back-to-back by the LOCAL kernel re-fragmenting one datagram it had just
// reassembled, so they arrive in order, contiguous, within one BatchRead —
// a strictly easier case than a remote peer's output queue. A ceiling that
// covers the harder case covers this one.
//
// These are the ceilings that hold WHILE traffic flows and also AFTER it stops:
// expiry is traffic-driven, not timed, so the residue after the last fragment
// is bounded by these numbers rather than by fragTimeout. Read fragTimeout.
const (
// fragMaxEntries is the maximum number of datagrams under reassembly at
// once. A 65th datagram evicts the oldest one rather than growing the map.
fragMaxEntries = 64
// fragMaxTotalBytes caps the payload+header bytes held across all entries.
// Exceeding it evicts oldest-first until the budget fits.
fragMaxTotalBytes = 1 << 20 // 1 MiB
// fragMaxDatagram caps a single reassembled payload. 65535 is the ceiling
// both IP versions can express in their length field, so a fragment
// claiming more than this is malformed by construction.
fragMaxDatagram = 65535
// fragInitialPayload is the first payload allocation for a new entry; it
// doubles from there. 2048 holds any single-MTU-pair datagram (the only
// shape that actually occurs on this path) with no regrow.
fragInitialPayload = 2048
// fragMaxRanges caps the number of disjoint byte ranges tracked per
// datagram. Contiguous arrival — in order or reverse order — merges down to
// one range, so this is only reached by a sender interleaving holes on
// purpose. Exceeding it poisons the entry (see fragEntry.broken).
fragMaxRanges = 64
// fragTimeout is how long an incomplete datagram stays USABLE. Past it the
// entry is refused and released. The clock starts at the FIRST fragment and
// is never refreshed, so a sender cannot keep an entry alive by dribbling
// fragments into it.
//
// READ THIS BEFORE TREATING IT AS A MEMORY GUARANTEE. There is no timer.
// Expiry is driven by traffic: the sweep runs inside reassemble, so an
// expired entry is released ON THE NEXT FRAGMENT that reaches this bridge,
// not five seconds from now. When fragmented traffic stops completely, up to
// fragMaxEntries expired entries (bounded by the ceilings above, so ~1.1 MiB
// worst case, and in practice a few KiB) stay resident until it resumes or
// the backend is closed.
//
// That is a deliberate trade, not an oversight:
//
// - What bounds growth is fragMaxEntries/fragMaxTotalBytes, not this
// value. The residue is capped no matter how long the silence lasts.
// - This timeout's real job is CORRECTNESS — never assembling a datagram
// from a stale head — and for that a check driven by the arriving
// fragment is exact, because the arriving fragment is the only thing
// that could make a stale entry matter.
// - The alternative is a goroutine per backend that must be stopped on
// Close and might not be. A goroutine that must be stopped and might not
// be is a failure this project has already paid for; waking one forever
// to reclaim at most ~1.1 MiB that only exists after fragmented traffic
// has already happened is the wrong side of the trade on a 128 MB
// router.
//
// Linux uses 30 s (net.ipv4.ipfrag_time). 5 s is chosen instead because the
// real inter-fragment gap on this path is sub-millisecond (the kernel emits
// the fragments of one datagram back-to-back into the same tun read batch),
// so 5 s is already four orders of magnitude of slack, and a shorter timer
// means the 1 MiB budget is never the thing that starts dropping.
fragTimeout = 5 * time.Second
)
// fragKey identifies one datagram under reassembly.
//
// IPv4 (RFC 791): source, destination, protocol, identification.
// IPv6 (RFC 8200 §4.5): source, destination, identification — the next-header
// value is deliberately NOT part of the key, so protocol stays 0 for v6.
type fragKey struct {
source netip.Addr
destination netip.Addr
id uint32
protocol uint8
version uint8
}
type fragRange struct {
start int
end int
}
type fragEntry struct {
key fragKey
// header is the unfragmentable part, copied from the offset-0 fragment:
// the IPv4 header for v4, or the IPv6 header plus every extension header
// ahead of the fragment header (with the last NextHeader byte already
// patched past it) for v6. nil until that fragment arrives.
header []byte
// payload is the fragmentable part indexed from byte 0 of the datagram
// payload. Grown by doubling, never beyond fragMaxDatagram.
payload []byte
// ranges are the byte ranges of payload already received: sorted,
// non-overlapping, and merged where adjacent.
ranges []fragRange
// total is the payload length, known once the last fragment (MF=0 / M=0)
// has been seen; -1 before that.
total int
// broken poisons the key: an overlapping (or absurdly hole-ridden)
// fragment was seen, so nothing under this key will be assembled until the
// deadline passes. Buffers are released the moment it is set.
broken bool
deadline int64
}
func (e *fragEntry) size() int {
return len(e.header) + len(e.payload)
}
func (e *fragEntry) complete() bool {
return e.header != nil && e.total >= 0 && len(e.ranges) == 1 &&
e.ranges[0].start == 0 && e.ranges[0].end == e.total
}
type fragInsert uint8
const (
// fragInsertNew — the range is new and was recorded.
fragInsertNew fragInsert = iota
// fragInsertDuplicate — the range is fully covered by one already held; the
// held bytes win and the new copy is ignored.
fragInsertDuplicate
// fragInsertConflict — the range partially overlaps a held one, or the
// entry has too many holes. The entry is poisoned.
fragInsertConflict
)
// addRange records [start,end) in e.ranges.
//
// OVERLAP POLICY — chosen deliberately, and the reason is security, not
// laziness:
//
// - A range fully CONTAINED in one already held is a duplicate: benign
// networks retransmit and duplicate fragments, so this must not be fatal.
// The bytes already held win (first-wins). That is deterministic, so there
// is no "which copy did the receiver take" ambiguity for anyone to exploit.
//
// - Any PARTIAL overlap poisons the whole datagram. No conforming fragmenter
// ever emits one; overlapping fragments exist in the wild as teardrop-style
// malformity and as IDS/DPI evasion, and every historical vulnerability in
// this area comes from a reassembler that tried to be clever about
// resolving the conflict (first-wins vs last-wins vs BSD-style). Refusing
// to assemble is the one answer with no differential interpretation.
func (e *fragEntry) addRange(start, end int) fragInsert {
for _, r := range e.ranges {
if start >= r.start && end <= r.end {
return fragInsertDuplicate
}
if start < r.end && r.start < end {
return fragInsertConflict
}
}
if len(e.ranges) >= fragMaxRanges {
return fragInsertConflict
}
index := 0
for index < len(e.ranges) && e.ranges[index].start < start {
index++
}
e.ranges = append(e.ranges, fragRange{})
copy(e.ranges[index+1:], e.ranges[index:])
e.ranges[index] = fragRange{start: start, end: end}
merged := e.ranges[:1]
for _, r := range e.ranges[1:] {
last := &merged[len(merged)-1]
if r.start == last.end {
last.end = r.end
continue
}
merged = append(merged, r)
}
e.ranges = merged
return fragInsertNew
}
// fragmentReassembler is the per-backend cache. Its zero value is ready to
// use; the map is allocated on the first fragment, so a bridge that never sees
// one costs a mutex.
type fragmentReassembler struct {
access sync.Mutex
entries map[fragKey]*fragEntry
bytes int
// nowFunc is a test hook. nil means time.Now().
nowFunc func() int64
}
func (r *fragmentReassembler) now() int64 {
if r.nowFunc != nil {
return r.nowFunc()
}
return time.Now().UnixNano()
}
// isFragmentPacket reports whether packet is an IP fragment that needs
// reassembly before anyone can judge it.
//
// This is THE hot-path predicate: it runs on every packet read off the bridge
// TUN, and the overwhelming majority are not fragments. The non-fragment
// answer costs, for IPv4, one shift and two byte loads:
//
// byte 6 = |reserved|DF|MF|offset 12..8|, byte 7 = offset 7..0
// fragment <=> (byte6 & 0x3f) != 0 || byte7 != 0
//
// Note the mask excludes DF (0x40) and the reserved bit (0x80) — a DF-marked
// unfragmented packet must stay on the untouched path.
//
// For IPv6 it is one byte load compared against 44 (fragment header), plus —
// only when the next header is one of the three that may legally precede it —
// a walk of the extension header chain.
func isFragmentPacket(packet []byte) bool {
if len(packet) < 8 {
return false
}
switch header.IPVersion(packet) {
case header.IPv4Version:
return packet[6]&0x3f != 0 || packet[7] != 0
case header.IPv6Version:
if len(packet) < header.IPv6MinimumSize {
return false
}
_, _, ok := ipv6FragmentHeaderOffset(packet)
return ok
default:
return false
}
}
// ipv6FragmentHeaderOffset walks the extension header chain looking for a
// fragment header (44). It returns the byte offset of that header inside
// packet, the offset of the NextHeader byte that points AT it (which is what a
// reassembled packet must have rewritten), and whether one was found.
//
// Only Hop-by-Hop (0), Routing (43) and Destination Options (60) may precede a
// fragment header; anything else ends the unfragmentable part, so the walk is
// closed — an unknown next-header value means "not a fragment", never "keep
// looking".
func ipv6FragmentHeaderOffset(packet []byte) (fragmentOffset int, nextHeaderPos int, found bool) {
nextHeader := packet[header.IPv6NextHeaderOffset]
nextHeaderPos = header.IPv6NextHeaderOffset
cursor := header.IPv6MinimumSize
for {
switch header.IPv6ExtensionHeaderIdentifier(nextHeader) {
case header.IPv6FragmentExtHdrIdentifier:
if cursor+header.IPv6FragmentHeaderSize > len(packet) {
return 0, 0, false
}
return cursor, nextHeaderPos, true
case header.IPv6HopByHopOptionsExtHdrIdentifier,
header.IPv6RoutingExtHdrIdentifier,
header.IPv6DestinationOptionsExtHdrIdentifier:
if cursor+2 > len(packet) {
return 0, 0, false
}
extensionLength := (int(packet[cursor+1]) + 1) * 8
if cursor+extensionLength > len(packet) {
return 0, 0, false
}
nextHeader = packet[cursor]
nextHeaderPos = cursor
cursor += extensionLength
default:
return 0, 0, false
}
}
}
// fragmentInfo is one parsed fragment. All slices point INTO the caller's
// buffer and are only valid for the duration of the reassemble call.
type fragmentInfo struct {
key fragKey
// unfragmentable is the part to keep verbatim, set only for the offset-0
// fragment (the one that carries the real header).
unfragmentable []byte
// nextHeaderPos, for IPv6, is the index inside unfragmentable of the byte
// that must be rewritten to nextHeader; -1 for IPv4.
nextHeaderPos int
nextHeader uint8
payload []byte
start int
more bool
}
// parseFragment validates a fragment and extracts what reassembly needs.
// It returns false for anything malformed — such a packet is dropped, which is
// what every reassembler does and what the host stack would have done with it
// anyway.
func parseFragment(packet []byte) (fragmentInfo, bool) {
switch header.IPVersion(packet) {
case header.IPv4Version:
ipHdr := header.IPv4(packet)
if !ipHdr.IsValid(len(packet)) {
return fragmentInfo{}, false
}
headerLength := int(ipHdr.HeaderLength())
totalLength := int(ipHdr.TotalLength())
if headerLength < header.IPv4MinimumSize || totalLength < headerLength || totalLength > len(packet) {
return fragmentInfo{}, false
}
info := fragmentInfo{
key: fragKey{
source: ipHdr.SourceAddr(),
destination: ipHdr.DestinationAddr(),
id: uint32(ipHdr.ID()),
protocol: ipHdr.Protocol(),
version: 4,
},
nextHeaderPos: -1,
payload: packet[headerLength:totalLength],
start: int(ipHdr.FragmentOffset()),
more: ipHdr.More(),
}
if info.start == 0 {
info.unfragmentable = packet[:headerLength]
}
if !validFragmentExtent(info.start, len(info.payload), info.more) {
return fragmentInfo{}, false
}
return info, true
case header.IPv6Version:
ipHdr := header.IPv6(packet)
if !ipHdr.IsValid(len(packet)) {
return fragmentInfo{}, false
}
end := header.IPv6MinimumSize + int(ipHdr.PayloadLength())
if end > len(packet) {
return fragmentInfo{}, false
}
fragmentOffset, nextHeaderPos, found := ipv6FragmentHeaderOffset(packet[:end])
if !found {
return fragmentInfo{}, false
}
fragHdr := header.IPv6Fragment(packet[fragmentOffset:end])
if !fragHdr.IsValid() {
return fragmentInfo{}, false
}
payloadStart := fragmentOffset + header.IPv6FragmentHeaderSize
info := fragmentInfo{
key: fragKey{
source: ipHdr.SourceAddr(),
destination: ipHdr.DestinationAddr(),
id: fragHdr.ID(),
version: 6,
},
nextHeaderPos: nextHeaderPos,
nextHeader: fragHdr.NextHeader(),
payload: packet[payloadStart:end],
// IPv6Fragment.FragmentOffset() is in 8-byte units, unlike the
// IPv4 accessor of the same name which already returns bytes.
start: int(fragHdr.FragmentOffset()) * 8,
more: fragHdr.More(),
}
if info.start == 0 {
info.unfragmentable = packet[:fragmentOffset]
}
if !validFragmentExtent(info.start, len(info.payload), info.more) {
return fragmentInfo{}, false
}
return info, true
default:
return fragmentInfo{}, false
}
}
// validFragmentExtent enforces the two structural rules a conforming
// fragmenter always obeys: a fragment carries payload, and every fragment but
// the last carries a multiple of 8 bytes. Both are closed positively — an
// out-of-range or zero-length fragment is refused, not passed through.
func validFragmentExtent(start int, length int, more bool) bool {
if length == 0 {
return false
}
if more && length%8 != 0 {
return false
}
return start >= 0 && start+length <= fragMaxDatagram
}
// reassemble feeds one fragment into the cache.
//
// packet is BORROWED: every bridge read loop hands over a slice of a buffer it
// reuses on the next read, so everything retained here is copied.
//
// prefix is how many writable bytes the returned buffer must reserve in front
// of the reassembled datagram; the caller uses it as the return-path headroom.
//
// It returns the completed datagram — a freshly allocated, fully owned buffer
// laid out as prefix bytes of headroom followed by the packet — or nil when the
// datagram is still incomplete or was refused. A duplicate fragment is not
// automatically nil: it contributes no bytes, but a last fragment whose range
// is already held still tells us the total length and can complete the
// datagram.
func (r *fragmentReassembler) reassemble(packet []byte, prefix int) []byte {
info, ok := parseFragment(packet)
if !ok {
return nil
}
r.access.Lock()
defer r.access.Unlock()
now := r.now()
// Sweep on EVERY fragment, not only when a new key appears. It is an
// O(fragMaxEntries) scan on a path that is already the rare one, and it is
// the single mechanism behind both halves of the timeout: an expired entry
// is unusable AND unallocated. See fragTimeout for what this does and does
// not guarantee — there is no timer, so "expired" means "released on the
// next fragment", not "released 5 s from now".
r.sweep(now)
entry := r.entries[info.key]
if entry == nil {
if r.entries == nil {
r.entries = make(map[fragKey]*fragEntry)
}
for len(r.entries) >= fragMaxEntries {
if !r.evictOldest(nil) {
return nil
}
}
entry = &fragEntry{key: info.key, total: -1, deadline: now + int64(fragTimeout)}
r.entries[info.key] = entry
}
if entry.broken {
return nil
}
end := info.start + len(info.payload)
if !info.more {
if entry.total >= 0 && entry.total != end {
// Two different "last fragments" for one datagram.
r.poison(entry)
return nil
}
if len(entry.ranges) > 0 && entry.ranges[len(entry.ranges)-1].end > end {
// Bytes are already held past the end this fragment declares.
r.poison(entry)
return nil
}
entry.total = end
} else if entry.total >= 0 && end > entry.total {
// A fragment claiming bytes past the declared end.
r.poison(entry)
return nil
}
// A duplicate contributes no bytes — the held ones win, see addRange — but
// it can still be the fragment that COMPLETES the datagram: a last fragment
// (MF=0) whose range is already covered contributes only the total length.
// So a duplicate falls through to the completion check instead of returning
// here. Returning early stranded such a datagram forever: every later
// fragment is a duplicate too, so nothing would re-examine the entry and it
// died at its deadline with all its bytes present.
duplicate := false
switch entry.addRange(info.start, end) {
case fragInsertDuplicate:
duplicate = true
case fragInsertConflict:
r.poison(entry)
return nil
}
if !duplicate {
if !r.grow(entry, end) {
r.poison(entry)
return nil
}
copy(entry.payload[info.start:end], info.payload)
}
if info.unfragmentable != nil && entry.header == nil {
stored := make([]byte, len(info.unfragmentable))
copy(stored, info.unfragmentable)
if info.nextHeaderPos >= 0 && info.nextHeaderPos < len(stored) {
// Splice the fragment extension header out of the chain.
stored[info.nextHeaderPos] = info.nextHeader
}
entry.header = stored
r.bytes += len(stored)
r.evictWhileOverBudget(entry)
}
if !entry.complete() {
return nil
}
assembled := entry.build(prefix)
r.remove(entry)
return assembled
}
// grow makes sure entry.payload can hold need bytes.
func (r *fragmentReassembler) grow(entry *fragEntry, need int) bool {
if need <= len(entry.payload) {
return true
}
if need > fragMaxDatagram {
return false
}
size := len(entry.payload)
if size == 0 {
size = fragInitialPayload
}
for size < need {
size *= 2
}
if size > fragMaxDatagram {
size = fragMaxDatagram
}
grown := make([]byte, size)
copy(grown, entry.payload)
r.bytes += size - len(entry.payload)
entry.payload = grown
r.evictWhileOverBudget(entry)
return true
}
// evictWhileOverBudget drops oldest entries until the byte budget fits again.
// keep is never evicted; a single entry can never exceed the budget on its own
// (fragMaxDatagram + a 60-byte header is far under fragMaxTotalBytes), so this
// always terminates with the budget satisfied.
func (r *fragmentReassembler) evictWhileOverBudget(keep *fragEntry) {
for r.bytes > fragMaxTotalBytes {
if !r.evictOldest(keep) {
return
}
}
}
// evictOldest removes the entry with the earliest deadline, which — because
// deadlines are set once at creation and never refreshed — is the oldest one.
func (r *fragmentReassembler) evictOldest(keep *fragEntry) bool {
var oldest *fragEntry
for _, entry := range r.entries {
if entry == keep {
continue
}
if oldest == nil || entry.deadline < oldest.deadline {
oldest = entry
}
}
if oldest == nil {
return false
}
r.remove(oldest)
return true
}
func (r *fragmentReassembler) sweep(now int64) {
for key, entry := range r.entries {
if now >= entry.deadline {
r.bytes -= entry.size()
delete(r.entries, key)
}
}
}
func (r *fragmentReassembler) remove(entry *fragEntry) {
r.bytes -= entry.size()
delete(r.entries, entry.key)
}
// poison keeps the key occupied until its deadline but releases its memory:
// once a conflicting fragment has been seen, nothing under this key will be
// assembled. Holding the key (rather than deleting it) is what makes the
// refusal stick — otherwise the next fragment would open a fresh entry and the
// sender could still pick which bytes we assemble.
func (r *fragmentReassembler) poison(entry *fragEntry) {
r.bytes -= entry.size()
entry.header = nil
entry.payload = nil
entry.ranges = nil
entry.broken = true
}
// build materialises the reassembled datagram. The result owns its memory.
func (e *fragEntry) build(prefix int) []byte {
switch e.key.version {
case 4:
totalLength := len(e.header) + e.total
if totalLength > 65535 {
return nil
}
buffer := make([]byte, prefix+totalLength)
copy(buffer[prefix:], e.header)
copy(buffer[prefix+len(e.header):], e.payload[:e.total])
assembled := header.IPv4(buffer[prefix:])
assembled.SetTotalLength(uint16(totalLength))
// Clears MF and the fragment offset. DF is necessarily already 0 — a
// fragment cannot carry it.
assembled.SetFlagsFragmentOffset(0, 0)
// CalculateChecksum sums the header EXCLUDING the checksum field, so
// the stale value copied from the first fragment does not contribute.
assembled.SetChecksum(^assembled.CalculateChecksum())
return buffer
case 6:
payloadLength := len(e.header) - header.IPv6MinimumSize + e.total
if payloadLength > 65535 {
return nil
}
buffer := make([]byte, prefix+len(e.header)+e.total)
copy(buffer[prefix:], e.header)
copy(buffer[prefix+len(e.header):], e.payload[:e.total])
assembled := header.IPv6(buffer[prefix:])
assembled.SetPayloadLength(uint16(payloadLength))
return buffer
default:
return nil
}
}
@@ -0,0 +1,163 @@
// lx: the Linux half of the bridge return-path fragment tests.
//
// backendBase.readLoop (covered portably in frag_reassembly_lx_test.go) is the
// fallback; the path that actually runs on a Linux router is
// backendLinux.batchReadLoop, which reads whole batches straight into
// headroom-prefixed buffers and never copies a whole packet. It therefore calls
// the fragment seam with a NON-ZERO prefix, and it is the only call site where
// a reassembled datagram is handed on without going through a second copy —
// so it needs its own instrument rather than an argument by analogy.
//go:build linux
package bridge
import (
"io"
"testing"
"time"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/sagernet/sing/common/logger"
)
// fragTestBatchTUN replays one scripted batch per BatchRead call and then
// closes the backend so the loop exits.
type fragTestBatchTUN struct {
fragTestTun
batches [][][]byte
index int
closed chan struct{}
}
func (t *fragTestBatchTUN) FrontHeadroom() int { return 0 }
func (t *fragTestBatchTUN) BatchSize() int { return 8 }
func (t *fragTestBatchTUN) TXChecksumOffload() bool { return false }
func (t *fragTestBatchTUN) BatchWrite(buffers [][]byte, offset int) (int, error) {
return len(buffers), nil
}
func (t *fragTestBatchTUN) BatchRead(buffers [][]byte, offset int, readN []int) (int, error) {
if t.index >= len(t.batches) {
close(t.closed)
return 0, io.EOF
}
batch := t.batches[t.index]
t.index++
for i, packet := range batch {
if offset+len(packet) > len(buffers[i]) {
return 0, io.ErrShortBuffer
}
copy(buffers[i][offset:], packet)
readN[i] = len(packet)
}
return len(batch), nil
}
// runBatchReadLoop drives the real backendLinux.batchReadLoop over the script
// and returns what the return path was handed.
func runBatchReadLoop(t *testing.T, headroom int, batches ...[][]byte) *fragTestReturn {
t.Helper()
returnPath := &fragTestReturn{headroom: headroom}
closed := make(chan struct{})
batchTUN := &fragTestBatchTUN{batches: batches, closed: closed}
backend := &backendLinux{}
backend.logger = logger.NOP()
backend.returnPaths = []tun.Return{returnPath}
backend.closed = closed
backend.readDone = make(chan struct{})
backend.batchTUN = batchTUN
go backend.batchReadLoop()
select {
case <-backend.readDone:
case <-time.After(5 * time.Second):
t.Fatal("batchReadLoop did not finish")
}
return returnPath
}
// TestBridgeBatchReadLoopDeliversFragmentedDatagram is the Linux instrument:
// three fragments arriving in one BatchRead must reach the return path as ONE
// whole datagram, carrying the path's headroom. Before the seam existed the
// return path saw three fragments, classifyReturn passed every one of them, and
// batchReadLoop dropped the lot.
func TestBridgeBatchReadLoopDeliversFragmentedDatagram(t *testing.T) {
datagram := ipv4Datagram(141, udpSegment(fragTestBody(900)))
fragments := ipv4Fragments(datagram, 320)
if len(fragments) != 3 {
t.Fatalf("test setup produced %d fragments, want 3", len(fragments))
}
returnPath := runBatchReadLoop(t, 16, fragments)
delivered := returnPath.payloads()
if len(delivered) != 1 {
t.Fatalf("the return path was handed %d packets, want exactly 1 whole datagram — %d means batchReadLoop still offers raw fragments, which classifyReturn refuses to judge and this loop then drops", len(delivered), len(delivered))
}
// batchReadLoop deliberately does not run fixReturnChecksum (BatchRead
// completes kernel-deferred checksums itself), so the payload must come
// back byte-for-byte, placeholder UDP checksum and all.
if !bytesEqual(delivered[0], datagram) {
t.Fatalf("the delivered datagram is not byte-identical to the original (%d bytes vs %d)", len(delivered[0]), len(datagram))
}
if header.IPv4(delivered[0]).More() || header.IPv4(delivered[0]).FragmentOffset() != 0 {
t.Fatal("the delivered packet still looks like a fragment")
}
}
// TestBridgeBatchReadLoopWholePacketsUnchanged is the control: the same loop,
// the same assertions, unfragmented packets. It shows the harness can report a
// delivery, and it pins that whole packets still travel by reference out of the
// read buffers rather than through the cache.
func TestBridgeBatchReadLoopWholePacketsUnchanged(t *testing.T) {
first := ipv4Datagram(142, udpSegment(fragTestBody(120)))
second := ipv4Datagram(143, udpSegment(fragTestBody(240)))
returnPath := runBatchReadLoop(t, 16, [][]byte{first, second})
delivered := returnPath.payloads()
if len(delivered) != 2 {
t.Fatalf("the return path was handed %d packets, want 2", len(delivered))
}
if !bytesEqual(delivered[0], first) || !bytesEqual(delivered[1], second) {
t.Fatal("an ordinary packet was altered on its way through the fragment seam")
}
}
// TestBridgeBatchReadLoopMixedBatch: fragments and whole packets in one batch.
// The whole packets must not be held back, and the datagram must appear as soon
// as its last fragment does — in the same batch, in arrival order.
func TestBridgeBatchReadLoopMixedBatch(t *testing.T) {
fragmented := ipv4Datagram(144, udpSegment(fragTestBody(700)))
fragments := ipv4Fragments(fragmented, 360)
whole := ipv4Datagram(145, udpSegment(fragTestBody(96)))
returnPath := runBatchReadLoop(t, 8, [][]byte{fragments[0], whole, fragments[1]})
delivered := returnPath.payloads()
if len(delivered) != 2 {
t.Fatalf("the return path was handed %d packets, want 2 (one whole + one reassembled)", len(delivered))
}
if !bytesEqual(delivered[0], whole) {
t.Fatal("the whole packet was delayed or reordered behind the fragments")
}
if !bytesEqual(delivered[1], fragmented) {
t.Fatal("the reassembled datagram was not delivered when its last fragment arrived")
}
}
// TestBridgeBatchReadLoopSpansBatches pins that a datagram whose fragments
// arrive in DIFFERENT BatchRead calls still completes: the read buffers are
// reused between batches, so this only works because the cache copies.
func TestBridgeBatchReadLoopSpansBatches(t *testing.T) {
datagram := ipv4Datagram(146, udpSegment(fragTestBody(900)))
fragments := ipv4Fragments(datagram, 320)
returnPath := runBatchReadLoop(t, 16,
[][]byte{fragments[0]},
[][]byte{fragments[1]},
[][]byte{fragments[2]},
)
delivered := returnPath.payloads()
if len(delivered) != 1 {
t.Fatalf("the return path was handed %d packets, want 1", len(delivered))
}
if !bytesEqual(delivered[0], datagram) {
t.Fatal("a datagram whose fragments spanned three read batches came back corrupted — the cache is holding slices of buffers the loop reuses")
}
}
+977
View File
@@ -0,0 +1,977 @@
// lx: tests for return-path IP fragment reassembly on the bridge outbound
// (frag_reassembly.go + the seam in backend.go / backend_linux.go /
// backend_darwin.go).
//
// The defect these pin: sing-tun's classifyReturn answers `returnPass` for any
// fragment, and the bridge read loops DROP whatever no return path claimed —
// there is no second consumer here the way the WireGuard endpoint has its own
// tun stack. So before this code a fragmented answer coming back through a
// bridge outbound was lost outright.
//
// The instrument that proves it is TestBridgeReadLoopDeliversFragmentedDatagram
// below: it drives the REAL backendBase.readLoop over a fake tun and asserts
// what the return path is handed. Its control twin
// (TestBridgeReadLoopDeliversWholeDatagram) shows the same instrument reporting
// the positive case, so a green fragment test is not just a broken probe.
//go:build linux || darwin || (windows && (amd64 || 386))
package bridge
import (
"encoding/binary"
"io"
"net/netip"
"sync"
"testing"
"time"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/sagernet/sing/common/logger"
)
var (
fragTestSource = netip.MustParseAddr("198.51.100.7")
fragTestDestination = netip.MustParseAddr("192.0.2.1")
fragTestSource6 = netip.MustParseAddr("2001:db8:1::7")
fragTestDest6 = netip.MustParseAddr("2001:db8::1")
)
// ------------------------------------------------------------- builders -----
func fragTestBody(size int) []byte {
body := make([]byte, size)
for i := range body {
body[i] = byte(i*7 + 3)
}
return body
}
// udpSegment builds a UDP header + body carrying a DELIBERATELY WRONG checksum,
// so a test can tell whether fixReturnChecksum ran over the datagram that was
// finally delivered.
func udpSegment(body []byte) []byte {
segment := make([]byte, header.UDPMinimumSize+len(body))
binary.BigEndian.PutUint16(segment[0:], 4000)
binary.BigEndian.PutUint16(segment[2:], 53)
binary.BigEndian.PutUint16(segment[4:], uint16(len(segment)))
binary.BigEndian.PutUint16(segment[6:], 0xdead)
copy(segment[header.UDPMinimumSize:], body)
return segment
}
// udpChecksumValid verifies the UDP checksum of a whole IPv4 datagram the way a
// receiver does: summing the pseudo-header and the segment INCLUDING the
// checksum field must yield 0xffff.
func udpChecksumValid(datagram []byte) bool {
ipHdr := header.IPv4(datagram)
segment := ipHdr.Payload()
pseudo := header.PseudoHeaderChecksum(header.UDPProtocolNumber, ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice(), uint16(len(segment)))
return checksum.Checksum(segment, pseudo) == 0xffff
}
// udpBody returns the bytes after the UDP header of a whole IPv4 datagram. The
// checksum field is deliberately excluded: fixReturnChecksum rewrites it, which
// is the point of the udpChecksumValid assertion next to every use of this.
func udpBody(datagram []byte) []byte {
return header.IPv4(datagram).Payload()[header.UDPMinimumSize:]
}
// ipv4Datagram builds a whole IPv4 packet whose payload is segment.
func ipv4Datagram(id uint16, segment []byte) []byte {
packet := make([]byte, header.IPv4MinimumSize+len(segment))
ipHdr := header.IPv4(packet)
ipHdr.Encode(&header.IPv4Fields{
TotalLength: uint16(len(packet)),
ID: id,
TTL: 64,
Protocol: uint8(header.UDPProtocolNumber),
SrcAddr: fragTestSource,
DstAddr: fragTestDestination,
})
copy(packet[header.IPv4MinimumSize:], segment)
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
return packet
}
// ipv4Fragment carves one fragment out of datagram: payload bytes
// [start, start+length) with the given more-fragments flag.
func ipv4Fragment(datagram []byte, start int, length int, more bool) []byte {
source := header.IPv4(datagram)
payload := source.Payload()
fragment := make([]byte, header.IPv4MinimumSize+length)
copy(fragment, datagram[:header.IPv4MinimumSize])
copy(fragment[header.IPv4MinimumSize:], payload[start:start+length])
ipHdr := header.IPv4(fragment)
ipHdr.SetTotalLength(uint16(len(fragment)))
var flags uint8
if more {
flags = header.IPv4FlagMoreFragments
}
ipHdr.SetFlagsFragmentOffset(flags, uint16(start))
ipHdr.SetChecksum(0)
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
return fragment
}
// ipv4Fragments splits a datagram into fragments of at most size payload bytes.
func ipv4Fragments(datagram []byte, size int) [][]byte {
payload := header.IPv4(datagram).Payload()
var fragments [][]byte
for start := 0; start < len(payload); start += size {
length := min(size, len(payload)-start)
fragments = append(fragments, ipv4Fragment(datagram, start, length, start+length < len(payload)))
}
return fragments
}
const (
fragTestExtLength = 8 // one Destination Options header with no options
fragTestDstOptsNext = 60
)
// ipv6Datagram builds a whole IPv6 packet whose payload is segment, optionally
// behind a Destination Options extension header.
func ipv6Datagram(extension bool, segment []byte) []byte {
unfragmentable := header.IPv6MinimumSize
if extension {
unfragmentable += fragTestExtLength
}
packet := make([]byte, unfragmentable+len(segment))
ipHdr := header.IPv6(packet)
ipHdr.Encode(&header.IPv6Fields{
PayloadLength: uint16(len(packet) - header.IPv6MinimumSize),
TransportProtocol: header.UDPProtocolNumber,
HopLimit: 64,
SrcAddr: fragTestSource6,
DstAddr: fragTestDest6,
})
if extension {
ipHdr.SetNextHeader(fragTestDstOptsNext)
packet[header.IPv6MinimumSize] = uint8(header.UDPProtocolNumber)
packet[header.IPv6MinimumSize+1] = 0 // (0+1)*8 = 8 bytes
}
copy(packet[unfragmentable:], segment)
return packet
}
// ipv6Fragment carves a fragment out of an ipv6Datagram, splicing the fragment
// extension header in after the unfragmentable part.
func ipv6Fragment(datagram []byte, extension bool, id uint32, start int, length int, more bool) []byte {
unfragmentable := header.IPv6MinimumSize
if extension {
unfragmentable += fragTestExtLength
}
payload := datagram[unfragmentable:]
fragment := make([]byte, unfragmentable+header.IPv6FragmentHeaderSize+length)
copy(fragment, datagram[:unfragmentable])
if extension {
fragment[header.IPv6MinimumSize] = header.IPv6FragmentHeader
} else {
header.IPv6(fragment).SetNextHeader(header.IPv6FragmentHeader)
}
fragHdr := fragment[unfragmentable:]
fragHdr[0] = uint8(header.UDPProtocolNumber)
fragHdr[1] = 0
offsetAndFlags := uint16(start/8) << 3
if more {
offsetAndFlags |= 1
}
binary.BigEndian.PutUint16(fragHdr[2:], offsetAndFlags)
binary.BigEndian.PutUint32(fragHdr[4:], id)
copy(fragment[unfragmentable+header.IPv6FragmentHeaderSize:], payload[start:start+length])
header.IPv6(fragment).SetPayloadLength(uint16(len(fragment) - header.IPv6MinimumSize))
return fragment
}
func bytesEqual(a []byte, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// --------------------------------------------------------------- doubles ----
// fragTestReturn is the l3 return path. It mimics sing-tun's forwardReturn
// filter-in-place (`unconsumed := packets[:0]`), so the caller's aliasing
// hazard is exercised the same way it is in production.
type fragTestReturn struct {
headroom int
accept func([]byte) bool
access sync.Mutex
received [][]byte
}
func (r *fragTestReturn) ReturnHeadroom() int { return r.headroom }
func (r *fragTestReturn) ReturnPackets(packets [][]byte) [][]byte {
unconsumed := packets[:0]
for _, packet := range packets {
payload := packet[r.headroom:]
if r.accept != nil && !r.accept(payload) {
unconsumed = append(unconsumed, packet)
continue
}
stored := make([]byte, len(payload))
copy(stored, payload)
r.access.Lock()
r.received = append(r.received, stored)
r.access.Unlock()
}
return unconsumed
}
func (r *fragTestReturn) payloads() [][]byte {
r.access.Lock()
defer r.access.Unlock()
return append([][]byte(nil), r.received...)
}
// fragTestTun replays a fixed script of packets to backendBase.readLoop and
// then reports EOF, which is how readLoop terminates.
type fragTestTun struct {
packets [][]byte
index int
}
func (t *fragTestTun) Read(p []byte) (int, error) {
if t.index >= len(t.packets) {
return 0, io.EOF
}
packet := t.packets[t.index]
t.index++
if tun.PacketOffset+len(packet) > len(p) {
return 0, io.ErrShortBuffer
}
clear(p[:tun.PacketOffset])
copy(p[tun.PacketOffset:], packet)
// Scribble past the packet so a reassembler that retained a slice of this
// buffer instead of copying would be caught by the next read.
for i := tun.PacketOffset + len(packet); i < len(p); i++ {
p[i] = 0xa5
}
return tun.PacketOffset + len(packet), nil
}
func (t *fragTestTun) Write(p []byte) (int, error) { return len(p), nil }
func (t *fragTestTun) Name() (string, error) { return "frag-test", nil }
func (t *fragTestTun) Start() error { return nil }
func (t *fragTestTun) Close() error { return nil }
func (t *fragTestTun) UpdateRouteOptions(_ tun.Options) error { return nil }
// runReadLoop drives the real readLoop over the given script and returns what
// the return path was handed.
func runReadLoop(t *testing.T, headroom int, packets ...[]byte) *fragTestReturn {
t.Helper()
returnPath := &fragTestReturn{headroom: headroom}
backend := &backendBase{
logger: logger.NOP(),
tunInterface: &fragTestTun{packets: packets},
returnPaths: []tun.Return{returnPath},
closed: make(chan struct{}),
readDone: make(chan struct{}),
}
go backend.readLoop()
select {
case <-backend.readDone:
case <-time.After(5 * time.Second):
t.Fatal("readLoop did not finish")
}
return returnPath
}
// clock is a monotonic test clock for the reassembler's nowFunc hook.
type fragTestClock struct {
access sync.Mutex
value int64
}
func (c *fragTestClock) now() int64 {
c.access.Lock()
defer c.access.Unlock()
return c.value
}
func (c *fragTestClock) advance(d time.Duration) {
c.access.Lock()
defer c.access.Unlock()
c.value += int64(d)
}
// ------------------------------------------------------------- fast path ----
func TestBridgeIsFragmentPacket(t *testing.T) {
whole4 := ipv4Datagram(1, udpSegment(fragTestBody(64)))
df4 := ipv4Datagram(2, udpSegment(fragTestBody(64)))
header.IPv4(df4).SetFlagsFragmentOffset(header.IPv4FlagDontFragment, 0)
whole6 := ipv6Datagram(false, udpSegment(fragTestBody(64)))
ext6 := ipv6Datagram(true, udpSegment(fragTestBody(64)))
frag6 := ipv6Fragment(whole6, false, 9, 0, 64, true)
fragExt6 := ipv6Fragment(ext6, true, 9, 0, 64, true)
// An IPv6 packet whose next header is neither a fragment header nor one of
// the three that may precede one: the walk must stop, not keep looking.
esp6 := ipv6Datagram(false, udpSegment(fragTestBody(64)))
header.IPv6(esp6).SetNextHeader(50)
for _, testCase := range []struct {
name string
packet []byte
want bool
}{
{"ipv4 whole", whole4, false},
{"ipv4 don't fragment", df4, false},
{"ipv4 first fragment", ipv4Fragment(whole4, 0, 32, true), true},
{"ipv4 last fragment", ipv4Fragment(whole4, 32, 40, false), true},
{"ipv6 whole", whole6, false},
{"ipv6 with destination options", ext6, false},
{"ipv6 unknown next header", esp6, false},
{"ipv6 fragment", frag6, true},
{"ipv6 fragment behind extension", fragExt6, true},
{"too short", []byte{0x45, 0, 0}, false},
{"not ip", []byte{0x99, 0, 0, 0, 0, 0, 0, 0, 0, 0}, false},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := isFragmentPacket(testCase.packet); got != testCase.want {
t.Fatalf("isFragmentPacket = %v, want %v — the predicate decides whether a packet pays for reassembly at all: a false negative loses the datagram, a false positive drags every ordinary packet through the cache", got, testCase.want)
}
})
}
}
// TestBridgeReturnDatagramWholePacketUntouched is the guard on "do not break
// what works": a packet that is not a fragment must come back as the very same
// slice, and the cache must not have allocated a thing.
func TestBridgeReturnDatagramWholePacketUntouched(t *testing.T) {
backend := &backendBase{}
for _, prefix := range []int{0, 16} {
datagram := ipv4Datagram(11, udpSegment(fragTestBody(200)))
buffer := make([]byte, prefix+len(datagram))
copy(buffer[prefix:], datagram)
got := backend.returnDatagram(buffer, prefix)
if len(got) != len(buffer) || &got[0] != &buffer[0] {
t.Fatalf("prefix %d: a whole packet was copied instead of passed through — that is a per-packet allocation on the hot path of every bridge read", prefix)
}
}
if backend.fragments.entries != nil || backend.fragments.bytes != 0 {
t.Fatalf("the reassembly cache allocated for packets that are not fragments (entries=%v bytes=%d)", backend.fragments.entries, backend.fragments.bytes)
}
}
// ------------------------------------------------------------ reassembly ----
func TestBridgeReturnDatagramReassemblesIPv4(t *testing.T) {
for _, testCase := range []struct {
name string
body int
fragment int
prefix int
}{
{"two fragments", 400, 256, 0},
{"three fragments", 900, 320, 0},
{"with headroom", 900, 320, 24},
{"reverse order", 400, 256, 8},
} {
t.Run(testCase.name, func(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(21, udpSegment(fragTestBody(testCase.body)))
fragments := ipv4Fragments(datagram, testCase.fragment)
if testCase.name == "reverse order" {
for i, j := 0, len(fragments)-1; i < j; i, j = i+1, j-1 {
fragments[i], fragments[j] = fragments[j], fragments[i]
}
}
var assembled []byte
for index, fragment := range fragments {
buffer := make([]byte, testCase.prefix+len(fragment))
copy(buffer[testCase.prefix:], fragment)
got := backend.returnDatagram(buffer, testCase.prefix)
if index < len(fragments)-1 {
if got != nil {
t.Fatalf("fragment %d of %d already produced a datagram — an incomplete datagram must not be delivered", index, len(fragments))
}
continue
}
assembled = got
}
if assembled == nil {
t.Fatal("the last fragment did not complete the datagram — this is exactly the loss the seam exists to close")
}
if len(assembled) != testCase.prefix+len(datagram) {
t.Fatalf("assembled length = %d, want %d (prefix %d + datagram %d)", len(assembled), testCase.prefix+len(datagram), testCase.prefix, len(datagram))
}
result := header.IPv4(assembled[testCase.prefix:])
if !bytesEqual(result.Payload(), header.IPv4(datagram).Payload()) {
t.Fatal("the reassembled payload differs from the original datagram")
}
if result.More() || result.FragmentOffset() != 0 {
t.Fatalf("the reassembled packet still looks like a fragment (more=%v offset=%d) — classifyReturn would refuse it exactly as before", result.More(), result.FragmentOffset())
}
if int(result.TotalLength()) != len(datagram) {
t.Fatalf("TotalLength = %d, want %d", result.TotalLength(), len(datagram))
}
if result.Checksum() != ^result.CalculateChecksum() {
t.Fatal("the IPv4 header checksum of the reassembled packet is stale — it was copied from the first fragment, whose TotalLength and fragment flags no longer describe this packet")
}
})
}
}
func TestBridgeReturnDatagramReassemblesIPv6(t *testing.T) {
for _, extension := range []bool{false, true} {
name := "plain"
unfragmentable := header.IPv6MinimumSize
if extension {
name = "behind destination options"
unfragmentable += fragTestExtLength
}
t.Run(name, func(t *testing.T) {
backend := &backendBase{}
datagram := ipv6Datagram(extension, udpSegment(fragTestBody(600)))
payload := datagram[unfragmentable:]
var assembled []byte
for start := 0; start < len(payload); start += 256 {
length := min(256, len(payload)-start)
more := start+length < len(payload)
fragment := ipv6Fragment(datagram, extension, 0x11223344, start, length, more)
assembled = backend.returnDatagram(fragment, 0)
if more && assembled != nil {
t.Fatal("an incomplete IPv6 datagram was delivered")
}
}
if assembled == nil {
t.Fatal("the last IPv6 fragment did not complete the datagram")
}
if !bytesEqual(assembled, datagram) {
t.Fatalf("the reassembled IPv6 datagram is not byte-identical to the original (got %d bytes, want %d) — the fragment header must be spliced out of the chain and PayloadLength restored", len(assembled), len(datagram))
}
if isFragmentPacket(assembled) {
t.Fatal("the reassembled IPv6 packet still carries a fragment header")
}
})
}
}
// TestBridgeReturnDatagramAtomicIPv6 covers RFC 8200 §4.5 atomic fragments —
// one fragment header, offset 0, M=0 — which must lose the header and be
// delivered immediately.
func TestBridgeReturnDatagramAtomicIPv6(t *testing.T) {
backend := &backendBase{}
datagram := ipv6Datagram(false, udpSegment(fragTestBody(64)))
payload := datagram[header.IPv6MinimumSize:]
atomic := ipv6Fragment(datagram, false, 7, 0, len(payload), false)
assembled := backend.returnDatagram(atomic, 0)
if assembled == nil {
t.Fatal("an atomic IPv6 fragment was swallowed — it is a whole datagram wearing a fragment header, and dropping it loses traffic no reassembly was ever needed for")
}
if !bytesEqual(assembled, datagram) {
t.Fatal("the atomic fragment did not reduce to the original datagram")
}
}
// TestBridgeReturnDatagramBorrowedBufferIsCopied proves the cache does not
// retain the caller's buffer. Every read loop in this package reuses its
// buffers on the next read, so a retained slice would be a use-after-free that
// shows up as corrupted traffic, not as a crash.
//
// Scribbling alone is not enough of an instrument: the fragments of one
// datagram carry NEARLY identical headers, so a retained IPv4 header is
// overwritten by an equally valid one and the corruption is invisible. Each
// fragment below therefore carries a distinguishable TTL, and the IPv6 half
// asserts the caller's buffer itself is left alone — the next-header splice
// must land on the copy.
func TestBridgeReturnDatagramBorrowedBufferIsCopied(t *testing.T) {
t.Run("ipv4", func(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(31, udpSegment(fragTestBody(600)))
fragments := ipv4Fragments(datagram, 256)
scratch := make([]byte, 2048)
var assembled []byte
for index, fragment := range fragments {
buffer := scratch[:len(fragment)]
copy(buffer, fragment)
// Only the offset-0 fragment contributes the header the assembled
// datagram must wear; give the rest a TTL nobody else uses.
if index > 0 {
header.IPv4(buffer).SetTTL(9)
header.IPv4(buffer).SetChecksum(0)
header.IPv4(buffer).SetChecksum(^header.IPv4(buffer).CalculateChecksum())
}
assembled = backend.returnDatagram(buffer, 0)
for i := range buffer {
buffer[i] = 0x5a
}
}
if assembled == nil {
t.Fatal("no datagram was assembled")
}
if got := header.IPv4(assembled).TTL(); got != 64 {
t.Fatalf("the assembled datagram wears TTL %d, want 64 — its header was read back out of the caller's buffer AFTER that buffer had been reused, i.e. the cache kept a slice instead of a copy", got)
}
if !bytesEqual(header.IPv4(assembled).Payload(), header.IPv4(datagram).Payload()) {
t.Fatal("the assembled datagram carries the scribbled-over bytes — the cache retained the caller's buffer instead of copying")
}
})
t.Run("ipv6", func(t *testing.T) {
backend := &backendBase{}
datagram := ipv6Datagram(false, udpSegment(fragTestBody(600)))
payload := datagram[header.IPv6MinimumSize:]
scratch := make([]byte, 2048)
var assembled []byte
for start := 0; start < len(payload); start += 256 {
length := min(256, len(payload)-start)
fragment := ipv6Fragment(datagram, false, 0x55, start, length, start+length < len(payload))
buffer := scratch[:len(fragment)]
copy(buffer, fragment)
assembled = backend.returnDatagram(buffer, 0)
if start == 0 && buffer[header.IPv6NextHeaderOffset] != header.IPv6FragmentHeader {
t.Fatalf("the caller's buffer was rewritten: next header is now %d, was %d — the fragment header was spliced out of the BORROWED bytes, so the cache is holding a slice of a buffer the read loop is about to reuse", buffer[header.IPv6NextHeaderOffset], header.IPv6FragmentHeader)
}
for i := range buffer {
buffer[i] = 0x5a
}
}
if assembled == nil {
t.Fatal("no IPv6 datagram was assembled")
}
if !bytesEqual(assembled, datagram) {
t.Fatal("the assembled IPv6 datagram carries the scribbled-over bytes")
}
})
}
// ------------------------------------------------------------ read loop -----
// TestBridgeReadLoopDeliversFragmentedDatagram is the end-to-end instrument:
// the real backendBase.readLoop, the real seam, a real return path. Before the
// seam existed this test saw three fragments arrive (each of which sing-tun's
// classifyReturn passes, after which the bridge drops it).
func TestBridgeReadLoopDeliversFragmentedDatagram(t *testing.T) {
body := fragTestBody(900)
datagram := ipv4Datagram(41, udpSegment(body))
fragments := ipv4Fragments(datagram, 320)
if len(fragments) != 3 {
t.Fatalf("test setup produced %d fragments, want 3", len(fragments))
}
returnPath := runReadLoop(t, 12, fragments...)
delivered := returnPath.payloads()
if len(delivered) != 1 {
t.Fatalf("the return path was handed %d packets, want exactly 1 whole datagram — %d means the fragments went out as fragments, which classifyReturn refuses to judge and the bridge then drops", len(delivered), len(delivered))
}
if !bytesEqual(udpBody(delivered[0]), body) {
t.Fatal("the delivered datagram does not carry the original payload")
}
if !udpChecksumValid(delivered[0]) {
t.Fatal("the delivered datagram carries the placeholder UDP checksum — fixReturnChecksum did not run over the REASSEMBLED packet, so a kernel that deferred the checksum would have its offload dropped on the floor")
}
}
// TestBridgeReadLoopDeliversWholeDatagram is the control: the same instrument,
// the same assertions, an unfragmented packet. It shows the harness can report
// a delivery at all, so the fragment test's green is a result and not a probe
// that never fires.
func TestBridgeReadLoopDeliversWholeDatagram(t *testing.T) {
body := fragTestBody(200)
returnPath := runReadLoop(t, 12, ipv4Datagram(42, udpSegment(body)))
delivered := returnPath.payloads()
if len(delivered) != 1 {
t.Fatalf("the return path was handed %d packets, want 1", len(delivered))
}
if !bytesEqual(udpBody(delivered[0]), body) {
t.Fatal("the delivered datagram does not carry the original payload")
}
if !udpChecksumValid(delivered[0]) {
t.Fatal("fixReturnChecksum did not run over an ordinary packet")
}
}
// TestBridgeReadLoopMixedBatch: a fragmented datagram interleaved with whole
// packets must deliver all three payloads, and the whole packets must not be
// held back waiting for the fragments.
func TestBridgeReadLoopMixedBatch(t *testing.T) {
fragmentedBody := fragTestBody(700)
firstBody := fragTestBody(48)
secondBody := fragTestBody(64)
fragments := ipv4Fragments(ipv4Datagram(43, udpSegment(fragmentedBody)), 360)
first := ipv4Datagram(44, udpSegment(firstBody))
second := ipv4Datagram(45, udpSegment(secondBody))
returnPath := runReadLoop(t, 4, fragments[0], first, fragments[1], second)
delivered := returnPath.payloads()
if len(delivered) != 3 {
t.Fatalf("the return path was handed %d packets, want 3 (two whole + one reassembled)", len(delivered))
}
if !bytesEqual(udpBody(delivered[0]), firstBody) {
t.Fatal("the first whole packet was delayed or reordered behind the fragments")
}
if !bytesEqual(udpBody(delivered[1]), fragmentedBody) {
t.Fatal("the reassembled datagram was not delivered when its last fragment arrived")
}
if !bytesEqual(udpBody(delivered[2]), secondBody) {
t.Fatal("the second whole packet was lost")
}
}
// --------------------------------------------------------------- limits -----
// TestBridgeFragmentTimeout: an incomplete datagram must free its memory and
// must not be completable afterwards. Paired with a control that completes
// before the deadline, so the instrument is shown able to report both.
func TestBridgeFragmentTimeout(t *testing.T) {
datagram := ipv4Datagram(51, udpSegment(fragTestBody(600)))
fragments := ipv4Fragments(datagram, 256)
t.Run("completes before the deadline", func(t *testing.T) {
clock := &fragTestClock{value: 1}
backend := &backendBase{}
backend.fragments.nowFunc = clock.now
for i, fragment := range fragments[:len(fragments)-1] {
if got := backend.returnDatagram(fragment, 0); got != nil {
t.Fatalf("fragment %d completed the datagram early", i)
}
}
clock.advance(fragTimeout - time.Millisecond)
if backend.returnDatagram(fragments[len(fragments)-1], 0) == nil {
t.Fatal("a datagram completed just inside the deadline was refused")
}
})
t.Run("expires at the deadline", func(t *testing.T) {
clock := &fragTestClock{value: 1}
backend := &backendBase{}
backend.fragments.nowFunc = clock.now
for _, fragment := range fragments[:len(fragments)-1] {
backend.returnDatagram(fragment, 0)
}
if backend.fragments.bytes == 0 {
t.Fatal("nothing was cached, so the expiry assertion below would pass on an empty cache")
}
clock.advance(fragTimeout)
if got := backend.returnDatagram(fragments[len(fragments)-1], 0); got != nil {
t.Fatal("a datagram was assembled from a head that had already expired")
}
if backend.fragments.bytes > fragMaxDatagram {
t.Fatalf("the expired entry's memory was not released (bytes=%d)", backend.fragments.bytes)
}
})
}
// fragTestIPv4Key is the cache key a test datagram built by ipv4Datagram lands
// under. Spelled once, because getting it wrong is how this file's sweep test
// came to assert nothing (see TestBridgeFragmentSweepIsPerCall).
func fragTestIPv4Key(id uint32) fragKey {
return fragKey{
source: fragTestSource,
destination: fragTestDestination,
id: id,
protocol: uint8(header.UDPProtocolNumber),
version: 4,
}
}
// TestBridgeFragmentSweepIsPerCall: the sweep must run on EVERY fragment, not
// only when a new key is created. Otherwise an entry that expired while another
// key stayed alive keeps its memory until some unrelated new datagram appears.
//
// THE PROBE MUST NOT OPEN A KEY, or the test proves nothing — a fragment that
// creates an entry sweeps even in the broken arrangement, so the assertion
// passes either way. fragKey carries the IPv4 identification (see fragKey), so
// a probe with a different id is a NEW key however firmly the comment beside it
// says "existing"; this test asserted exactly that for one wave, and the
// arrangement it names went unmeasured. The probe below is therefore the SECOND
// fragment of a datagram whose first fragment is already cached, and the
// assertion immediately before it pins that the entry really is there.
//
// The two entries need different deadlines to be told apart at the moment of
// the probe, and a deadline is set once at creation and never refreshed (see
// fragTimeout). So the stale entry is opened at t0 and the live one half a
// timeout later: at t0+fragTimeout the first is past its deadline and the
// second is not.
func TestBridgeFragmentSweepIsPerCall(t *testing.T) {
clock := &fragTestClock{value: 1}
backend := &backendBase{}
backend.fragments.nowFunc = clock.now
staleFragments := ipv4Fragments(ipv4Datagram(61, udpSegment(fragTestBody(600))), 256)
backend.returnDatagram(staleFragments[0], 0)
if backend.fragments.bytes == 0 {
t.Fatal("the stale fragment was not cached, so the sweep below would have nothing to fail to collect")
}
clock.advance(fragTimeout / 2)
liveFragments := ipv4Fragments(ipv4Datagram(62, udpSegment(fragTestBody(600))), 256)
if len(liveFragments) < 2 {
t.Fatalf("test setup produced %d live fragments, want at least 2 — the probe has to be a fragment that does NOT open a key", len(liveFragments))
}
backend.returnDatagram(liveFragments[0], 0)
if _, held := backend.fragments.entries[fragTestIPv4Key(61)]; !held {
t.Fatal("the stale entry was collected half a timeout early — then the assertion below could not tell a per-call sweep from a per-key one")
}
// Past the stale entry's deadline, still inside the live one's.
clock.advance(fragTimeout / 2)
if _, held := backend.fragments.entries[fragTestIPv4Key(62)]; !held {
t.Fatal("the live entry is gone, so the probe below would CREATE a key — and a key-creating fragment sweeps even when the sweep runs only on new keys")
}
staleSize := backend.fragments.bytes
backend.returnDatagram(liveFragments[1], 0)
if _, held := backend.fragments.entries[fragTestIPv4Key(61)]; held {
t.Fatal("the expired entry survived a fragment that did not create a new key — the sweep only runs on new keys, so a busy flow pins dead memory")
}
if backend.fragments.bytes >= staleSize {
t.Fatalf("the cache still accounts for %d bytes (was %d before the sweep) — the expired entry was unlinked without returning its bytes to the budget", backend.fragments.bytes, staleSize)
}
}
// TestBridgeFragmentEntryEviction: the (fragMaxEntries+1)-th datagram evicts
// the OLDEST, and the evicted one can no longer be completed.
func TestBridgeFragmentEntryEviction(t *testing.T) {
clock := &fragTestClock{value: 1}
backend := &backendBase{}
backend.fragments.nowFunc = clock.now
datagrams := make([][]byte, fragMaxEntries+1)
firsts := make([][]byte, fragMaxEntries+1)
lasts := make([][]byte, fragMaxEntries+1)
for i := range datagrams {
datagrams[i] = ipv4Datagram(uint16(1000+i), udpSegment(fragTestBody(400)))
parts := ipv4Fragments(datagrams[i], 256)
firsts[i], lasts[i] = parts[0], parts[1]
}
for i := range fragMaxEntries {
backend.returnDatagram(firsts[i], 0)
clock.advance(time.Millisecond)
}
if len(backend.fragments.entries) != fragMaxEntries {
t.Fatalf("cache holds %d entries, want %d", len(backend.fragments.entries), fragMaxEntries)
}
backend.returnDatagram(firsts[fragMaxEntries], 0)
if len(backend.fragments.entries) > fragMaxEntries {
t.Fatalf("the cache grew past its ceiling to %d entries", len(backend.fragments.entries))
}
if got := backend.returnDatagram(lasts[0], 0); got != nil {
t.Fatal("the oldest datagram survived eviction — then the ceiling is not a ceiling")
}
if got := backend.returnDatagram(lasts[fragMaxEntries], 0); got == nil {
t.Fatal("the newest datagram was evicted instead of the oldest — the control shows the instrument can complete a datagram at all")
}
}
// TestBridgeFragmentByteBudget: each datagram here declares a fragment at a
// high offset, forcing a near-maximum payload allocation, so a cache without a
// byte ceiling would grow to well over the budget.
func TestBridgeFragmentByteBudget(t *testing.T) {
clock := &fragTestClock{value: 1}
backend := &backendBase{}
backend.fragments.nowFunc = clock.now
for i := range 40 {
datagram := ipv4Datagram(uint16(2000+i), udpSegment(fragTestBody(600)))
// offset 60000 with MF set: a legal-looking fragment that forces the
// entry to allocate a 64 KiB payload.
high := ipv4Fragment(datagram, 0, 64, true)
header.IPv4(high).SetFlagsFragmentOffset(header.IPv4FlagMoreFragments, 60000)
backend.returnDatagram(high, 0)
clock.advance(time.Millisecond)
if backend.fragments.bytes > fragMaxTotalBytes+fragMaxDatagram {
t.Fatalf("cache grew to %d bytes after %d datagrams, past the %d-byte budget", backend.fragments.bytes, i+1, fragMaxTotalBytes)
}
}
if backend.fragments.bytes == 0 {
t.Fatal("nothing was cached at all, so the budget assertion never had anything to catch")
}
}
// TestBridgeFragmentOverlapRefused: a partially overlapping fragment poisons
// the datagram. Nothing is assembled under that key until the timeout, and the
// memory is released at once. Its control is the duplicate test below.
func TestBridgeFragmentOverlapRefused(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(71, udpSegment(fragTestBody(600)))
fragments := ipv4Fragments(datagram, 256)
backend.returnDatagram(fragments[0], 0)
// A fragment starting inside the range already held.
overlap := ipv4Fragment(datagram, 128, 256, true)
if got := backend.returnDatagram(overlap, 0); got != nil {
t.Fatal("an overlapping fragment produced a datagram")
}
if backend.fragments.bytes != 0 {
t.Fatalf("the poisoned entry kept %d bytes — poisoning must release memory, or an attacker allocates for free", backend.fragments.bytes)
}
for _, fragment := range fragments[1:] {
if got := backend.returnDatagram(fragment, 0); got != nil {
t.Fatal("the datagram was assembled after an overlap was seen — every historical vulnerability here comes from a reassembler that resolved the conflict instead of refusing")
}
}
}
// TestBridgeFragmentDuplicateAccepted is the control for the overlap policy:
// duplicates and retransmissions are benign and must NOT poison anything.
func TestBridgeFragmentDuplicateAccepted(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(81, udpSegment(fragTestBody(600)))
fragments := ipv4Fragments(datagram, 256)
backend.returnDatagram(fragments[0], 0)
if got := backend.returnDatagram(fragments[0], 0); got != nil {
t.Fatal("a duplicate first fragment completed the datagram")
}
var assembled []byte
for _, fragment := range fragments[1:] {
assembled = backend.returnDatagram(fragment, 0)
}
if assembled == nil {
t.Fatal("a duplicated fragment poisoned an otherwise healthy datagram — benign networks duplicate fragments, so this would drop real traffic")
}
if !bytesEqual(header.IPv4(assembled).Payload(), header.IPv4(datagram).Payload()) {
t.Fatal("the duplicate overwrote held bytes instead of losing to them")
}
}
// TestBridgeFragmentCoveredLastFragment: the last fragment (MF=0) carries the
// total length, and when its range is already covered it adds no bytes — but it
// is still the fragment that COMPLETES the datagram.
func TestBridgeFragmentCoveredLastFragment(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(91, udpSegment(fragTestBody(400)))
payload := header.IPv4(datagram).Payload()
// A non-conforming sender: the whole payload arrives with MF=1, then the
// tail arrives again as the last fragment.
whole := ipv4Fragment(datagram, 0, len(payload), true)
tail := ipv4Fragment(datagram, len(payload)-64, 64, false)
if got := backend.returnDatagram(whole, 0); got != nil {
t.Fatal("a datagram with no last fragment was delivered")
}
assembled := backend.returnDatagram(tail, 0)
if assembled == nil {
t.Fatal("the covered last fragment did not complete the datagram — returning early on \"duplicate\" strands it forever, because every later fragment is a duplicate too")
}
if !bytesEqual(header.IPv4(assembled).Payload(), payload) {
t.Fatal("the completed datagram does not match")
}
}
// TestBridgeFragmentTooManyHoles: a sender interleaving holes cannot make the
// bookkeeping grow without bound.
func TestBridgeFragmentTooManyHoles(t *testing.T) {
backend := &backendBase{}
datagram := ipv4Datagram(101, udpSegment(fragTestBody(1200)))
refused := false
for i := range fragMaxRanges + 8 {
// Every other 8-byte slot, so no two ranges are ever adjacent.
piece := ipv4Fragment(datagram, i*16, 8, true)
backend.returnDatagram(piece, 0)
entry := backend.fragments.entries[fragKey{source: fragTestSource, destination: fragTestDestination, id: 101, protocol: uint8(header.UDPProtocolNumber), version: 4}]
if entry == nil {
t.Fatalf("the entry vanished after %d pieces", i+1)
}
if entry.broken {
refused = true
break
}
if len(entry.ranges) > fragMaxRanges {
t.Fatalf("range bookkeeping grew to %d, past the %d ceiling", len(entry.ranges), fragMaxRanges)
}
}
if !refused {
t.Fatalf("a sender interleaved more than %d holes and the entry stayed alive", fragMaxRanges)
}
}
// TestBridgeFragmentMalformed: closed validation — a fragment no conforming
// fragmenter emits is refused rather than reassembled.
//
// EVERY SUBTEST HERE ASSERTS ON THE CACHE, not only on the returned datagram.
// "Nothing came back" is not evidence about a FIRST fragment: one carries MF=1
// and can never complete a datagram, so `got != nil` is unreachable whether the
// packet was refused or accepted. What separates the two is whether an entry
// was allocated — and the control subtest at the bottom is what shows that
// number is capable of being 1.
func TestBridgeFragmentMalformed(t *testing.T) {
datagram := ipv4Datagram(111, udpSegment(fragTestBody(600)))
t.Run("non-final fragment not a multiple of 8", func(t *testing.T) {
backend := &backendBase{}
bad := ipv4Fragment(datagram, 0, 100, true) // 100 % 8 != 0
if got := backend.returnDatagram(bad, 0); got != nil {
t.Fatal("a misaligned non-final fragment was accepted")
}
if len(backend.fragments.entries) != 0 {
t.Fatal("a malformed fragment allocated an entry")
}
})
t.Run("truncated header", func(t *testing.T) {
backend := &backendBase{}
bad := ipv4Fragment(datagram, 0, 256, true)[:12]
if got := backend.returnDatagram(bad, 0); got != nil {
t.Fatal("a truncated fragment was accepted")
}
if len(backend.fragments.entries) != 0 {
t.Fatal("a packet shorter than an IPv4 header allocated an entry")
}
})
// The overread case: the header is whole and says TotalLength 276, but only
// 28 bytes arrived. Parsing this by trusting the header slices past the end
// of the buffer, so it must be refused on the length, not on the header.
t.Run("shorter than its declared TotalLength", func(t *testing.T) {
backend := &backendBase{}
bad := ipv4Fragment(datagram, 0, 256, true)[:header.IPv4MinimumSize+8]
if got := backend.returnDatagram(bad, 0); got != nil {
t.Fatal("a fragment shorter than its own TotalLength was accepted")
}
if len(backend.fragments.entries) != 0 {
t.Fatal("a fragment shorter than its own TotalLength allocated an entry — whatever it cached was read past the end of the caller's buffer")
}
})
t.Run("control: the same shape, aligned, is accepted", func(t *testing.T) {
backend := &backendBase{}
good := ipv4Fragment(datagram, 0, 104, true) // 104 % 8 == 0
backend.returnDatagram(good, 0)
if len(backend.fragments.entries) != 1 {
t.Fatal("a well-formed fragment was refused, so the refusals above prove nothing")
}
})
}
// ---------------------------------------------------------- concurrency -----
// TestBridgeFragmentsConcurrent exercises the cache from several goroutines.
// The bridge read loops are single-threaded per backend today, but the Windows
// diverter fans in from several, and the mutex is what the -race gate checks.
func TestBridgeFragmentsConcurrent(t *testing.T) {
backend := &backendBase{}
const flows = 16
var waiter sync.WaitGroup
assembled := make([]int, flows)
for flow := range flows {
waiter.Add(1)
go func() {
defer waiter.Done()
for round := range 8 {
datagram := ipv4Datagram(uint16(flow*100+round), udpSegment(fragTestBody(600)))
for _, fragment := range ipv4Fragments(datagram, 256) {
if backend.returnDatagram(fragment, 0) != nil {
assembled[flow]++
}
}
}
}()
}
waiter.Wait()
total := 0
for _, count := range assembled {
total += count
}
if total != flows*8 {
t.Fatalf("assembled %d datagrams, want %d — concurrent flows lost datagrams to each other", total, flows*8)
}
}
+28
View File
@@ -67,6 +67,34 @@ func (t *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n
return adapter.JudgeFlow(t.router, t.Tag(), t.Type(), network, source, destination, firstPacket)
}
// lx: NO fragment reassembly here, and that is a finding, not an omission.
//
// The other two tun.Port implementations reassemble IP fragments before
// offering them to the return path, because sing-tun's classifyReturn answers
// `returnPass` for any fragment and the packet is then lost or misrouted (see
// transport/wireguard/frag_reassembly.go and protocol/bridge/frag_reassembly.go).
// The same blindness exists on this path — but not at a seam this package owns:
//
// - The tunnel return path is tstun.Wrapper.Write, which calls
// offerReturnPath BEFORE the inbound filter and before tdevWrite. Our
// tunDeviceAdapter (tun_device_unix.go) is that tdev, i.e. it runs AFTER
// the return path already declined. There is no hook in this tree ahead of
// classifyReturn; github.com/sagernet/tailscale is pinned with no `replace`.
//
// - The one ReturnPackets call this package does make — the BuildUnreachable
// replies in WritePackets below — can never carry a fragment. sing-tun's
// buildRejectICMPv4/v6 synthesise a fresh header (Flags and FragmentOffset
// zero, total length capped at 576), and BuildUnreachable refuses an input
// whose FragmentOffset is non-zero in the first place.
//
// Closing it would mean wrapping returnPath in a reassembling decorator here.
// That is possible, but it cannot simply divert fragments: a datagram the l3
// return path declines belongs to this node's own netstack, which reassembles
// it today — so the decorator would have to duplicate rather than consume, and
// nothing in this project exercises it (with_tailscale is in neither
// scripts/router-tags.sh nor Makefile.lx, so this file is not compiled into any
// binary we ship). Left deliberately undone, with the shape of the fix written
// down, rather than added untested to a hot path.
func (t *Endpoint) AttachReturn(returnPath tun.Return) error {
t.returnAccess.Lock()
defer t.returnAccess.Unlock()
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env bash
#
# check-test-fs-isolation.sh — does running the test suite DAMAGE the machine?
#
# WHY THIS EXISTS (2026-07-27)
# shater/stats/store_test.go called
#
# _ = os.Remove(statsFilePath())
#
# and statsFilePath() is not a test path — it is THE product path,
# /etc/shater/stats.db on any host where /etc/shater exists. So on the testbed
# (local_openwrt) and on the router itself, `go test ./shater/...` deleted the
# accumulated statistics database. Nothing in the tree noticed, because the
# test passed either way: the damage is not an assertion failure, it is a side
# effect.
#
# No amount of grepping for "/etc" finds that one — the path is BUILT BY A
# FUNCTION. The only instrument that sees it is the filesystem itself.
#
# WHAT IT DOES
# 1. seeds a router-shaped canary tree (every persistent path the product code
# names: /etc/shater/*, /etc/config/*, /var/run/*, /tmp/shater-*, ...),
# because a delete of a file that does not exist leaves no trace at all —
# an unseeded run is an instrument that cannot see the very defect it was
# written for;
# 2. snapshots /etc /var /usr /root /home /opt /srv /run /tmp (path, type,
# size, mtime);
# 3. runs the whole Go suite under the SHIPPED tags with TMPDIR pointed at a
# private directory, so t.TempDir()/os.MkdirTemp land somewhere the
# snapshot ignores and everything left in /tmp is a HARDCODED name;
# 4. snapshots again and reports every created / deleted / modified path BY
# NAME. Any difference fails.
# 5. on a difference, BISECTS: re-runs package by package and then test by
# test inside the guilty package, so the report names the test, not just
# the file it ate. That costs time only on a failing run.
#
# CONTAINER ONLY, AND THAT IS THE POINT
# The check works by letting the tests do their worst and then looking. Doing
# that on a real host means doing the damage on that host — on the router,
# this script's own method would eat the stats DB it is meant to protect. So
# it refuses to run outside a container unless SHATER_FS_CHECK_I_KNOW=1.
#
# Usage:
# scripts/check-test-fs-isolation.sh # re-execs into docker
# SHATER_FS_CHECK_IN_DOCKER=1 ... # set by the re-exec; run in place
#
# Env:
# SHATER_GO_IMAGE docker image for the re-exec (default golang:1.26)
# SHATER_FS_CHECK_SKIP_BISECT=1 report paths only, do not hunt the test
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO"
# shellcheck source=router-tags.sh
. "$SCRIPT_DIR/router-tags.sh"
# The same ROOTS run-tests.sh gates, so this check covers exactly the suite that
# gate runs — no more (an untested package cannot damage anything) and no less.
# common/ is run separately, minus its CAP_NET_ADMIN integration tests: without
# the capability those FAIL rather than skip (see run-tests.sh SKIP_COMMON), and
# a run that dies early is a run whose filesystem verdict covers less than it
# claims.
PKG_ROOTS=(./shater/... ./protocol/... ./transport/... ./adapter/... ./route/... ./option/... ./dns/...)
PKG_ROOTS_COMMON=(./common/...)
SKIP_COMMON='^TestIntegration'
# --- re-exec into a container ------------------------------------------------
if [ "${SHATER_FS_CHECK_IN_DOCKER:-0}" != "1" ]; then
if [ "${SHATER_FS_CHECK_I_KNOW:-0}" = "1" ]; then
echo "== fs-isolation check: running IN PLACE on this host (SHATER_FS_CHECK_I_KNOW=1) =="
echo " Any test that writes outside its temp dir will do so HERE, for real."
else
image="${SHATER_GO_IMAGE:-golang:1.26}"
echo "== fs-isolation check: re-exec into docker ($image) =="
host_repo="$REPO"
command -v cygpath >/dev/null 2>&1 && host_repo="$(cygpath -w "$REPO")"
exec env MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 docker run --rm \
-v "$host_repo":/src \
-v shater-tagcheck-gomod:/go/pkg/mod \
-v shater-tagcheck-gocache:/root/.cache/go-build \
-w /src \
-e SHATER_FS_CHECK_IN_DOCKER=1 \
-e SHATER_FS_CHECK_SKIP_BISECT="${SHATER_FS_CHECK_SKIP_BISECT:-0}" \
"$image" bash scripts/check-test-fs-isolation.sh
fi
fi
WATCH=(/etc /var /usr /root /home /opt /srv /run /tmp)
PRIVATE_TMP=/tmp/shater-fscheck-tmp
STATE=/tmp/shater-fscheck-state
mkdir -p "$PRIVATE_TMP" "$STATE"
# Snapshot. -xdev keeps the docker volume mounts (/go, /root/.cache/go-build,
# /src) out by construction; the prunes cover what lives on the container's own
# filesystem and legitimately churns.
# PRUNED, each for a stated reason — this list is the instrument's blind spot and
# every entry widens it:
# $PRIVATE_TMP where TMPDIR sends t.TempDir()/os.MkdirTemp. Excluding it is the
# whole trick: what is left in /tmp is a HARDCODED name.
# $STATE this script's own scratch.
# /root/.config the Go TOOLCHAIN's telemetry counters (…/go/telemetry/*.count),
# rewritten by every `go` invocation including this script's own.
# Not a test, and not something a test can be blamed for.
# /root/.cache /root/go /var/cache /var/lib/apt /var/log/apt
# build caches and the package manager: churn owned by the image.
# -xdev keeps the docker volume mounts (/go, /root/.cache/go-build, /src) out by
# construction.
snapshot() { # $1 = output file
find "${WATCH[@]}" -xdev \
\( -path "$PRIVATE_TMP" -o -path "$STATE" -o -path /var/cache -o -path /var/lib/apt \
-o -path /var/log/apt -o -path /root/.cache -o -path /root/go -o -path /root/.config \) -prune -o \
-printf '%p\t%y\t%s\t%T@\n' 2>/dev/null \
| awk -F'\t' 'BEGIN{OFS="\t"} $2=="d"{$3="-";$4="-"} {print}' | sort >"$1"
}
# ...why a directory's size and mtime are dropped: a directory's mtime moves
# whenever anything inside it is created or removed, so keeping it would report
# /etc/shater a second time for the child that is already named on its own line,
# and would report /root every run because the Go toolchain writes telemetry
# counters under it. Directories are still reported when they are CREATED or
# DELETED. The blind spot this leaves is a file created and removed again inside
# the same run — which the file-level snapshot cannot see either way.
# run_suite_quiet runs the whole gated suite the way run-tests.sh does. Its exit
# status is deliberately NOT this check's verdict — assertions are run-tests.sh's
# job; here the only question is what the run left behind.
run_suite_quiet() { # $1 = log file (appended)
TMPDIR="$PRIVATE_TMP" go test -count=1 -timeout 20m \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
"${PKG_ROOTS[@]}" >>"$1" 2>&1 || true
TMPDIR="$PRIVATE_TMP" go test -count=1 -timeout 20m -skip "$SKIP_COMMON" \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
"${PKG_ROOTS_COMMON[@]}" >>"$1" 2>&1 || true
}
# --- the canary tree ---------------------------------------------------------
# Every persistent path named by a string literal in the product code (grep for
# "/etc|/var|/tmp|/usr in shater/**/*.go, minus _test.go) plus the directories
# they live in. A path that exists can be deleted, truncated or overwritten
# VISIBLY; a path that does not exist absorbs os.Remove without a trace.
CANARY_FILES=(
/etc/shater/stats.db
/etc/shater/cache.db
/etc/shater/shaterd.log
/etc/shater/alert-state.json
/etc/shater/boot.nft
/etc/config/shater
/etc/config/network
/etc/config/firewall
/etc/config/dhcp
/var/run/shaterd.pid
/var/run/shaterd.ctl
/var/run/shater.restarting
/var/run/shater.active
/var/log/shaterd.log
/var/lock/shater.lock
/tmp/dhcp.leases
/tmp/shater-stats.db
/tmp/shater-cache.db
/tmp/ads.lst
)
CANARY_DIRS=(
/etc/shater/lists
/etc/shater/subs
/etc/shater/history
/tmp/shater-subs
/tmp/shater-lists
/tmp/.uci
)
# Only ever CREATE what is missing, and record it, so the canaries can be taken
# away again on exit. That matters for the SHATER_FS_CHECK_I_KNOW=1 path: seeding
# a router with a fake /etc/shater/stats.db and walking away would be this
# script committing the very sin it hunts. A path that already existed is never
# written and never removed.
seed_canaries() {
local f d
for d in "${CANARY_DIRS[@]}"; do
[ -d "$d" ] || { mkdir -p "$d" && echo "$d" >>"$STATE/seeded-dirs"; }
done
for f in "${CANARY_FILES[@]}"; do
mkdir -p "$(dirname "$f")"
[ -e "$f" ] || {
printf 'canary: if this file changed, a test wrote outside its temp dir\n' >"$f"
echo "$f" >>"$STATE/seeded-files"
}
done
}
# unseed removes exactly what seed_canaries created, newest first for the dirs.
unseed() {
local p
if [ -f "$STATE/seeded-files" ]; then
while read -r p; do [ -n "$p" ] && rm -f "$p"; done < <(sort -u "$STATE/seeded-files")
fi
if [ -f "$STATE/seeded-dirs" ]; then
while read -r p; do [ -n "$p" ] && rmdir "$p" 2>/dev/null || true; done < <(sort -ur "$STATE/seeded-dirs")
fi
}
trap unseed EXIT
echo "== [1/3] seeding the router-shaped canary tree =="
seed_canaries
echo " ${#CANARY_FILES[@]} files, ${#CANARY_DIRS[@]} dirs seeded under /etc /var /usr /tmp"
echo " tags: $SHATER_ROUTER_TAGS"
echo
echo "== [2/3] snapshot -> run the suite -> snapshot =="
snapshot "$STATE/before"
echo " before: $(wc -l <"$STATE/before" | tr -d ' ') paths"
: >"$STATE/testlog"
run_suite_quiet "$STATE/testlog"
echo " suites run: $(grep -cE '^(ok|FAIL|---)' "$STATE/testlog" || true) package verdicts (assertions are run-tests.sh's job)"
snapshot "$STATE/after"
echo " after : $(wc -l <"$STATE/after" | tr -d ' ') paths"
echo
echo "== [3/3] verdict =="
# The control: a snapshot that came back empty would make every run "clean".
if [ "$(wc -l <"$STATE/before" | tr -d ' ')" -lt 100 ]; then
echo " FAILED: the before-snapshot has fewer than 100 paths — find(1) is not" >&2
echo " reading the tree, so this check's silence means nothing." >&2
exit 1
fi
diff_out="$(diff "$STATE/before" "$STATE/after" || true)"
if [ -z "$diff_out" ]; then
echo " CLEAN: the whole suite ran and not one path under ${WATCH[*]} changed."
echo " (t.TempDir/os.MkdirTemp went to $PRIVATE_TMP, which is excluded"
echo " by design — everything else is a hardcoded, persistent name.)"
exit 0
fi
# Name what moved, by path, split into the three ways it can move.
awk -F'\t' '/^</ {print $1}' <<<"$diff_out" | sed 's/^< //' | cut -f1 | sort >"$STATE/gone_or_changed"
awk -F'\t' '/^>/ {print $1}' <<<"$diff_out" | sed 's/^> //' | cut -f1 | sort >"$STATE/new_or_changed"
comm -23 "$STATE/gone_or_changed" "$STATE/new_or_changed" >"$STATE/deleted"
comm -13 "$STATE/gone_or_changed" "$STATE/new_or_changed" >"$STATE/created"
comm -12 "$STATE/gone_or_changed" "$STATE/new_or_changed" >"$STATE/modified"
echo " THE SUITE CHANGED THE MACHINE. On the router or the testbed these are not"
echo " scratch files — they are the product's state."
while read -r p; do [ -n "$p" ] && echo " DELETED $p" >&2; done <"$STATE/deleted"
while read -r p; do [ -n "$p" ] && echo " CREATED $p" >&2; done <"$STATE/created"
while read -r p; do [ -n "$p" ] && echo " MODIFIED $p" >&2; done <"$STATE/modified"
echo
if [ "${SHATER_FS_CHECK_SKIP_BISECT:-0}" = "1" ]; then
echo " (bisect disabled by SHATER_FS_CHECK_SKIP_BISECT=1 — the paths above are"
echo " the whole report)"
exit 1
fi
# --- bisect: which package, then which test ----------------------------------
# Only on a failing run, so the cost is paid exactly when it buys something.
echo " hunting the culprit — package by package, then test by test:"
pkgs="$(go list -tags "$SHATER_ROUTER_TAGS" \
-f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' \
"${PKG_ROOTS[@]}" "${PKG_ROOTS_COMMON[@]}" | grep -v '^$')"
found=0
while read -r pkg; do
[ -n "$pkg" ] || continue
seed_canaries
snapshot "$STATE/b1"
TMPDIR="$PRIVATE_TMP" go test -count=1 -timeout 10m \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" "$pkg" >/dev/null 2>&1 || true
snapshot "$STATE/a1"
if diff -q "$STATE/b1" "$STATE/a1" >/dev/null; then
continue
fi
echo " PACKAGE $pkg" >&2
found=1
# ...and now the tests of that package, one at a time.
tests="$(go test -list '.*' -tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" "$pkg" 2>/dev/null | grep -E '^(Test|Fuzz|Example)' || true)"
while read -r tn; do
[ -n "$tn" ] || continue
seed_canaries
snapshot "$STATE/b2"
TMPDIR="$PRIVATE_TMP" go test -count=1 -timeout 10m -run "^${tn}\$" \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" "$pkg" >/dev/null 2>&1 || true
snapshot "$STATE/a2"
if ! diff -q "$STATE/b2" "$STATE/a2" >/dev/null; then
echo " TEST ${pkg}.${tn}" >&2
diff "$STATE/b2" "$STATE/a2" | grep -E '^[<>]' | sed 's/^/ | /' | cut -f1 | sort -u >&2
fi
done <<<"$tests"
done <<<"$pkgs"
if [ "$found" -eq 0 ]; then
echo " no single package reproduced it — the change needs the packages running" >&2
echo " together, or something outside go test moved. Read $STATE/before vs after." >&2
fi
echo
echo "== FS ISOLATION CHECK FAILED — a test writes outside its own temp directory. ==" >&2
exit 1
+102 -9
View File
@@ -53,6 +53,25 @@
# A guard that silently runs nothing is worse than no guard (same rule as
# scripts/check-router-tags.sh).
#
# WHAT IT DOES NOT SEE, AND WHAT DOES (2026-07-27)
# Everything above is about whether a test RAN and what it ASSERTED. None of it
# can see what a test DID to the machine. shater/stats/store_test.go ended with
# `os.Remove(statsFilePath())` — the PRODUCT path — so every run of this gate on
# the testbed or the router deleted /etc/shater/stats.db, and every instrument
# here reported `ok shater/stats`. Six packages were doing something of the kind.
# Two instruments now cover it, and neither is inside the steps below:
# - shater/testguard/fsisolation_test.go — a Go test that reads the SOURCE of
# every _test.go under shater/ and fails BY NAME when a filesystem-mutating
# call is handed a path that is not provably a temp dir. It runs as part of
# [2/7] and [4/7] like any other test, so it needs no step of its own; what
# it cannot see is damage done by PRODUCT code that a test merely calls.
# - scripts/check-test-fs-isolation.sh — the dynamic half, for exactly that
# blind spot: it seeds a router-shaped canary tree in a container, runs this
# whole suite, and diffs. NOT run from here on purpose — it costs a second
# full suite, and its method (let the damage happen, then look) must never
# be pointed at a real /etc. Run it by hand when tests touch anything that
# resolves a product path.
#
# Usage:
# scripts/run-tests.sh # full gate (~3 min warm on the runner)
# scripts/run-tests.sh --no-race # skip the -race pass (faster; local loop)
@@ -123,6 +142,41 @@ SKIP_COMMON='^TestIntegration'
# the race is fixed.
RACE_SKIP='^$'
# --- the per-package deadline ------------------------------------------------
# GOTIMEOUT is `go test`'s own default, 10m. It is WRITTEN DOWN rather than
# inherited because on 2026-07-26 this gate reported a failure that did not
# exist: [4/7] printed
#
# FAIL shater/netplane 600.019s
# panic: test timed out after 10m0s
# running tests: TestApplyIfaceSysctlsCoversRuleDivertedIface
#
# over code that was neither hung nor wrong. Under -race that package really did
# need 663.8 s (measured 2026-07-27, golang:1.26, 32 cores) against 2.0 s without
# it — a 337x factor that no amount of "-race is slower" explains.
#
# THE ANSWER WAS NOT A BIGGER NUMBER, and this comment exists so nobody reaches
# for one next time. The 664 seconds were not work: 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), and ThreadSanitizer sleeps
# `atexit_sleep_ms` — DEFAULT 1000 — before every -race process exits. ~660
# intercepted commands, one wall-clock second each, zero CPU; the per-test times
# came out as near-exact integers (12.17 s, 14.17 s, 129.62 s) because that is
# what they were counting. The children now run with GORACE=atexit_sleep_ms=0
# (shater/netplane/racehelperenv_test.go, which explains what that does and — more
# to the point — what it does NOT cost) and the package is 10.6 s under -race.
# shater/devices had the same disease for the same reason: 0.108 s plain,
# 28.412 s under -race, 0.358 s once its children stopped sleeping.
#
# So the deadline stays where it was, and it stays there as a HANG DETECTOR.
# Measured by this script on 2026-07-27 after both fixes, [4/7] end to end: 56 s,
# slowest package shater/generate at 18.9 s — 10m is ~32x that. If this fires
# again the tests are BLOCKED, not slow: read the goroutine dump the panic prints
# and fix the block. Raising it is only ever an answer with a fresh measurement
# written down beside it, because a deadline raised past a real hang stops being
# a deadline.
GOTIMEOUT=10m
# --- the ONLY skips this gate accepts ----------------------------------------
# A t.Skip is invisible to every other check here: the test binary exits 0, the
# package prints `ok <pkg>`, and the name of the test that did not run appears
@@ -430,7 +484,7 @@ check_skips() { # $1=label ; reads $LOG
run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
local label="$1" extra="$2"
shift 2
local pkgs=("$@") rc=0 expect missing=0 pkg
local pkgs=("$@") rc=0 expect missing=0 pkg timed_out=0 t0=$SECONDS
expect="$(go list -tags "$SHATER_ROUTER_TAGS" \
-f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' \
@@ -450,12 +504,20 @@ run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
# 38/24/24 s with -v. What it costs is OUTPUT: 5 KB -> 257 KB, which is why the
# printing below is filtered rather than the flag dropped.
# shellcheck disable=SC2086 # $extra is a deliberate word-split flag list
go test -count=1 -v $extra \
go test -count=1 -v -timeout "$GOTIMEOUT" $extra \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
"${pkgs[@]}" >"$LOG" 2>&1
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
if [ "$rc" -ne 0 ] && grep -q '^panic: test timed out after ' "$LOG"; then
timed_out=1
fi
if [ "$timed_out" -eq 1 ]; then
# For a deadline the -v transcript ahead of the panic is a quarter of a
# megabyte of PASS lines that say nothing about a block; the goroutine dump
# the panic prints says everything. Print from the panic on.
sed -n '/^panic: test timed out after /,$p' "$LOG" | sed 's/^/ /'
elif [ "$rc" -ne 0 ]; then
# A failure needs the whole story, t.Logf output and all.
sed 's/^/ /' "$LOG"
else
@@ -467,7 +529,31 @@ run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
fi
if [ "$rc" -ne 0 ]; then
echo " FAILED [$label]: go test exited $rc" >&2
# TIMED OUT and FAILED both exit non-zero, and until 2026-07-27 this gate
# printed the same "FAILED [race]: go test exited 1" for both. They are not
# the same event and they call for OPPOSITE actions: a failed assertion says
# the product is wrong, a deadline says nothing at all about the product
# until you know whether the tests were blocked or merely slow. Reading the
# first as the second is how a real hang gets "fixed" with a bigger number.
if [ "$timed_out" -eq 1 ]; then
echo " TIMED OUT [$label] after ${GOTIMEOUT} — NOT a failing assertion. No test said" >&2
echo " the product is wrong; go test's per-package deadline fired." >&2
echo " Still running when it did:" >&2
sed -n '/^[[:space:]]*running tests:/,/^$/p' "$LOG" \
| sed -n 's/^[[:space:]]*\(Test[^[:space:]]*.*\)$/ \1/p' >&2
echo " Two causes, opposite fixes:" >&2
echo " BLOCKED — deadlock, a channel nobody closes, a child process that" >&2
echo " never exits. The goroutine dump printed above names the line each" >&2
echo " of those tests is parked on. Fix the block; do not touch GOTIMEOUT." >&2
echo " SLOW — the suite outgrew the budget. MEASURE it before deciding:" >&2
echo " go test -race -v -timeout 30m ./<pkg>/ 2>&1 | grep -- '--- PASS'" >&2
echo " sorted by the per-test seconds. If the numbers come out as near-exact" >&2
echo " whole seconds, they are counting SLEEPS, not work — see the GOTIMEOUT" >&2
echo " comment at the top of this file, which is the last time that happened." >&2
echo " Raise the deadline only with the new measurement written beside it." >&2
else
echo " FAILED [$label]: go test exited $rc" >&2
fi
FAILED=1
# Name the skips anyway. A suite that failed somewhere else must not become
# a hiding place for a test that did not run — that is the same sin one
@@ -499,7 +585,11 @@ run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
FAILED=1
return
fi
echo " OK [$label]"
# The elapsed seconds are printed on purpose: nothing here recorded a suite's
# total before, so the only thing that ever noticed [4/7] growing towards ten
# minutes was the deadline going off. One number per suite makes a doubling
# visible on the run that causes it.
echo " OK [$label] in $((SECONDS - t0))s"
}
# --- [2/7] the fork's trees, shipped tags, linux -----------------------------
@@ -513,10 +603,13 @@ run_suite common "-skip $SKIP_COMMON" "${ROOTS_COMMON[@]}"
echo
# --- [4/7] -race over the same trees -----------------------------------------
# Everything, not a subset: shater/netplane alone is ~110 s under -race and it is
# the single most concurrency-critical package we own (the nft data plane), so
# once it is in, adding the rest costs ~40 s more. common/ is left out — it is
# upstream code exercised by upstream CI.
# Everything, not a subset: shater/netplane is the single most concurrency-critical
# package we own (the nft data plane), so once it is in, adding the rest is cheap.
# common/ is left out — it is upstream code exercised by upstream CI.
#
# The "~110 s for netplane" that stood here was wrong by six times: measured
# 2026-07-27 it was 663.8 s, which is what fired the deadline. See GOTIMEOUT at
# the top for what those seconds actually were and why they are now 10.6 s.
if [ "$RACE" -eq 1 ]; then
echo "== [4/7] go test -race — the fork's trees =="
echo " nothing is skipped under -race"
+872 -7
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -36,13 +36,41 @@ import (
// performs still run for real from here. They are left alone because nothing
// else in the gate reads those tables, so unlike the devices they have no
// observed victim — not because they are harmless on a machine that matters.
// It ALSO points ActiveFlag at a private directory, for a second reason of the same
// shape. applyLocked raises that flag and Teardown clears it, both for real from here,
// and the shipped value is /var/run/shater.active — the ONE token hotplug and cron check
// before they touch the data plane. So `go test ./shater/apply/` used to delete it on
// whatever machine ran it: on the testbed and on the router, that tells the reconcilers
// the stack is down while it is up, and they stand down. The old
// `t.Cleanup(func() { _ = os.Remove(ActiveFlag) })` in holdstate_test.go was not a
// cleanup at all — it was the delete. Caught 2026-07-27 by
// scripts/check-test-fs-isolation.sh; realActiveFlag below keeps the shipped value so
// the product decision is still asserted (TestActiveFlagIsTheShippedPath).
var realActiveFlag = ActiveFlag
func TestMain(m *testing.M) {
restore := netplane.L3StubKernelForTest()
dir, err := os.MkdirTemp("", "apply-active-flag")
if err != nil {
panic("apply TestMain: cannot make a private dir for ActiveFlag: " + err.Error())
}
ActiveFlag = dir + "/shater.active"
code := m.Run()
_ = os.RemoveAll(dir)
restore()
os.Exit(code)
}
// TestActiveFlagIsTheShippedPath pins what TestMain took away. The location is not
// cosmetic: /var/run is tmpfs, so a reboot clears the flag and nothing reconciles the
// data plane before the init script has run. Moving it to persistent storage would let a
// cron tick touch the plane on a box that has not started yet.
func TestActiveFlagIsTheShippedPath(t *testing.T) {
if realActiveFlag != "/var/run/shater.active" {
t.Errorf("ActiveFlag = %q, want /var/run/shater.active (tmpfs: cleared by a reboot, so nothing reconciles before the init has run)", realActiveFlag)
}
}
// TestCanRollback pins the signal the panel gates its rollback control on:
// canRollback is false on a fresh applier (no armed commit-confirm snapshot AND
// the engine holds no last-good predecessor), and flips to true once a snapshot
+236
View File
@@ -0,0 +1,236 @@
package apply
// THE FIRST-BOOT DEADLOCK, NAMED.
//
// THE STATE. Every subscription is pulled through the tunnel; the tunnel is built
// from nodes that only a subscription fetch supplies; the node caches are gone.
// The fetch is refused for want of an outbound, the outbound is missing for want
// of the fetch, and cron repeats the pair every 300 seconds forever. With
// `kill_switch=closed` the LAN is dark throughout.
//
// WHY IT NEEDED A WARNING RATHER THAN A LOG LINE. The refusal reaches stderr, and
// /etc/init.d/shater-cron sends stderr to /dev/null; the daemon's own line reaches
// syslog, which `globals.log_syslog='0'` switches off. Neither channel is
// guaranteed, and the state is one line to fix and impossible to deduce from what
// is visible. Warnings is carried in /api/status, which survives both.
//
// THE CONTROLS ARE THE POINT of this file. The condition has two halves and each
// half has several ways of being false; a warning that fired on any ordinary
// configuration would be worse than no warning at all, because this one is
// deliberately alarming. So every "it fires" below is paired with the same
// instrument staying silent when a single field changes.
import (
"strings"
"testing"
"github.com/sagernet/sing-box/shater/model"
"github.com/sagernet/sing-box/shater/netplane"
)
// deadlockModel is the measured shape: one proxy-fetched subscription with a real
// detour, and NOTHING to build that detour out of — no nodes (the caches are
// gone), no egresses.
func deadlockModel(subs ...model.Subscription) *model.Model {
g := model.DefaultGlobals()
g.KillSwitch = "closed"
g.Untunnelable = netplane.UntunnelableBlock
if len(subs) == 0 {
subs = []model.Subscription{{
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=t",
FetchVia: "proxy", FetchDetour: "group:auto",
}}
}
return &model.Model{Globals: g, Subscriptions: subs}
}
// deadlockFindings returns the findings this file is about, read out of the
// PUBLISHED set so a finding that never reaches collectWarnings fails the test.
func deadlockFindings(t *testing.T, m *model.Model) []Warning {
t.Helper()
var out []Warning
for _, w := range collectWarnings(m, nil, nil, nil) {
if w.Section == subscriptionSection && strings.Contains(w.Message, "DEADLOCK") {
out = append(out, w)
}
}
return out
}
func oneDeadlock(t *testing.T, m *model.Model) Warning {
t.Helper()
got := deadlockFindings(t, m)
if len(got) != 1 {
t.Fatalf("got %d deadlock findings, want exactly 1: %+v", len(got), got)
}
return got[0]
}
// TestBootstrapDeadlockIsNamedWithAWayOut is the defect.
//
// RED BEFORE: nothing anywhere produced a finding for this model. The apply
// succeeds, generate is happy, and the only trace was a CLI refusal on a stream
// that goes to /dev/null.
func TestBootstrapDeadlockIsNamedWithAWayOut(t *testing.T) {
w := oneDeadlock(t, deadlockModel())
if w.Severity != SeverityCritical {
t.Errorf("severity = %q, want %q: nothing recovers from this by waiting", w.Severity, SeverityCritical)
}
if w.Name != "qomar" {
t.Errorf("Name = %q, want the subscription's own name so the panel badges its row", w.Name)
}
for _, want := range []string{
// WHAT the state is, in words that do not read as a transient failure.
"DEADLOCK",
"waits for the tunnel",
// WHERE the caches live, because "restore them" is meaningless without it.
"/etc/shater/subs",
// That the dark LAN is the kill switch working, not a second fault — an
// operator who concludes otherwise switches off the protection.
"kill_switch=closed",
// And the two escapes, with the free one first and the costly one priced.
"add a manual node",
// The free escape is only free if it is also COMPLETE: adding a node does
// nothing unless the detour, which is resolved by name, names it. An earlier
// draft of this message said "add a node and the next refresh has something
// to travel through", which is false for the `group:auto` detour in this
// very fixture.
"point this subscription's `fetch_detour` at it",
"fetch_via='direct'",
"shaterd sub update",
"real IP address",
} {
if !strings.Contains(w.Message, want) {
t.Errorf("message missing %q:\n%s", want, w.Message)
}
}
}
// TestBootstrapDeadlockSaysWhetherAnythingIsStillRetrying: the sentence about the
// 5-minute retry is true only of a subscription cron actually walks. Switched off,
// nothing is retrying and the state simply sits there — a different thing for the
// operator to know, and the reason the message is not one fixed string.
func TestBootstrapDeadlockSaysWhetherAnythingIsStillRetrying(t *testing.T) {
on := oneDeadlock(t, deadlockModel())
if !strings.Contains(on.Message, "every 5 minutes") {
t.Errorf("an enabled subscription IS retried by cron and the message must say so:\n%s", on.Message)
}
offSub := model.Subscription{
Name: "qomar", Enabled: false, URL: "https://provider.example/sub?token=t",
FetchVia: "proxy", FetchDetour: "group:auto",
}
off := oneDeadlock(t, deadlockModel(offSub))
if strings.Contains(off.Message, "every 5 minutes") {
t.Errorf("nothing retries a switched-off subscription:\n%s", off.Message)
}
if !strings.Contains(off.Message, "switched off") {
t.Errorf("the message must say why nothing is retrying:\n%s", off.Message)
}
}
// TestBootstrapDeadlockIsSilentWhenAnythingCanBuildAnOutbound is CONTROL HALF 1.
// Each case leaves the subscription exactly as it is and adds ONE thing that
// could carry the fetch. If the warning survives any of them it is not measuring
// the deadlock, it is measuring "a proxy-fetched subscription exists".
func TestBootstrapDeadlockIsSilentWhenAnythingCanBuildAnOutbound(t *testing.T) {
cases := []struct {
name string
mutate func(*model.Model)
}{
{"a manual node", func(m *model.Model) {
m.Nodes = []model.Node{{Name: "tokyo", Enabled: true, URI: "ss://tokyo"}}
}},
{"a node still in the subscription cache", func(m *model.Model) {
m.Nodes = []model.Node{{Name: "n1", Enabled: true, URI: "ss://n1", FromSub: "qomar"}}
}},
{"an interface egress, which needs no nodes at all", func(m *model.Model) {
m.Egresses = []model.Egress{{Name: "wg0", Type: "interface", Interface: "wg0"}}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := deadlockModel()
tc.mutate(m)
if got := deadlockFindings(t, m); len(got) != 0 {
t.Errorf("%s is enough to carry the fetch — there is no deadlock: %+v", tc.name, got)
}
})
}
// A node that exists but is switched OFF builds nothing, so the deadlock is
// real. This is the control ON THE CONTROL: without it, "any node at all"
// would pass the three cases above and still be the wrong test.
m := deadlockModel()
m.Nodes = []model.Node{{Name: "tokyo", Enabled: false, URI: "ss://tokyo"}}
if got := deadlockFindings(t, m); len(got) != 1 {
t.Errorf("a disabled node is not an outbound; the deadlock stands: %d findings", len(got))
}
}
// TestBootstrapDeadlockIsSilentWhenSomethingCanBeFetchedWithoutATunnel is CONTROL
// HALF 2: one subscription that does not need the engine breaks the cycle, and
// the warning must go away even though everything else is unchanged.
func TestBootstrapDeadlockIsSilentWhenSomethingCanBeFetchedWithoutATunnel(t *testing.T) {
stuck := model.Subscription{
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=t",
FetchVia: "proxy", FetchDetour: "group:auto",
}
for _, second := range []model.Subscription{
{Name: "plain", Enabled: true, URL: "https://p.example/s", FetchVia: "direct"},
{Name: "plain", Enabled: true, URL: "https://p.example/s", FetchVia: ""},
// proxy with an EMPTY detour resolves to the `direct` outbound: it leaks,
// loudly, via subscriptionFetchWarnings — but it is not stuck, and reporting
// a deadlock over it would send the operator to fix the wrong thing.
{Name: "leaky", Enabled: true, URL: "https://p.example/s", FetchVia: "proxy"},
} {
m := deadlockModel(stuck, second)
if got := deadlockFindings(t, m); len(got) != 0 {
t.Errorf("%q can be fetched with no engine, so nothing is deadlocked: %+v", second.Name, got)
}
}
// And with no URL there is no fetch to deadlock over at all.
if got := deadlockFindings(t, deadlockModel(model.Subscription{
Name: "empty", Enabled: true, FetchVia: "proxy", FetchDetour: "group:auto",
})); len(got) != 0 {
t.Errorf("a subscription with no URL is not a deadlock: %+v", got)
}
// The POSITIVE control for this whole test: strip the escape hatch back out and
// the same instrument fires again.
if got := deadlockFindings(t, deadlockModel(stuck)); len(got) != 1 {
t.Fatalf("the instrument must still be able to report: got %d findings", len(got))
}
}
// TestBootstrapDeadlockNamesAllTheStuckSubscriptions: with several, the opening
// clause has to say so, or an operator who fixes one wonders why nothing changed.
func TestBootstrapDeadlockNamesAllTheStuckSubscriptions(t *testing.T) {
a := model.Subscription{Name: "qomar", Enabled: true, URL: "https://a.example/s",
FetchVia: "proxy", FetchDetour: "group:auto"}
b := model.Subscription{Name: "backup", Enabled: true, URL: "https://b.example/s",
FetchVia: "proxy", FetchDetour: "node:tokyo"}
w := oneDeadlock(t, deadlockModel(a, b))
for _, want := range []string{"2 subscriptions", "qomar", "backup"} {
if !strings.Contains(w.Message, want) {
t.Errorf("message missing %q:\n%s", want, w.Message)
}
}
}
// TestOrdinaryConfigurationsProduceNoDeadlockFinding is the broadest control: the
// model every other test in this package uses is healthy, and this instrument must
// have nothing to say about it.
func TestOrdinaryConfigurationsProduceNoDeadlockFinding(t *testing.T) {
if got := deadlockFindings(t, subFetchModel(proxySub("qomar", "group:auto"))); len(got) != 0 {
t.Errorf("a healthy configuration must produce no deadlock finding: %+v", got)
}
if got := deadlockFindings(t, &model.Model{Globals: model.DefaultGlobals()}); len(got) != 0 {
t.Errorf("a configuration with no subscriptions at all cannot be deadlocked: %+v", got)
}
if got := deadlockFindings(t, nil); len(got) != 0 {
t.Errorf("nil model: %+v", got)
}
}

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